diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..8e3ff067f6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,60 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: Bug +assignees: '' + +--- + + +- [ ] I have searched open and closed issues for duplicates + + + +### Bug Description + + + +### Steps to Reproduce + + + +1. step one +2. step two +3. step three + +Actual Result: + + + +Expected Result: + + + +### Screenshots + + + +### Platform Info + +Friendica Version: + + + +Friendica Source: + +PHP version: + +SQL version: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..7bdd4f066c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Friendica Community Support + url: https://forum.friendi.ca/ + about: Please ask and answer questions here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..7484a8362a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,22 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: New Feature +assignees: '' + +--- + +### Is the feature request related to a problem? Please describe. + + + +### Describe alternatives you've considered + + + +### Additional context \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000000..86df437339 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,10 @@ +--- +name: Question / Support +about: Select this if you have a question +title: '' +labels: Support Request +assignees: '' + +--- + +# For general question about Friendica, please try to find a solution at https://wiki.friendi.ca first. \ No newline at end of file diff --git a/.github/issue_template.md b/.github/issue_template.md deleted file mode 100644 index e1ae36e709..0000000000 --- a/.github/issue_template.md +++ /dev/null @@ -1,15 +0,0 @@ -### Expected behavior - -### Actual behavior - -### Steps to reproduce the problem - -### Friendica version you encountered the problem - -see `example.com/friendica` on your Friendica node for the version information. - -### Friendica source (git, zip) - -### PHP version - -### SQL version diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000000..91a8b60747 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,101 @@ +name: Testing Friendica +on: [push, pull_request] + +jobs: + friendica: + name: Friendica (PHP ${{ matrix.php-versions }}) + runs-on: ubuntu-latest + services: + mariadb: + image: mariadb:latest + env: + MYSQL_ALLOW_EMPTY_PASSWORD: true + MYSQL_DATABASE: test + MYSQL_PASSWORD: test + MYSQL_USER: test + ports: + - 3306/tcp + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + redis: + image: redis + ports: + - 6379/tcp + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + memcached: + image: memcached + ports: + - 11211/tcp + strategy: + fail-fast: false + matrix: + php-versions: ['7.2', '7.3', '7.4'] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: pecl, composer:v1 + extensions: pdo_mysql, gd, zip, opcache, ctype, pcntl, ldap, apcu, memcached, redis, imagick, memcache + coverage: xdebug + ini-values: apc.enabled=1, apc.enable_cli=1 + + - name: Start mysql service + run: sudo /etc/init.d/mysql start + + - name: Validate composer.json and composer.lock + run: composer validate + + - name: Get composer cache directory + id: composercache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist + + - name: Copy default Friendica config + run: cp config/local-sample.config.php config/local.config.php + + - name: Verify MariaDB connection + env: + PORT: ${{ job.services.mariadb.ports[3306] }} + run: | + while ! mysqladmin ping -h"127.0.0.1" -P"$PORT" --silent; do + sleep 1 + done + + - name: Setup MYSQL database + env: + PORT: ${{ job.services.mariadb.ports[3306] }} + run: | + mysql -h"127.0.0.1" -P"$PORT" -utest -ptest test < database.sql + + - name: Test with Parallel-lint + run: vendor/bin/parallel-lint --exclude vendor/ --exclude view/asset/ . + + - name: Test with phpunit + run: vendor/bin/phpunit --configuration tests/phpunit.xml --coverage-clover clover.xml + env: + MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: ${{ job.services.mariadb.ports[3306] }} + MYSQL_DATABASE: test + MYSQL_PASSWORD: test + MYSQL_USER: test + REDIS_PORT: ${{ job.services.redis.ports[6379] }} + REDIS_HOST: 127.0.0.1 + MEMCACHED_PORT: ${{ job.services.memcached.ports[11211] }} + MEMCACHE_PORT: ${{ job.services.memcached.ports[11211] }} + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 + with: + file: clover.xml diff --git a/.gitignore b/.gitignore index 2d8acf0160..3250fb0761 100644 --- a/.gitignore +++ b/.gitignore @@ -71,8 +71,8 @@ venv/ /addons /addon -#ignore .htaccess -.htaccess +#ignore base .htaccess +/.htaccess #ignore filesystem storage default path /storage diff --git a/.htaccess-dist b/.htaccess-dist index a671cc680a..3c90982515 100644 --- a/.htaccess-dist +++ b/.htaccess-dist @@ -1,3 +1,6 @@ +# This file is meant to be copied to ".htaccess" on Apache-powered web servers. +# The created .htaccess file can be edited manually and will not be overwritten by Friendica updates. + Options -Indexes AddType application/x-java-archive .jar AddType audio/ogg .oga diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5e4c3483b7..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,31 +0,0 @@ ---- -language: php -## Friendica officially supports PHP version >= 7.1 -php: - - 7.1 - - 7.2 - - 7.3 - -services: - - mysql - - redis - - memcached -env: - - MYSQL_HOST=localhost MYSQL_PORT=3306 MYSQL_USERNAME=travis MYSQL_PASSWORD="" MYSQL_DATABASE=test - -install: - - composer install -before_script: - - cp config/local-sample.config.php config/local.config.php - - mysql -e 'CREATE DATABASE IF NOT EXISTS test;' - - mysql -utravis test < database.sql - - pecl channel-update pecl.php.net - - pecl config-set preferred_state beta - - phpenv config-add .travis/redis.ini - - phpenv config-add .travis/memcached.ini - -script: - - vendor/bin/parallel-lint --exclude vendor/ --exclude view/asset/ . - - vendor/bin/phpunit --configuration tests/phpunit.xml --coverage-clover clover.xml - -after_success: bash <(curl -s https://codecov.io/bash) diff --git a/.travis/apcu.ini b/.travis/apcu.ini deleted file mode 100644 index 92598662cf..0000000000 --- a/.travis/apcu.ini +++ /dev/null @@ -1,4 +0,0 @@ -extension="apcu.so" - -apc.enabled = 1 -apc.enable_cli = 1 \ No newline at end of file diff --git a/.travis/memcached.ini b/.travis/memcached.ini deleted file mode 100644 index c9a2ff0c9a..0000000000 --- a/.travis/memcached.ini +++ /dev/null @@ -1 +0,0 @@ -extension="memcached.so" \ No newline at end of file diff --git a/.travis/redis.ini b/.travis/redis.ini deleted file mode 100644 index ab995b8374..0000000000 --- a/.travis/redis.ini +++ /dev/null @@ -1 +0,0 @@ -extension="redis.so" \ No newline at end of file diff --git a/CHANGELOG b/CHANGELOG index 197b2bca05..97a12d55d9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,11 +1,269 @@ -Version 2020.06 (unreleased) +Version 2021.03 (unreleased) + Friendica Core + Removed the frontend worker [annando] + + Friendica Addons + + Closed Issues + +Version 2021.01 (2021-01-04) + Friendica Core + Added HU translation + Updates to the translations: DE, IT, RU [translation teams] + Updates to the themes (duepuntozero, frio, vier) [annando, MrPetovan, tobiasd, vinzv] + General Code cleanup [annando, MrPetovan, nupplaphil] + Enhanced the handling of permission sets [annando] + Enhanced the usage of system resources when displaying photos and updating contacts [annando] + Enhanced the database structure [annando, Quix0r] + Enhanced the detection of PeerTube servers [annando] + Enhanced the photo cache [annando] + Enhanced the import of old postings which would otherwise not be imported due their age [annando] + Enhanced the delivery process of ActivityPub content [annando] + Enhanced the performance profiler [annando] + Enhanced the background worker [annando] + Enhanced the handling of blocked authors [MrPetovan] + Enhanced the user management in the admin panel [MrPetovan] + Enhanced the process of expiring postings [annando] + Enhanced the un/follow process of contacts [annando] + Enhanced the handling of HTTP requests [nupplaphil] + Enhanced filter possibilities of contacts [annando] + Enhanced language detection of postings [annando] + Enhanced the admin panel [MrPetovan, tobiasd] + Enhanced the contact suggestions [annando] + Enhanced the community page (filter, tags) [annando] + Enhanced the display of the reason why a posting is displayed in a stream [annando] + Enhanced the forum delivery of postings [redmatrix] + Enhanced PHP8 compatibility [annando] + Enhanced the worker_cooldown mechanism [annando] + Added new options to the remote_self feature [annando] + Added API endpoints for accounts and trends [annando] + Added API endpoints for re-sharing of postings [annando] + Added provider fields to the API [annando] + Added the possibility to map $_SERVER variables during installation [nupplaphil] + Added the possibility to filter account types on the network page [annando] + Added missing Mastodon API endpoints as "unsupported" [annando] + Added a watchdog mode to check if the daemon is running [annando] + Added number of group members to the contact widget [annando] + Added endless scrolling in several places [annando] + Added an option to stay local when clicking on a contact profile [annando] + Added support of ActivityPub relays [annando] + Model\User::getAuthenticationInfo is now available for addons [MrPetovan] + Contact details can only edited for mail and feed contacts [annando] + Fixed some problems during the export of user data [annando] + Fixed various problems with the notification system [MrPetovan] + Fixed a problem with emoticon alt-text interpretation [MrPetovan] + Fixed a problem that caused comments on Tweets being distributed via ActivityPub [annando] + Fixed a problem with the auto-completion when composing comments [MrPetovan] + Fixed an ACL problem while poking contacts [MrPetovan] + Fixed a problem with Mastodon emoticons [MrPetovan] + Fixed a parser problem that caused additional
tags [annando] + Fixed escaping of several HTML snippets [MrPetovan] + Fixed a problem with fetching objects by URL [annando] + + Friendica Addons + Updated to the translations IT, HU [translation teams] + advancedcontentfilter: + Added examples [hoergen] + blackout: + Improved the wording in the admin interface [urbalazs] + catavatar: + Improved the generation of avatars [annando] + ifttt: + Added support for delayed postings [annando] + mailstream: + Improved code structure [nupplaphil] + Fix case-sensitive check by [nupplaphil] + markdown: + Improved parsing [MrPetovan] + newmemberwidget: + Improved addon description [SpencerDub] + langfilter: + Changed the input to use a slider [MrPetovan] + ldapauth: + Reworked the authentication code [MrPetovan] + libravatar: + Fixed a problem with DNS requests [annando] + Improved the list of available avatars [annando] + phpmailer: + Fixed UTF8 encoding problems [MrPetovan] + rendertime: + Added more information about the "other" things that cost time [annando] + showmore: + Improved handling of the HTML structure of postings [MrPetovan] + showmore_dyn: + Improved user settings, language [MrPetovan] + twitter: + Improved logging [annando] + Improved the twitter_post_hook [MrPetovan] + Improved the posts send to twitter [annando] + Improved the remote_self functionality [annando] + Added support for delayed postings [annando] + Fixed a bug with direct re-shares [MrPetovan] + + Closed Issues + 2803, 4230, 4486, 4494, 5616, 7393, 7697, 8485, 8533, 8605, 8689, + 8796, 8896, 8943, 8950, 9042, 9089, 9127, 9142, 9165, 9235, 9236, + 9238, 9249, 9264, 9268, 9276, 9281, 9291, 9296, 9305, 9306, 9315, + 9326, 9328, 9329, 9337, 9344, 9348, 9363, 9383, 9385, 9407, 9427, + 9430, 9432, 9457, 9461, 9464, 9465, 9480, 9486, 9496, 9508, 9518, + 9525, 9538, 9549, 9564, 9568, 9573, 9598, 9611, 9622, 9629, 9630, + 9633, 9636, 9639, 9641, 9642, 9662, 9672, 9673, 9678, 9682, 9692, + 9712 + +Version 2020.09-1 (2020-09-24) Friendica Core: + Updates to the translations: RU [translation teams] + Enhanced forum delivery using attached mention tags [redmatrix] + Enhanced code test-ability [nupplaphil] + Enhanced character set detection when parsing URLs [MrPetovan] + Enhanced the Activity Pub relay functionality [annando] + Added phpseclib dependency to replace standalone ASN1 library [nupplaphil] + Fixed a bug generating Message-IDs for notification mails [nupplaphil] + Fixed missing uri-ids in the database [annando] + Fixed a display problem with the new re-shares [annando] Friendica Addons: - showmore_dyn: - New addon to collapse long post depending on their actual height [wiwie] + nominatim: + Added addon to resolve coordinates with OpenStreetmap [annando] + phpmailer: + Fixed a bug that prevented notification mails being send [nupplaphil] Closed Issues: + 9142, 9264 + +Version 2020.09 (2020-09-20) + Friendica Core: + Updates to the translations: DE, EN GB, EN US, ES, FR, IT, NL, PL, RU, ZH_CN [translation teams] + Updates to the themes (all) [MrPetovan, tobiasd] + Updates to the documentation [annando, mpanhans, realkinetix, tobiasd] + General code cleanup and refactoring [annando, MrPetovan, nupplaphil] + Enhanced the API [annando] + Enhanced the processing of background jobs [annando] + Enhanced federation of activities [annando, vpzomtrrfrt] + Enhanced the user notifications[annando] + Enhanced database usage [annando, MrPetovan] + Enhanced ActivityPub support for forums [annando] + Enhanced the utilization of the cache [annando, MrPetovan] + Enhanced the performance of the daemon [annando] + Enhanced the communication with the directory servers [annando] + Enhanced the re-sharing of items [annando] + Enhanced sample lighttpd and nginx configs [MrPetovan, tobiasd] + Enhanced the checks for incoming postings using ActivityPub [annando, Roger Meyer] + Enhanced the import of RSS feeds by removing tracking pixels [annando] + Enhanced the speed of the full text search [annando] + Replaced library used for text completion [MrPetovan] + Fixed a problem that prevented recipients of direct messages to be selected [MrPetovan] + Fixed a problem that prevented new email contacts from being added [annando] + Fixed a problem with the console command search [tobiasd] + Fixed a problem during the search for contacts [annando] + Fixed a problem with the JOT of private notes [MrPetovan] + Fixed missing HTML encoding [MrPetovan] + Fixed a layout problem with the frio composer for new postings [MrPetovan] + Fixed some composer notices [nupplaphil] + Fixed a problem for empty preview data when importing feed posts [annando] + Fixed a problem with the pager on search result pages [annando] + Fixed some templates to show the correct un-/follow button for contacts [annando] + Fixed a problem with the generation of the Message-ID of notification emails [nupplaphil] + Added nodeinfo2 support [annando] + Added CSV export and import of blocked servers to the console [tobiasd] + Added new admin debug module for ActivityPub [MrPetovan] + Added the automatic determination of frequency to pull feeds [annando] + Added signed fetching from system users for ActivityPub [annando] + Added the discovery of new peers from contacts [annando] + Added the directory API endpoint [annando] + Added support for signed outbox requests [annando] + Added direction functionality for clarification of posting flow [annando] + Added the ability to set the database version [annando] + Added support for ActivityPub relay server [annando] + By default display of re-sharer information is now flattened [annando] + Removed some unused POCO functionality [annando] + Removed the unused rating functionality [annando] + Removed unneeded network request for local stuff [annando] + Removed some useless info messages [annando] + Reworked some additional features according to a user voting [MrPetovan] + + Friendica Addons: + Updates to the translations: DE, EN GB, EN US, IT, NL, RU, ZH_CN [translation teams] + Updates to the docs [SpencerDub] + General code cleanup and maintenance [annando, MrPetovan] + blockbot: + added some "good" bots [annando] + forumdirectory: + fixed some SQL queries [MrPetovan] + phpmailer: + fixed a problem leading to double message ID headers [nupplaphil] + qcomment: + restructured the addon and fixed a bug preventing the addon from working [MrPetovan] + + Closed Issues: + 2811, 4606, 5742, 5782, 7660, 8676, 8788, 8797, 8798, 8847, 8860, + 8874, 8882, 8885, 8906, 8914, 8922, 8928, 8929, 8935, 8940, 8941, + 8956, 8958, 8961, 8967, 8989, 8993, 8994, 8995, 8997, 8999, 9000, + 9004, 9013, 9015, 9051, 9064, 9065, 9072, 9081, 9090, 9091, 9099, + 9107, 9135, 9136, 9137, 9138, 9140, 9142, 9150, 9153, 9154, 9163, + 9164, 9172, 9182, 9192, 9193, 9204, 9210, 9229, 9231, 9246 + +Version 2020.07-1 (2020-09-08) + Friendica Core + Fixed a problem that leaked sensitive information [Roger Meyer, MrPetovan] + +Version 2020.07 (2020-07-12) + Friendica Core: + Update to the translations: DE, EN GB, EN US, FR, ET, NL, PL, RU, ZH-CN [translation teams] + Updates to the themes (frio, vier) [MrPetovan] + Updated the shipped composer version, and the dependency list [annando, MrPetovan, tobiasd] + Updates to the documentation [MrPetovan] + General code refactoring and enhancements [AlfredSK, annando, MrPetovan] + Replace charged terms with "allowlist", "denylist" and "blocklist" [MrPetovan] + Enhanced the comment distribution in threads that involve diaspora*, AP and DFRN actors [annando] + Enhanced the profile probing mechanism [annando, MrPetovan] + Enhanced the post update process of the database [annando] + Enhanced the database performance [annando] + Enhanced ActivityPub attachment handling [MrPetovan] + Enhanced security of redirections [annando] + Enhanced database performance [annando] + Enhanced the handling of BBCode [pre] tags [MrPetovan] + Enhanced Markdown to BBCode conversion [MrPetovan] + Enhanced the speed of the network page [annando] + Fixed a problem recognising logins via the API [MrPetovan] + Fixed a problem with handling local diaspora* URLs [MrPetovan] + Fixed a problem with implicit mentions [annando] + Fixed a problem with the password reset token security [lynn-stephenson] + Fixed a problem with receiving non-public posts via ActivityPub [annando] + Fixed a problem with the photo endpoint of the API [MrPetovan] + Fixed a problem with pressing the ESC key in the frio-theme [MrPetovan] + Fixed a problem with the display if post categories [annando] + Fixed a problem with validation of feeds [annando] + Fixed a problem that prevented AP activities being fetched sometimes [annando] + Renamed the -q option of the console user delete command to -y [MrPetovan] + Added notification count to page title [MrPetovan] + Added handling of relative URLs during feed detection [MrPetovan] + Added entities [nupplaphil] + + Friendica Addons: + Update to the translations (EN GB, NB NO, NL, PL, RU, ZH CN) [translation teams] + blockbot: + The list of accepted user agents was enhanced [annando] + Diaspora*: + Enhanced conntector settings [MrPetovan] + PHP Mailer SMTP: + Updated phpmailer version [dependabot] + showmore_dyn: + New addon to collapse long post depending on their actual height [wiwie] + twitter: + Enhaceed the handling of mobile twitter URLs [annando] + Enhanced the handling of quoted tweets [MrPetovan] + added HTML error code handling [MrPetovan] + various: + enhancements to the probe mechanism [MrPetovan] + + Closed Issues: + 3084, 3884, 8287, 8314, 8374, 8400, 8425, 8432, 8458, 8470, 8477, + 8482, 8488, 8489, 8490, 8493, 8495, 8498, 8511, 8517, 8523, 8527, + 8551, 8553, 8560, 8564, 8565, 8568, 8578, 8586, 8593, 8606, 8610, + 8612, 8626, 8664, 8672, 8683, 8685, 8691, 8694, 8702, 8709, 8714, + 8717, 8722, 8726, 8732, 8736, 8743, 8744, 8746, 8756, 8766, 8769, + 8781, 8800, 8807, 8808, 8827, 8829, 8836, 8844, 8846, 8857, 8866 Version 2020.03 "Red Hot Poker" (2020-03-30) Friendica Core: @@ -61,7 +319,7 @@ Version 2020.03 "Red Hot Poker" (2020-03-30) Update to the translations (CS, DE, FR, PL, RU, ZH-CN) [translation teams] General code refactoring and enhancements [AndyHee, annando, MrPetovan, nupplaphil] blockbot: - Ensure that good agents are whitelisted [valvin1] + Ensure that good agents are allowlisted [valvin1] markdown: Addon to use Markdown while composing a posting was added [annando] showmore: @@ -617,7 +875,7 @@ Version 2018.09 (2018-09-23) Version 2018.05 (2018-06-01) Friendica Core: Update to the translations (DE, EN-GB, EN-US, FI, IS, IT, NL, PL, RU, ZN CH) [translation teams] - Update to the documentation [andyhee, annando, fabrixxm, M-arcus, MrPedovan, rudloff, tobiasd] + Update to the documentation [andyhee, annando, fabrixxm, M-arcus, MrPetovan, rudloff, tobiasd] Enhancements to the DB handling [annando] Enhancements to the relay system [annando] Enhancements to the handling of URL that contain unicode characters [annando] @@ -911,7 +1169,7 @@ Version 3.5.3 (2017-10-05) Updates to the documentation [tobiasd] Code revision and refactoring [Hypolite] pumpio, twitter bridges adopted to new background mechanism [annando] - Leistungsschutzrecht has a new source list, and a whitelist [annando] + Leistungsschutzrecht has a new source list, and an allowlist [annando] retriever marked unsupported due to unwanted side-effects [annando] Unicode emoji added [annando] Enhancement to the general content filter [annando] @@ -1373,7 +1631,7 @@ Version 3.3.1 (2014-11-06) Set default location to empty for new users. Suppress warning on user creation (issue #1193) (fabrixxm) Correctly build urls with queries (issue #1190) (fabrixxm) Optionally use keywords in feed as post tags with "remote self" (annando) - A blacklist of keywords to not use can be defined (annando) + A denylist of keywords to not use can be defined (annando) "remote self" works also with Friendica and Diaspora contacts (annando) Show exact post time after 12 hours (FX7) Optionally redirect from non-SSL to SSL (annando) diff --git a/CREDITS.txt b/CREDITS.txt index 4f794e0d91..2f68970548 100644 --- a/CREDITS.txt +++ b/CREDITS.txt @@ -9,6 +9,7 @@ Aditoo AgnesElisa Albert Alberto Díaz Tormo +Aleksandr "M.O.Z.G" Dikov Alex Alexander An Alexander Fortin @@ -34,6 +35,7 @@ Athalbert aweiher axelt balderino +Balázs Úr Beanow beardyunixer Beatriz Vital @@ -54,6 +56,7 @@ Chris Case Christian González Christian M. Grube Christian Vogeley +Christian Wiwie Cohan Robinson Copiis Praeesse CrystalStiletto @@ -113,7 +116,6 @@ Hypolite Petovan Ilmari ImgBotApp irhen -Jak Jakob Jens Tautenhahn jensp @@ -121,6 +123,7 @@ Jeroen De Meerleer jeroenpraat Joan Bar JOduMonT +joe slam Johannes Schwab John Brazil Jonatan Nyberg @@ -131,6 +134,8 @@ julia.domagalska Julio Cova Karel Karolina +Kastal András +Keenan Pepper Keith Fernie Klaus Weidenbach Koyu Berteon @@ -141,10 +146,11 @@ Leberwurscht Leonard Lausen Lionel Triay loma-one +loma1 Lorem Ipsum Ludovic Grossard +Lynn Stephenson maase2 -Magdalena Gazda Mai Anh Nguyen Manuel Pérez Monís Marcin Klessa @@ -153,7 +159,6 @@ Marcus Müller Marie Olive Mariusz Pisz marmor -Marquis_de_Carabas Martin Schmitt Mateusz Mikos Mats Sjöberg @@ -169,9 +174,11 @@ Michal Šupler Michalina Mike Macgirvin miqrogroove +mpanhans mytbk nathilia-peirce Nicola Spanti +nobody Olaf Conradi Oliver Olivier @@ -208,6 +215,7 @@ repat Ricardo Pereira Rik 4 RJ Madsen +Roger Meyer Roland Häder Rui Andrada rwa @@ -219,23 +227,22 @@ Samuli Valavuo Sandro Santilli Sebastian Egbers sella -Sennewood Seth Silke Meyer Simon L'nu Simó Albert i Beltran softmetz soko1 -SpencerDub +Spencer Dub St John Karp Stanislav N. Steffen K9 StefOfficiel +steve jobs Sveinn í Felli Sven Anders Sylke Vicious Sylvain Lagacé -szymon.filip Sérgio Lima Taekus Tazman DeVille @@ -263,7 +270,6 @@ U-SOUND\mike ufic Ulf Rompe Unknown -Valvin Valvin A Vasudev Kamath Vasya Novikov diff --git a/README.md b/README.md index 1406678be8..a7494d7f8d 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,7 @@ Have a look at the [installation documentation](doc/Install.md) for further info |*Vier theme, desktop browser. Public timeline view.*| |![Vier theme in desktop browser](images/screenshots/friendica-vier-community.png?raw=true "Vier theme in desktop browser") |*Vier theme, desktop browser. Community post displayed.*| + +## Endorsements + +- [![Awesome Humane Tech](images/humane-tech-badge.svg)](https://github.com/humanetech-community/awesome-humane-tech) On August 12th 2020, Friendica was added to [the curated Awesome Humane Tech directory](https://github.com/humanetech-community/awesome-humane-tech) in [the "Fediverse" category](https://github.com/humanetech-community/awesome-humane-tech#fediverse). diff --git a/VERSION b/VERSION index 51ae1fea5c..50ccf18a2d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2020.06-dev +2021.03-dev diff --git a/bin/.htaccess b/bin/.htaccess new file mode 100644 index 0000000000..716a932e1c --- /dev/null +++ b/bin/.htaccess @@ -0,0 +1,10 @@ +# This file prevents browser access to Friendica command-line scripts on Apache-powered web servers. +# It isn't meant to be edited manually, please check the base Friendica folder for the .htaccess-dist file instead. + + + Require all denied + + + Order Allow,Deny + Deny from all + diff --git a/bin/auth_ejabberd.php b/bin/auth_ejabberd.php index f00615f02b..d6e20dfe15 100755 --- a/bin/auth_ejabberd.php +++ b/bin/auth_ejabberd.php @@ -51,9 +51,14 @@ * */ +if (php_sapi_name() !== 'cli') { + header($_SERVER["SERVER_PROTOCOL"] . ' 403 Forbidden'); + exit(); +} + use Dice\Dice; use Friendica\App\Mode; -use Friendica\Util\ExAuth; +use Friendica\Security\ExAuth; use Psr\Log\LoggerInterface; if (sizeof($_SERVER["argv"]) == 0) { @@ -80,6 +85,7 @@ $dice = $dice->addRule(LoggerInterface::class,['constructParams' => ['auth_ejabb $appMode = $dice->create(Mode::class); if ($appMode->isNormal()) { - $oAuth = new ExAuth(); + /** @var ExAuth $oAuth */ + $oAuth = $dice->create(ExAuth::class); $oAuth->readStdin(); } diff --git a/bin/composer.phar b/bin/composer.phar index 57fce9576e..4cb50a573b 100755 Binary files a/bin/composer.phar and b/bin/composer.phar differ diff --git a/bin/console.php b/bin/console.php index 27522d8554..4d5b4c79c2 100755 --- a/bin/console.php +++ b/bin/console.php @@ -20,6 +20,11 @@ * */ +if (php_sapi_name() !== 'cli') { + header($_SERVER["SERVER_PROTOCOL"] . ' 403 Forbidden'); + exit(); +} + use Dice\Dice; use Psr\Log\LoggerInterface; diff --git a/bin/daemon.php b/bin/daemon.php index c2ce05c8e0..fcdd735668 100755 --- a/bin/daemon.php +++ b/bin/daemon.php @@ -23,11 +23,18 @@ * This script was taken from http://php.net/manual/en/function.pcntl-fork.php */ +if (php_sapi_name() !== 'cli') { + header($_SERVER["SERVER_PROTOCOL"] . ' 403 Forbidden'); + exit(); +} + use Dice\Dice; +use Friendica\App\Mode; use Friendica\Core\Logger; use Friendica\Core\Worker; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Util\DateTimeFormat; use Psr\Log\LoggerInterface; // Get options @@ -59,6 +66,8 @@ if (DI::mode()->isInstall()) { die("Friendica isn't properly installed yet.\n"); } +DI::mode()->setExecutor(Mode::DAEMON); + DI::config()->load(); if (empty(DI::config()->get('system', 'pidfile'))) { @@ -138,34 +147,35 @@ Logger::notice('Starting worker daemon.', ["pid" => $pid]); if (!$foreground) { echo "Starting worker daemon.\n"; - // Switch over to daemon mode. - if ($pid = pcntl_fork()) { - return; // Parent - } - - fclose(STDIN); // Close all of the standard - - // Enabling this seem to block a running php process with 100% CPU usage when there is an outpout - // fclose(STDOUT); // file descriptors as we - // fclose(STDERR); // are running as a daemon. - DBA::disconnect(); + // Fork a daemon process + $pid = pcntl_fork(); + if ($pid == -1) { + echo "Daemon couldn't be forked.\n"; + Logger::warning('Could not fork daemon'); + exit(1); + } elseif ($pid) { + // The parent process continues here + echo 'Child process started with pid ' . $pid . ".\n"; + Logger::notice('Child process started', ['pid' => $pid]); + file_put_contents($pidfile, $pid); + exit(0); + } + + // We now are in the child process register_shutdown_function('shutdown'); + // Make the child the main process, detach it from the terminal if (posix_setsid() < 0) { return; } - if ($pid = pcntl_fork()) { - return; // Parent - } + // Closing all existing connections with the outside + fclose(STDIN); - $pid = getmypid(); - file_put_contents($pidfile, $pid); - - // We lose the database connection upon forking - DBA::reconnect(); + // And now connect the database again + DBA::connect(); } DI::config()->set('system', 'worker_daemon_mode', true); @@ -185,7 +195,12 @@ while (true) { $do_cron = true; } - Worker::spawnWorker($do_cron); + if ($do_cron || (!DI::process()->isMaxLoadReached() && Worker::entriesExists() && Worker::isReady())) { + Worker::spawnWorker($do_cron); + } else { + Logger::info('Cool down for 5 seconds', ['pid' => $pid]); + sleep(5); + } if ($do_cron) { // We force a reconnect of the database connection. @@ -195,8 +210,9 @@ while (true) { $last_cron = time(); } - Logger::info("Sleeping", ["pid" => $pid]); $start = time(); + Logger::info("Sleeping", ["pid" => $pid, 'until' => gmdate(DateTimeFormat::MYSQL, $start + $wait_interval)]); + do { $seconds = (time() - $start); @@ -204,9 +220,14 @@ while (true) { // Background: After jobs had been started, they often fork many workers. // To not waste too much time, the sleep period increases. $arg = (($seconds + 1) / ($wait_interval / 9)) + 1; - $sleep = round(log10($arg) * 1000000, 0); + $sleep = min(1000000, round(log10($arg) * 1000000, 0)); usleep($sleep); + $pid = pcntl_waitpid(-1, $status, WNOHANG); + if ($pid > 0) { + Logger::info('Children quit via pcntl_waitpid', ['pid' => $pid, 'status' => $status]); + } + $timeout = ($seconds >= $wait_interval); } while (!$timeout && !Worker::IPCJobsExists()); diff --git a/bin/dev/make_credits.py b/bin/dev/make_credits.py index d895213909..be7a52e322 100755 --- a/bin/dev/make_credits.py +++ b/bin/dev/make_credits.py @@ -34,7 +34,7 @@ dontinclude = ['root', 'friendica', 'bavatar', 'tony baldwin', 'Taek', 'silke m' path = os.path.abspath(argv[0].split('bin/dev/make_credits.py')[0]) print('> base directory is assumed to be: '+path) # a place to store contributors -contributors = ["Andi Stadler", "Ratten", "Vít Šesták 'v6ak'"] +contributors = ["Andi Stadler", "Ratten", "Roger Meyer", "Vít Šesták 'v6ak'"] # get the contributors print('> getting contributors to the friendica core repository') p = subprocess.Popen(['git', 'shortlog', '--no-merges', '-s'], diff --git a/bin/testargs.php b/bin/testargs.php index b7d7125f7a..9aed353037 100644 --- a/bin/testargs.php +++ b/bin/testargs.php @@ -26,6 +26,10 @@ * */ +if (php_sapi_name() !== 'cli') { + header($_SERVER["SERVER_PROTOCOL"] . ' 403 Forbidden'); + exit(); +} if (($_SERVER["argc"] > 1) && isset($_SERVER["argv"][1])) { echo $_SERVER["argv"][1]; diff --git a/bin/wait-for-connection b/bin/wait-for-connection index b6c03a6705..de860e9849 100755 --- a/bin/wait-for-connection +++ b/bin/wait-for-connection @@ -24,6 +24,11 @@ * Usage: php bin/wait-for-connection {HOST} {PORT} [{TIMEOUT}] */ +if (php_sapi_name() !== 'cli') { + header($_SERVER["SERVER_PROTOCOL"] . ' 403 Forbidden'); + exit(); +} + $timeout = 60; switch ($argc) { case 4: diff --git a/bin/worker.php b/bin/worker.php index 1b70a20955..52400a045a 100755 --- a/bin/worker.php +++ b/bin/worker.php @@ -21,8 +21,14 @@ * Starts the background processing */ +if (php_sapi_name() !== 'cli') { + header($_SERVER["SERVER_PROTOCOL"] . ' 403 Forbidden'); + exit(); +} + use Dice\Dice; use Friendica\App; +use Friendica\App\Mode; use Friendica\Core\Update; use Friendica\Core\Worker; use Friendica\DI; @@ -53,6 +59,8 @@ $dice = $dice->addRule(LoggerInterface::class,['constructParams' => ['worker']]) DI::init($dice); $a = DI::app(); +DI::mode()->setExecutor(Mode::WORKER); + // Check the database structure and possibly fixes it Update::check($a->getBasePath(), true, DI::mode()); @@ -76,4 +84,4 @@ Worker::processQueue($run_cron); Worker::unclaimProcess(); -Worker::endProcess(); +DI::process()->end(); diff --git a/boot.php b/boot.php index a070e103ea..1c5b4c168c 100644 --- a/boot.php +++ b/boot.php @@ -38,7 +38,7 @@ use Friendica\Util\DateTimeFormat; define('FRIENDICA_PLATFORM', 'Friendica'); define('FRIENDICA_CODENAME', 'Red Hot Poker'); -define('FRIENDICA_VERSION', '2020.06-dev'); +define('FRIENDICA_VERSION', '2021.03-dev'); define('DFRN_PROTOCOL_VERSION', '2.23'); define('NEW_UPDATE_ROUTINE_VERSION', 1170); @@ -201,6 +201,7 @@ define('PRIORITY_HIGH', 20); define('PRIORITY_MEDIUM', 30); define('PRIORITY_LOW', 40); define('PRIORITY_NEGLIGIBLE', 50); +define('PRIORITIES', [PRIORITY_CRITICAL, PRIORITY_HIGH, PRIORITY_MEDIUM, PRIORITY_LOW, PRIORITY_NEGLIGIBLE]); /* @}*/ /** @@ -253,10 +254,10 @@ function public_contact() if (!$public_contact_id && !empty($_SESSION['authenticated'])) { if (!empty($_SESSION['my_address'])) { // Local user - $public_contact_id = intval(Contact::getIdForURL($_SESSION['my_address'], 0, true)); + $public_contact_id = intval(Contact::getIdForURL($_SESSION['my_address'], 0, false)); } elseif (!empty($_SESSION['visitor_home'])) { // Remote user - $public_contact_id = intval(Contact::getIdForURL($_SESSION['visitor_home'], 0, true)); + $public_contact_id = intval(Contact::getIdForURL($_SESSION['visitor_home'], 0, false)); } } elseif (empty($_SESSION['authenticated'])) { $public_contact_id = false; @@ -266,7 +267,7 @@ function public_contact() } /** - * Returns contact id of authenticated site visitor or false + * Returns public contact id of authenticated site visitor or false * * @return int|bool visitor_id or false */ @@ -382,38 +383,6 @@ function is_site_admin() return local_user() && $admin_email && in_array($a->user['email'] ?? '', $adminlist); } -function explode_querystring($query) -{ - $arg_st = strpos($query, '?'); - if ($arg_st !== false) { - $base = substr($query, 0, $arg_st); - $arg_st += 1; - } else { - $base = ''; - $arg_st = 0; - } - - $args = explode('&', substr($query, $arg_st)); - foreach ($args as $k => $arg) { - /// @TODO really compare type-safe here? - if ($arg === '') { - unset($args[$k]); - } - } - $args = array_values($args); - - if (!$base) { - $base = $args[0]; - unset($args[0]); - $args = array_values($args); - } - - return [ - 'base' => $base, - 'args' => $args, - ]; -} - /** * Returns the complete URL of the current page, e.g.: http(s)://something.com/network * diff --git a/composer.json b/composer.json index 20cbe46a77..c242924543 100644 --- a/composer.json +++ b/composer.json @@ -34,34 +34,39 @@ "league/html-to-markdown": "^4.8", "level-2/dice": "^4", "lightopenid/lightopenid": "dev-master", + "matriphe/iso-639": "^1.2", "michelf/php-markdown": "^1.7", "mobiledetect/mobiledetectlib": "^2.8", "monolog/monolog": "^1.25", "nikic/fast-route": "^1.3", "paragonie/hidden-string": "^1.0", + "patrickschur/language-detection": "^3.4", "pear/console_table": "^1.3", - "pear/text_languagedetect": "1.*", + "phpseclib/phpseclib": "^2.0", "pragmarx/google2fa": "^5.0", "pragmarx/recovery": "^0.1.0", "psr/container": "^1.0", "seld/cli-prompt": "^1.0", "smarty/smarty": "^3.1", + "xemlock/htmlpurifier-html5": "^0.1.11", "fxp/composer-asset-plugin": "^1.4", "bower-asset/base64": "^1.0", "bower-asset/chart-js": "^2.8", "bower-asset/dompurify": "^1.0", + "bower-asset/fork-awesome": "^1.1", "bower-asset/vue": "^2.6", + "npm-asset/cropperjs": "1.2.2", "npm-asset/es-jquery-sortable": "^0.9.13", + "npm-asset/fullcalendar": "^3.10", + "npm-asset/imagesloaded": "4.1.4", "npm-asset/jquery": "^2.0", "npm-asset/jquery-colorbox": "^1.6", "npm-asset/jquery-datetimepicker": "^2.5", "npm-asset/jgrowl": "^1.4", "npm-asset/moment": "^2.24", - "npm-asset/fullcalendar": "^3.10", - "npm-asset/cropperjs": "1.2.2", - "npm-asset/imagesloaded": "4.1.4", - "npm-asset/typeahead.js": "^0.11.1", - "bower-asset/fork-awesome": "^1.1" + "npm-asset/perfect-scrollbar": "0.6.16", + "npm-asset/textcomplete": "^0.18.2", + "npm-asset/typeahead.js": "^0.11.1" }, "repositories": [ { @@ -74,14 +79,10 @@ "Friendica\\": "src/", "Friendica\\Addon\\": "addon/" }, - "psr-0": { - "": "library/" - }, "files": [ "include/conversation.php", "include/dba.php", "include/enotify.php", - "include/items.php", "boot.php" ] }, @@ -91,6 +92,9 @@ } }, "config": { + "platform": { + "php": "7.0" + }, "autoloader-suffix": "Friendica", "optimize-autoloader": true, "preferred-install": "dist", @@ -124,7 +128,7 @@ "mikey179/vfsstream": "^1.6", "mockery/mockery": "^1.2", "johnkary/phpunit-speedtrap": "1.1", - "jakub-onderka/php-parallel-lint": "^1.0" + "php-parallel-lint/php-parallel-lint": "^1.2" }, "scripts": { "test": "phpunit" diff --git a/composer.lock b/composer.lock index 52f1e80a3a..779c3b51b3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0d0fe0cafbe7bd050b41a5c9e96dba5f", + "content-hash": "7d8031c9b95fd94d8872804759a26509", "packages": [ { "name": "asika/simple-console", @@ -87,16 +87,16 @@ }, { "name": "bower-asset/Chart-js", - "version": "v2.8.0", + "version": "v2.9.3", "source": { "type": "git", "url": "https://github.com/chartjs/Chart.js.git", - "reference": "947d8a7ccfbfc76dd9d384ea75436fa4a7aeefb1" + "reference": "06f73dc3590084b2c464bf08189c7aee2b6b92d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/chartjs/Chart.js/zipball/947d8a7ccfbfc76dd9d384ea75436fa4a7aeefb1", - "reference": "947d8a7ccfbfc76dd9d384ea75436fa4a7aeefb1", + "url": "https://api.github.com/repos/chartjs/Chart.js/zipball/06f73dc3590084b2c464bf08189c7aee2b6b92d2", + "reference": "06f73dc3590084b2c464bf08189c7aee2b6b92d2", "shasum": "" }, "type": "bower-asset-library", @@ -115,20 +115,20 @@ "MIT" ], "description": "Simple HTML5 charts using the canvas element.", - "time": "2019-03-14T13:03:00+00:00" + "time": "2019-11-14T18:37:30+00:00" }, { "name": "bower-asset/base64", - "version": "1.0.2", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/davidchambers/Base64.js.git", - "reference": "10f0e9990dab0a73009fc106ff2b88102a0a13cf" + "reference": "660b299aa4854843fd35d42b30eda9273125b9da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/davidchambers/Base64.js/zipball/10f0e9990dab0a73009fc106ff2b88102a0a13cf", - "reference": "10f0e9990dab0a73009fc106ff2b88102a0a13cf", + "url": "https://api.github.com/repos/davidchambers/Base64.js/zipball/660b299aa4854843fd35d42b30eda9273125b9da", + "reference": "660b299aa4854843fd35d42b30eda9273125b9da", "shasum": "" }, "type": "bower-asset-library", @@ -146,7 +146,7 @@ "WTFPL" ], "description": "Base64 encoding and decoding", - "time": "2019-02-12T17:19:36+00:00" + "time": "2019-11-02T20:07:47+00:00" }, { "name": "bower-asset/dompurify", @@ -239,16 +239,16 @@ }, { "name": "bower-asset/vue", - "version": "v2.6.10", + "version": "v2.6.11", "source": { "type": "git", "url": "https://github.com/vuejs/vue.git", - "reference": "e90cc60c4718a69e2c919275a999b7370141f3bf" + "reference": "ec78fc8b6d03e59da669be1adf4b4b5abf670a34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vuejs/vue/zipball/e90cc60c4718a69e2c919275a999b7370141f3bf", - "reference": "e90cc60c4718a69e2c919275a999b7370141f3bf", + "url": "https://api.github.com/repos/vuejs/vue/zipball/ec78fc8b6d03e59da669be1adf4b4b5abf670a34", + "reference": "ec78fc8b6d03e59da669be1adf4b4b5abf670a34", "shasum": "" }, "type": "bower-asset-library" @@ -394,21 +394,24 @@ }, { "name": "ezyang/htmlpurifier", - "version": "v4.7.0", + "version": "v4.13.0", "source": { "type": "git", "url": "https://github.com/ezyang/htmlpurifier.git", - "reference": "ae1828d955112356f7677c465f94f7deb7d27a40" + "reference": "08e27c97e4c6ed02f37c5b2b20488046c8d90d75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/ae1828d955112356f7677c465f94f7deb7d27a40", - "reference": "ae1828d955112356f7677c465f94f7deb7d27a40", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/08e27c97e4c6ed02f37c5b2b20488046c8d90d75", + "reference": "08e27c97e4c6ed02f37c5b2b20488046c8d90d75", "shasum": "" }, "require": { "php": ">=5.2" }, + "require-dev": { + "simpletest/simpletest": "dev-master#72de02a7b80c6bb8864ef9bf66d41d2f58f826bd" + }, "type": "library", "autoload": { "psr-0": { @@ -416,11 +419,14 @@ }, "files": [ "library/HTMLPurifier.composer.php" + ], + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL" + "LGPL-2.1-or-later" ], "authors": [ { @@ -434,7 +440,7 @@ "keywords": [ "html" ], - "time": "2015-08-05T01:03:42+00:00" + "time": "2020-06-29T00:56:53+00:00" }, { "name": "friendica/json-ld", @@ -541,27 +547,29 @@ }, { "name": "guzzlehttp/guzzle", - "version": "6.3.3", + "version": "6.5.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba" + "reference": "9d4290de1cfd701f38099ef7e183b64b4b7b0c5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/407b0cb880ace85c9b63c5f9551db498cb2d50ba", - "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/9d4290de1cfd701f38099ef7e183b64b4b7b0c5e", + "reference": "9d4290de1cfd701f38099ef7e183b64b4b7b0c5e", "shasum": "" }, "require": { + "ext-json": "*", "guzzlehttp/promises": "^1.0", - "guzzlehttp/psr7": "^1.4", - "php": ">=5.5" + "guzzlehttp/psr7": "^1.6.1", + "php": ">=5.5", + "symfony/polyfill-intl-idn": "^1.17.0" }, "require-dev": { "ext-curl": "*", "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0", - "psr/log": "^1.0" + "psr/log": "^1.1" }, "suggest": { "psr/log": "Required for using the Log middleware" @@ -569,16 +577,16 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "6.3-dev" + "dev-master": "6.5-dev" } }, "autoload": { - "files": [ - "src/functions_include.php" - ], "psr-4": { "GuzzleHttp\\": "src/" - } + }, + "files": [ + "src/functions_include.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -602,7 +610,7 @@ "rest", "web service" ], - "time": "2018-04-22T15:46:56+00:00" + "time": "2020-06-16T21:01:06+00:00" }, { "name": "guzzlehttp/promises", @@ -792,16 +800,16 @@ }, { "name": "level-2/dice", - "version": "4.0.1", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/Level-2/Dice.git", - "reference": "e631f110f0520294fec902814c61cac26566023c" + "reference": "b9336d9200d0165c31e982374dc5d8d2552807bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Level-2/Dice/zipball/e631f110f0520294fec902814c61cac26566023c", - "reference": "e631f110f0520294fec902814c61cac26566023c", + "url": "https://api.github.com/repos/Level-2/Dice/zipball/b9336d9200d0165c31e982374dc5d8d2552807bc", + "reference": "b9336d9200d0165c31e982374dc5d8d2552807bc", "shasum": "" }, "require": { @@ -834,7 +842,7 @@ "di", "ioc" ], - "time": "2019-05-01T12:55:36+00:00" + "time": "2020-01-28T13:47:49+00:00" }, { "name": "lightopenid/lightopenid", @@ -870,22 +878,69 @@ "time": "2013-10-27T16:25:49+00:00" }, { - "name": "michelf/php-markdown", - "version": "1.8.0", + "name": "matriphe/iso-639", + "version": "1.2", "source": { "type": "git", - "url": "https://github.com/michelf/php-markdown.git", - "reference": "01ab082b355bf188d907b9929cd99b2923053495" + "url": "https://github.com/matriphe/php-iso-639.git", + "reference": "0245d844daeefdd22a54b47103ffdb0e03c323e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/michelf/php-markdown/zipball/01ab082b355bf188d907b9929cd99b2923053495", - "reference": "01ab082b355bf188d907b9929cd99b2923053495", + "url": "https://api.github.com/repos/matriphe/php-iso-639/zipball/0245d844daeefdd22a54b47103ffdb0e03c323e1", + "reference": "0245d844daeefdd22a54b47103ffdb0e03c323e1", + "shasum": "" + }, + "require-dev": { + "phpunit/phpunit": "^4.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matriphe\\ISO639\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Muhammad Zamroni", + "email": "halo@matriphe.com" + } + ], + "description": "PHP library to convert ISO-639-1 code to language name.", + "keywords": [ + "639", + "iso", + "iso-639", + "lang", + "language", + "laravel" + ], + "time": "2017-07-19T15:11:19+00:00" + }, + { + "name": "michelf/php-markdown", + "version": "1.9.0", + "source": { + "type": "git", + "url": "https://github.com/michelf/php-markdown.git", + "reference": "c83178d49e372ca967d1a8c77ae4e051b3a3c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/michelf/php-markdown/zipball/c83178d49e372ca967d1a8c77ae4e051b3a3c75c", + "reference": "c83178d49e372ca967d1a8c77ae4e051b3a3c75c", "shasum": "" }, "require": { "php": ">=5.3.0" }, + "require-dev": { + "phpunit/phpunit": ">=4.3 <5.8" + }, "type": "library", "autoload": { "psr-4": { @@ -913,7 +968,7 @@ "keywords": [ "markdown" ], - "time": "2018-01-15T00:49:33+00:00" + "time": "2019-12-02T02:32:27+00:00" }, { "name": "mobiledetect/mobiledetectlib", @@ -969,16 +1024,16 @@ }, { "name": "monolog/monolog", - "version": "1.25.1", + "version": "1.25.4", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "70e65a5470a42cfec1a7da00d30edb6e617e8dcf" + "reference": "3022efff205e2448b560c833c6fbbf91c3139168" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/70e65a5470a42cfec1a7da00d30edb6e617e8dcf", - "reference": "70e65a5470a42cfec1a7da00d30edb6e617e8dcf", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/3022efff205e2448b560c833c6fbbf91c3139168", + "reference": "3022efff205e2448b560c833c6fbbf91c3139168", "shasum": "" }, "require": { @@ -992,11 +1047,10 @@ "aws/aws-sdk-php": "^2.4.9 || ^3.0", "doctrine/couchdb": "~1.0@dev", "graylog2/gelf-php": "~1.0", - "jakub-onderka/php-parallel-lint": "0.9", "php-amqplib/php-amqplib": "~2.4", "php-console/php-console": "^3.1.3", + "php-parallel-lint/php-parallel-lint": "^1.0", "phpunit/phpunit": "~4.5", - "phpunit/phpunit-mock-objects": "2.3.0", "ruflin/elastica": ">=0.90 <3.0", "sentry/sentry": "^0.13", "swiftmailer/swiftmailer": "^5.3|^6.0" @@ -1043,7 +1097,7 @@ "logging", "psr-3" ], - "time": "2019-09-06T13:49:17+00:00" + "time": "2020-05-22T07:31:27+00:00" }, { "name": "nikic/fast-route", @@ -1270,12 +1324,69 @@ "time": "2017-07-06T13:46:38+00:00" }, { - "name": "npm-asset/fullcalendar", - "version": "3.10.1", + "name": "npm-asset/eventemitter3", + "version": "2.0.3", "dist": { "type": "tar", - "url": "https://registry.npmjs.org/fullcalendar/-/fullcalendar-3.10.1.tgz", - "shasum": "cca3f9a2656a7e978a3f3facb7f35934a91185db" + "url": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "shasum": "b5e1079b59fb5e1ba2771c0a993be060a58c99ba" + }, + "type": "npm-asset-library", + "extra": { + "npm-asset-bugs": { + "url": "https://github.com/primus/eventemitter3/issues" + }, + "npm-asset-main": "index.js", + "npm-asset-directories": [], + "npm-asset-repository": { + "type": "git", + "url": "git://github.com/primus/eventemitter3.git" + }, + "npm-asset-scripts": { + "build": "mkdir -p umd && browserify index.js -s EventEmitter3 | uglifyjs -m -o umd/eventemitter3.min.js", + "benchmark": "find benchmarks/run -name '*.js' -exec benchmarks/start.sh {} \\;", + "test": "nyc --reporter=html --reporter=text mocha", + "test-browser": "zuul -- test.js", + "prepublish": "npm run build", + "sync": "node versions.js" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Arnout Kazemier" + } + ], + "description": "EventEmitter3 focuses on performance while maintaining a Node.js AND browser compatible interface.", + "homepage": "https://github.com/primus/eventemitter3#readme", + "keywords": [ + "EventEmitter", + "EventEmitter2", + "EventEmitter3", + "Events", + "addEventListener", + "addListener", + "emit", + "emits", + "emitter", + "event", + "once", + "pub/sub", + "publish", + "reactor", + "subscribe" + ], + "time": "2017-03-31T14:51:09+00:00" + }, + { + "name": "npm-asset/fullcalendar", + "version": "3.10.2", + "dist": { + "type": "tar", + "url": "https://registry.npmjs.org/fullcalendar/-/fullcalendar-3.10.2.tgz", + "shasum": "9b1ba84bb02803621b761d1bba91a4f18affafb7" }, "type": "npm-asset-library", "extra": { @@ -1313,7 +1424,7 @@ "full-sized", "jquery-plugin" ], - "time": "2019-08-10T16:05:46+00:00" + "time": "2020-05-19T03:44:55+00:00" }, { "name": "npm-asset/imagesloaded", @@ -1647,11 +1758,11 @@ }, { "name": "npm-asset/moment", - "version": "2.24.0", + "version": "2.26.0", "dist": { "type": "tar", - "url": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", - "shasum": "0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" + "url": "https://registry.npmjs.org/moment/-/moment-2.26.0.tgz", + "shasum": "5e1f82c6bafca6e83e808b30c8705eed0dcbd39a" }, "type": "npm-asset-library", "extra": { @@ -1665,8 +1776,12 @@ "url": "git+https://github.com/moment/moment.git" }, "npm-asset-scripts": { - "typescript-test": "tsc --project typing-tests", + "ts3.1-typescript-test": "cross-env node_modules/typescript3/bin/tsc --project ts3.1-typing-tests", + "typescript-test": "cross-env node_modules/typescript/bin/tsc --project typing-tests", "test": "grunt test", + "eslint": "eslint Gruntfile.js tasks src", + "prettier-check": "prettier --check Gruntfile.js tasks src", + "prettier-fmt": "prettier --write Gruntfile.js tasks src", "coverage": "nyc npm test && nyc report", "coveralls": "nyc npm test && nyc report --reporter=text-lcov | coveralls" }, @@ -1709,7 +1824,7 @@ } ], "description": "Parse, validate, manipulate, and display dates", - "homepage": "http://momentjs.com", + "homepage": "https://momentjs.com", "keywords": [ "date", "ender", @@ -1721,20 +1836,78 @@ "time", "validate" ], - "time": "2019-01-21T21:10:34+00:00" + "time": "2020-05-20T06:46:22+00:00" + }, + { + "name": "npm-asset/perfect-scrollbar", + "version": "0.6.16", + "dist": { + "type": "tar", + "url": "https://registry.npmjs.org/perfect-scrollbar/-/perfect-scrollbar-0.6.16.tgz", + "shasum": "b1d61a5245cf3962bb9a8407a3fc669d923212fc" + }, + "type": "npm-asset-library", + "extra": { + "npm-asset-bugs": { + "url": "https://github.com/noraesae/perfect-scrollbar/issues" + }, + "npm-asset-files": [ + "dist", + "src", + "index.js", + "jquery.js", + "perfect-scrollbar.d.ts" + ], + "npm-asset-main": "./index.js", + "npm-asset-directories": [], + "npm-asset-repository": { + "type": "git", + "url": "git+https://github.com/noraesae/perfect-scrollbar.git" + }, + "npm-asset-scripts": { + "test": "gulp", + "before-deploy": "gulp && gulp compress", + "release": "rm -rf dist && gulp && npm publish" + }, + "npm-asset-engines": { + "node": ">= 0.12.0" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Hyunje Jun", + "email": "me@noraesae.net" + }, + { + "name": "Hyunje Jun", + "email": "me@noraesae.net" + } + ], + "description": "Minimalistic but perfect custom scrollbar plugin", + "homepage": "https://github.com/noraesae/perfect-scrollbar#readme", + "keywords": [ + "frontend", + "jquery-plugin", + "scroll", + "scrollbar" + ], + "time": "2017-01-10T01:03:05+00:00" }, { "name": "npm-asset/php-date-formatter", - "version": "v1.3.5", + "version": "v1.3.6", "source": { "type": "git", "url": "https://github.com/kartik-v/php-date-formatter.git", - "reference": "d842e1c4e6a8d6108017b726321c305bb5ae4fb5" + "reference": "514a53660b0d69439236fd3cbc3f41512adb00a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kartik-v/php-date-formatter/zipball/d842e1c4e6a8d6108017b726321c305bb5ae4fb5", - "reference": "d842e1c4e6a8d6108017b726321c305bb5ae4fb5", + "url": "https://api.github.com/repos/kartik-v/php-date-formatter/zipball/514a53660b0d69439236fd3cbc3f41512adb00a0", + "reference": "514a53660b0d69439236fd3cbc3f41512adb00a0", "shasum": "" }, "type": "npm-asset-library", @@ -1759,7 +1932,101 @@ ], "description": "A Javascript datetime formatting and manipulation library using PHP date-time formats.", "homepage": "https://github.com/kartik-v/php-date-formatter", - "time": "2018-07-13T06:56:46+00:00" + "time": "2020-04-14T10:16:32+00:00" + }, + { + "name": "npm-asset/textarea-caret", + "version": "3.1.0", + "dist": { + "type": "tar", + "url": "https://registry.npmjs.org/textarea-caret/-/textarea-caret-3.1.0.tgz", + "shasum": "5d5a35bb035fd06b2ff0e25d5359e97f2655087f" + }, + "type": "npm-asset-library", + "extra": { + "npm-asset-bugs": { + "url": "https://github.com/component/textarea-caret-position/issues" + }, + "npm-asset-files": [ + "index.js" + ], + "npm-asset-main": "index.js", + "npm-asset-directories": [], + "npm-asset-repository": { + "type": "git", + "url": "git+https://github.com/component/textarea-caret-position.git" + } + }, + "license": [ + "MIT" + ], + "description": "(x, y) coordinates of the caret in a textarea or input type='text'", + "homepage": "https://github.com/component/textarea-caret-position#readme", + "keywords": [ + "caret", + "position", + "textarea" + ], + "time": "2018-02-20T06:11:03+00:00" + }, + { + "name": "npm-asset/textcomplete", + "version": "0.18.2", + "dist": { + "type": "tar", + "url": "https://registry.npmjs.org/textcomplete/-/textcomplete-0.18.2.tgz", + "shasum": "de0d806567102f7e32daffcbcc3db05af1515eb5" + }, + "require": { + "npm-asset/eventemitter3": ">=2.0.3,<3.0.0", + "npm-asset/textarea-caret": ">=3.0.1,<4.0.0", + "npm-asset/undate": ">=0.2.3,<0.3.0" + }, + "type": "npm-asset-library", + "extra": { + "npm-asset-bugs": { + "url": "https://github.com/yuku-t/textcomplete/issues" + }, + "npm-asset-main": "lib/index.js", + "npm-asset-directories": [], + "npm-asset-repository": { + "type": "git", + "url": "git+ssh://git@github.com/yuku-t/textcomplete.git" + }, + "npm-asset-scripts": { + "build": "yarn run clean && run-p build:*", + "build:dist": "webpack && webpack --env=min && run-p print-dist-gz-size", + "build:docs": "run-p build:docs:*", + "build:docs:html": "webpack --config webpack.doc.config.js && pug -o docs src/doc/index.pug", + "build:docs:md": "documentation build src/*.js -f md -o doc/api.md", + "build:lib": "babel src -d lib -s && for js in src/*.js; do cp $js lib/${js##*/}.flow; done", + "clean": "rm -fr dist docs lib", + "format": "prettier --no-semi --trailing-comma all --write 'src/*.js' 'test/**/*.js'", + "gh-release": "npm pack textcomplete && gh-release -a textcomplete-$(cat package.json|jq -r .version).tgz", + "opener": "wait-on http://localhost:8082 && opener http://localhost:8082", + "print-dist-gz-size": "printf 'dist/textcomplete.min.js.gz: %d bytes\\n' \"$(gzip -9kc dist/textcomplete.min.js | wc -c)\"", + "start": "run-p watch opener", + "test": "run-p test:*", + "test:bundlesize": "yarn run build:dist && bundlesize", + "test:e2e": "NODE_ENV=test karma start --single-run", + "test:lint": "eslint src/*.js test/**/*.js", + "test:typecheck": "flow check", + "watch": "run-p watch:*", + "watch:webpack": "webpack-dev-server --config webpack.doc.config.js", + "watch:pug": "pug -o docs --watch src/doc/index.pug" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Yuku Takahashi" + } + ], + "description": "Autocomplete for textarea elements", + "homepage": "https://github.com/yuku-t/textcomplete#readme", + "time": "2020-06-10T06:11:00+00:00" }, { "name": "npm-asset/typeahead.js", @@ -1813,18 +2080,60 @@ ], "time": "2015-04-27T04:03:42+00:00" }, + { + "name": "npm-asset/undate", + "version": "0.2.4", + "dist": { + "type": "tar", + "url": "https://registry.npmjs.org/undate/-/undate-0.2.4.tgz", + "shasum": "ccb2a8cf38edc035d1006fcb2909c4c6024a8400" + }, + "type": "npm-asset-library", + "extra": { + "npm-asset-bugs": { + "url": "https://github.com/yuku-t/undate/issues" + }, + "npm-asset-main": "lib/index.js", + "npm-asset-directories": [], + "npm-asset-repository": { + "type": "git", + "url": "git+https://github.com/yuku-t/undate.git" + }, + "npm-asset-scripts": { + "build": "babel src -d lib && for js in src/*.js; do cp $js lib/${js##*/}.flow; done", + "test": "run-p test:*", + "test:eslint": "eslint src/*.js test/*.js", + "test:flow": "flow check", + "test:karma": "karma start --single-run" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Yuku Takahashi" + } + ], + "description": "Undoable update for HTMLTextAreaElement", + "homepage": "https://github.com/yuku-t/undate#readme", + "keywords": [ + "textarea" + ], + "time": "2018-01-24T10:49:39+00:00" + }, { "name": "paragonie/certainty", - "version": "v2.5.0", + "version": "v2.6.1", "source": { "type": "git", "url": "https://github.com/paragonie/certainty.git", - "reference": "cc39b91595e577fdff6128d7ce787892bd117274" + "reference": "b0068bc1e5605bd2ebe1ba906f2426d5df123944" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/certainty/zipball/cc39b91595e577fdff6128d7ce787892bd117274", - "reference": "cc39b91595e577fdff6128d7ce787892bd117274", + "url": "https://api.github.com/repos/paragonie/certainty/zipball/b0068bc1e5605bd2ebe1ba906f2426d5df123944", + "reference": "b0068bc1e5605bd2ebe1ba906f2426d5df123944", "shasum": "" }, "require": { @@ -1833,7 +2142,7 @@ "guzzlehttp/guzzle": "^6", "paragonie/constant_time_encoding": "^1|^2", "paragonie/sodium_compat": "^1.11", - "php": "^5.5|^7" + "php": "^5.5|^7|^8" }, "require-dev": { "composer/composer": "^1", @@ -1873,28 +2182,28 @@ "ssl", "tls" ], - "time": "2019-09-27T22:26:33+00:00" + "time": "2020-01-02T00:55:01+00:00" }, { "name": "paragonie/constant_time_encoding", - "version": "v2.2.3", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "55af0dc01992b4d0da7f6372e2eac097bbbaffdb" + "reference": "47a1cedd2e4d52688eb8c96469c05ebc8fd28fa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/55af0dc01992b4d0da7f6372e2eac097bbbaffdb", - "reference": "55af0dc01992b4d0da7f6372e2eac097bbbaffdb", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/47a1cedd2e4d52688eb8c96469c05ebc8fd28fa2", + "reference": "47a1cedd2e4d52688eb8c96469c05ebc8fd28fa2", "shasum": "" }, "require": { - "php": "^7" + "php": "^7|^8" }, "require-dev": { "phpunit/phpunit": "^6|^7", - "vimeo/psalm": "^1|^2" + "vimeo/psalm": "^1|^2|^3" }, "type": "library", "autoload": { @@ -1935,7 +2244,7 @@ "hex2bin", "rfc4648" ], - "time": "2019-01-03T20:26:31+00:00" + "time": "2019-11-06T19:20:29+00:00" }, { "name": "paragonie/hidden-string", @@ -2033,16 +2342,16 @@ }, { "name": "paragonie/sodium_compat", - "version": "v1.11.1", + "version": "v1.13.0", "source": { "type": "git", "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "a9f968bc99485f85f9303a8524c3485a7e87bc15" + "reference": "bbade402cbe84c69b718120911506a3aa2bae653" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/a9f968bc99485f85f9303a8524c3485a7e87bc15", - "reference": "a9f968bc99485f85f9303a8524c3485a7e87bc15", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/bbade402cbe84c69b718120911506a3aa2bae653", + "reference": "bbade402cbe84c69b718120911506a3aa2bae653", "shasum": "" }, "require": { @@ -2050,7 +2359,7 @@ "php": "^5.2.4|^5.3|^5.4|^5.5|^5.6|^7|^8" }, "require-dev": { - "phpunit/phpunit": "^3|^4|^5" + "phpunit/phpunit": "^3|^4|^5|^6|^7" }, "suggest": { "ext-libsodium": "PHP < 7.0: Better performance, password hashing (Argon2i), secure memory management (memzero), and better security.", @@ -2111,7 +2420,53 @@ "secret-key cryptography", "side-channel resistant" ], - "time": "2019-09-12T12:05:58+00:00" + "time": "2020-03-20T21:48:09+00:00" + }, + { + "name": "patrickschur/language-detection", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/patrickschur/language-detection.git", + "reference": "95b55109177d5c4bd6b1bec6e8835cd0df36ef5f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/patrickschur/language-detection/zipball/95b55109177d5c4bd6b1bec6e8835cd0df36ef5f", + "reference": "95b55109177d5c4bd6b1bec6e8835cd0df36ef5f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7" + }, + "require-dev": { + "phpunit/phpunit": "^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "LanguageDetection\\": "src/LanguageDetection" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Schur", + "email": "patrick_schur@outlook.de" + } + ], + "description": "A language detection library for PHP. Detects the language from a given text string.", + "homepage": "https://github.com/patrickschur/language-detection", + "keywords": [ + "detect", + "detection", + "language" + ], + "time": "2018-09-19T21:45:51+00:00" }, { "name": "pear/console_table", @@ -2169,48 +2524,109 @@ "time": "2018-01-25T20:47:17+00:00" }, { - "name": "pear/text_languagedetect", - "version": "v1.0.0", + "name": "phpseclib/phpseclib", + "version": "2.0.29", "source": { "type": "git", - "url": "https://github.com/pear/Text_LanguageDetect.git", - "reference": "bb9ff6f4970f686fac59081e916b456021fe7ba6" + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "497856a8d997f640b4a516062f84228a772a48a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/Text_LanguageDetect/zipball/bb9ff6f4970f686fac59081e916b456021fe7ba6", - "reference": "bb9ff6f4970f686fac59081e916b456021fe7ba6", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/497856a8d997f640b4a516062f84228a772a48a8", + "reference": "497856a8d997f640b4a516062f84228a772a48a8", "shasum": "" }, + "require": { + "php": ">=5.3.3" + }, "require-dev": { - "phpunit/phpunit": "*" + "phing/phing": "~2.7", + "phpunit/phpunit": "^4.8.35|^5.7|^6.0", + "squizlabs/php_codesniffer": "~2.0" }, "suggest": { - "ext-mbstring": "May require the mbstring PHP extension" + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." }, "type": "library", "autoload": { - "psr-0": { - "Text": "./" + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib\\": "phpseclib/" } }, "notification-url": "https://packagist.org/downloads/", - "include-path": [ - "./" - ], "license": [ - "BSD-2-Clause" + "MIT" ], "authors": [ { - "name": "Nicholas Pisarro", - "email": "taak@php.net", - "role": "Lead" + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" } ], - "description": "Identify human languages from text samples", - "homepage": "http://pear.php.net/package/Text_LanguageDetect", - "time": "2017-03-02T16:14:08+00:00" + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2020-09-08T04:24:43+00:00" }, { "name": "pragmarx/google2fa", @@ -2542,16 +2958,16 @@ }, { "name": "psr/log", - "version": "1.1.0", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd" + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", - "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", + "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc", "shasum": "" }, "require": { @@ -2560,7 +2976,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -2585,7 +3001,7 @@ "psr", "psr-3" ], - "time": "2018-11-20T15:27:04+00:00" + "time": "2020-03-23T09:12:05+00:00" }, { "name": "ralouphie/getallheaders", @@ -2677,21 +3093,25 @@ }, { "name": "smarty/smarty", - "version": "v3.1.33", + "version": "v3.1.36", "source": { "type": "git", "url": "https://github.com/smarty-php/smarty.git", - "reference": "dd55b23121e55a3b4f1af90a707a6c4e5969530f" + "reference": "fd148f7ade295014fff77f89ee3d5b20d9d55451" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/smarty-php/smarty/zipball/dd55b23121e55a3b4f1af90a707a6c4e5969530f", - "reference": "dd55b23121e55a3b4f1af90a707a6c4e5969530f", + "url": "https://api.github.com/repos/smarty-php/smarty/zipball/fd148f7ade295014fff77f89ee3d5b20d9d55451", + "reference": "fd148f7ade295014fff77f89ee3d5b20d9d55451", "shasum": "" }, "require": { "php": ">=5.2" }, + "require-dev": { + "phpunit/phpunit": "6.4.1", + "smarty/smarty-lexer": "^3.1" + }, "type": "library", "extra": { "branch-alias": { @@ -2699,8 +3119,8 @@ } }, "autoload": { - "files": [ - "libs/bootstrap.php" + "classmap": [ + "libs/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2726,20 +3146,141 @@ "keywords": [ "templating" ], - "time": "2018-09-12T20:54:16+00:00" + "time": "2020-04-14T14:44:26+00:00" }, { - "name": "symfony/polyfill-php56", - "version": "v1.12.0", + "name": "symfony/polyfill-intl-idn", + "version": "v1.17.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php56.git", - "reference": "0e3b212e96a51338639d8ce175c046d7729c3403" + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "3bff59ea7047e925be6b7f2059d60af31bb46d6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/0e3b212e96a51338639d8ce175c046d7729c3403", - "reference": "0e3b212e96a51338639d8ce175c046d7729c3403", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/3bff59ea7047e925be6b7f2059d60af31bb46d6a", + "reference": "3bff59ea7047e925be6b7f2059d60af31bb46d6a", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "symfony/polyfill-mbstring": "^1.3", + "symfony/polyfill-php72": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.17-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "time": "2020-05-12T16:47:27+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "fa79b11539418b02fc5e1897267673ba2c19419c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fa79b11539418b02fc5e1897267673ba2c19419c", + "reference": "fa79b11539418b02fc5e1897267673ba2c19419c", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.17-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "time": "2020-05-12T16:47:27+00:00" + }, + { + "name": "symfony/polyfill-php56", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php56.git", + "reference": "e3c8c138280cdfe4b81488441555583aa1984e23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/e3c8c138280cdfe4b81488441555583aa1984e23", + "reference": "e3c8c138280cdfe4b81488441555583aa1984e23", "shasum": "" }, "require": { @@ -2749,7 +3290,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.12-dev" + "dev-master": "1.17-dev" } }, "autoload": { @@ -2782,20 +3323,20 @@ "portable", "shim" ], - "time": "2019-08-06T08:03:45+00:00" + "time": "2020-05-12T16:47:27+00:00" }, { - "name": "symfony/polyfill-util", - "version": "v1.12.0", + "name": "symfony/polyfill-php72", + "version": "v1.17.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-util.git", - "reference": "4317de1386717b4c22caed7725350a8887ab205c" + "url": "https://github.com/symfony/polyfill-php72.git", + "reference": "f048e612a3905f34931127360bdd2def19a5e582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-util/zipball/4317de1386717b4c22caed7725350a8887ab205c", - "reference": "4317de1386717b4c22caed7725350a8887ab205c", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/f048e612a3905f34931127360bdd2def19a5e582", + "reference": "f048e612a3905f34931127360bdd2def19a5e582", "shasum": "" }, "require": { @@ -2804,7 +3345,62 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.12-dev" + "dev-master": "1.17-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php72\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2020-05-12T16:47:27+00:00" + }, + { + "name": "symfony/polyfill-util", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-util.git", + "reference": "4afb4110fc037752cf0ce9869f9ab8162c4e20d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-util/zipball/4afb4110fc037752cf0ce9869f9ab8162c4e20d7", + "reference": "4afb4110fc037752cf0ce9869f9ab8162c4e20d7", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.17-dev" } }, "autoload": { @@ -2834,7 +3430,58 @@ "polyfill", "shim" ], - "time": "2019-08-06T08:03:45+00:00" + "time": "2020-05-12T16:14:59+00:00" + }, + { + "name": "xemlock/htmlpurifier-html5", + "version": "v0.1.11", + "source": { + "type": "git", + "url": "https://github.com/xemlock/htmlpurifier-html5.git", + "reference": "f0d563f9fd4a82a3d759043483f9a94c0d8c2255" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/xemlock/htmlpurifier-html5/zipball/f0d563f9fd4a82a3d759043483f9a94c0d8c2255", + "reference": "f0d563f9fd4a82a3d759043483f9a94c0d8c2255", + "shasum": "" + }, + "require": { + "ezyang/htmlpurifier": "^4.8", + "php": ">=5.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.1|^2.1", + "phpunit/phpunit": ">=4.7 <8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "library/HTMLPurifier/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "xemlock", + "email": "xemlock@gmail.com" + } + ], + "description": "HTML5 element definitions for HTML Purifier", + "keywords": [ + "HTML5", + "Purifier", + "html", + "htmlpurifier", + "security", + "tidy", + "validator", + "xss" + ], + "time": "2019-08-07T17:19:21+00:00" } ], "packages-dev": [ @@ -2940,54 +3587,6 @@ ], "time": "2016-01-20T08:20:44+00:00" }, - { - "name": "jakub-onderka/php-parallel-lint", - "version": "v1.0.0", - "source": { - "type": "git", - "url": "https://github.com/JakubOnderka/PHP-Parallel-Lint.git", - "reference": "04fbd3f5fb1c83f08724aa58a23db90bd9086ee8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/JakubOnderka/PHP-Parallel-Lint/zipball/04fbd3f5fb1c83f08724aa58a23db90bd9086ee8", - "reference": "04fbd3f5fb1c83f08724aa58a23db90bd9086ee8", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "jakub-onderka/php-console-highlighter": "~0.3", - "nette/tester": "~1.3", - "squizlabs/php_codesniffer": "~2.7" - }, - "suggest": { - "jakub-onderka/php-console-highlighter": "Highlight syntax in code snippet" - }, - "bin": [ - "parallel-lint" - ], - "type": "library", - "autoload": { - "classmap": [ - "./" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Jakub Onderka", - "email": "ahoj@jakubonderka.cz" - } - ], - "description": "This tool check syntax of PHP files about 20x faster than serial check.", - "homepage": "https://github.com/JakubOnderka/PHP-Parallel-Lint", - "time": "2018-02-24T15:31:20+00:00" - }, { "name": "johnkary/phpunit-speedtrap", "version": "v1.1.0", @@ -3038,16 +3637,16 @@ }, { "name": "mikey179/vfsstream", - "version": "v1.6.7", + "version": "v1.6.8", "source": { "type": "git", "url": "https://github.com/bovigo/vfsStream.git", - "reference": "2b544ac3a21bcc4dde5d90c4ae8d06f4319055fb" + "reference": "231c73783ebb7dd9ec77916c10037eff5a2b6efe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/2b544ac3a21bcc4dde5d90c4ae8d06f4319055fb", - "reference": "2b544ac3a21bcc4dde5d90c4ae8d06f4319055fb", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/231c73783ebb7dd9ec77916c10037eff5a2b6efe", + "reference": "231c73783ebb7dd9ec77916c10037eff5a2b6efe", "shasum": "" }, "require": { @@ -3080,20 +3679,20 @@ ], "description": "Virtual file system to mock the real file system in unit tests.", "homepage": "http://vfs.bovigo.org/", - "time": "2019-08-01T01:38:37+00:00" + "time": "2019-10-30T15:31:00+00:00" }, { "name": "mockery/mockery", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/mockery/mockery.git", - "reference": "4eff936d83eb809bde2c57a3cea0ee9643769031" + "reference": "f69bbde7d7a75d6b2862d9ca8fab1cd28014b4be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/4eff936d83eb809bde2c57a3cea0ee9643769031", - "reference": "4eff936d83eb809bde2c57a3cea0ee9643769031", + "url": "https://api.github.com/repos/mockery/mockery/zipball/f69bbde7d7a75d6b2862d9ca8fab1cd28014b4be", + "reference": "f69bbde7d7a75d6b2862d9ca8fab1cd28014b4be", "shasum": "" }, "require": { @@ -3107,7 +3706,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.3.x-dev" } }, "autoload": { @@ -3145,7 +3744,7 @@ "test double", "testing" ], - "time": "2019-08-07T15:01:07+00:00" + "time": "2019-12-26T09:49:15+00:00" }, { "name": "myclabs/deep-copy", @@ -3192,6 +3791,59 @@ ], "time": "2017-10-19T19:58:43+00:00" }, + { + "name": "php-parallel-lint/php-parallel-lint", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/php-parallel-lint/PHP-Parallel-Lint.git", + "reference": "474f18bc6cc6aca61ca40bfab55139de614e51ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-parallel-lint/PHP-Parallel-Lint/zipball/474f18bc6cc6aca61ca40bfab55139de614e51ca", + "reference": "474f18bc6cc6aca61ca40bfab55139de614e51ca", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.4.0" + }, + "replace": { + "grogy/php-parallel-lint": "*", + "jakub-onderka/php-parallel-lint": "*" + }, + "require-dev": { + "nette/tester": "^1.3 || ^2.0", + "php-parallel-lint/php-console-highlighter": "~0.3", + "squizlabs/php_codesniffer": "~3.0" + }, + "suggest": { + "php-parallel-lint/php-console-highlighter": "Highlight syntax in code snippet" + }, + "bin": [ + "parallel-lint" + ], + "type": "library", + "autoload": { + "classmap": [ + "./" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Jakub Onderka", + "email": "ahoj@jakubonderka.cz" + } + ], + "description": "This tool check syntax of PHP files about 20x faster than serial check.", + "homepage": "https://github.com/php-parallel-lint/PHP-Parallel-Lint", + "time": "2020-04-04T12:18:32+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "1.0.1", @@ -3340,33 +3992,33 @@ }, { "name": "phpspec/prophecy", - "version": "1.8.1", + "version": "v1.10.3", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76" + "reference": "451c3cd1418cf640de218914901e51b064abb093" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/1927e75f4ed19131ec9bcc3b002e07fb1173ee76", - "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/451c3cd1418cf640de218914901e51b064abb093", + "reference": "451c3cd1418cf640de218914901e51b064abb093", "shasum": "" }, "require": { "doctrine/instantiator": "^1.0.2", "php": "^5.3|^7.0", - "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0", - "sebastian/comparator": "^1.1|^2.0|^3.0", - "sebastian/recursion-context": "^1.0|^2.0|^3.0" + "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0", + "sebastian/comparator": "^1.2.3|^2.0|^3.0|^4.0", + "sebastian/recursion-context": "^1.0|^2.0|^3.0|^4.0" }, "require-dev": { - "phpspec/phpspec": "^2.5|^3.2", + "phpspec/phpspec": "^2.5 || ^3.2", "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8.x-dev" + "dev-master": "1.10.x-dev" } }, "autoload": { @@ -3399,7 +4051,7 @@ "spy", "stub" ], - "time": "2019-06-13T12:50:23+00:00" + "time": "2020-03-05T15:02:03+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3648,6 +4300,7 @@ "keywords": [ "tokenizer" ], + "abandoned": true, "time": "2017-12-04T08:55:13+00:00" }, { @@ -4307,16 +4960,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.12.0", + "version": "v1.17.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "550ebaac289296ce228a706d0867afc34687e3f4" + "reference": "e94c8b1bbe2bc77507a1056cdb06451c75b427f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/550ebaac289296ce228a706d0867afc34687e3f4", - "reference": "550ebaac289296ce228a706d0867afc34687e3f4", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e94c8b1bbe2bc77507a1056cdb06451c75b427f9", + "reference": "e94c8b1bbe2bc77507a1056cdb06451c75b427f9", "shasum": "" }, "require": { @@ -4328,7 +4981,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.12-dev" + "dev-master": "1.17-dev" } }, "autoload": { @@ -4361,31 +5014,41 @@ "polyfill", "portable" ], - "time": "2019-08-06T08:03:45+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-05-12T16:14:59+00:00" }, { "name": "symfony/yaml", - "version": "v3.4.32", + "version": "v3.3.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "768f817446da74a776a31eea335540f9dcb53942" + "reference": "ddc23324e6cfe066f3dd34a37ff494fa80b617ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/768f817446da74a776a31eea335540f9dcb53942", - "reference": "768f817446da74a776a31eea335540f9dcb53942", + "url": "https://api.github.com/repos/symfony/yaml/zipball/ddc23324e6cfe066f3dd34a37ff494fa80b617ed", + "reference": "ddc23324e6cfe066f3dd34a37ff494fa80b617ed", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/polyfill-ctype": "~1.8" - }, - "conflict": { - "symfony/console": "<3.4" + "php": ">=5.5.9" }, "require-dev": { - "symfony/console": "~3.4|~4.0" + "symfony/console": "~2.8|~3.0" }, "suggest": { "symfony/console": "For validating YAML files using the lint command" @@ -4393,7 +5056,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "3.3-dev" } }, "autoload": { @@ -4420,35 +5083,34 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2019-09-10T10:38:46+00:00" + "time": "2017-07-23T12:43:26+00:00" }, { "name": "webmozart/assert", - "version": "1.5.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/webmozart/assert.git", - "reference": "88e6d84706d09a236046d686bbea96f07b3a34f4" + "reference": "9dc4f203e36f2b486149058bade43c851dd97451" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/88e6d84706d09a236046d686bbea96f07b3a34f4", - "reference": "88e6d84706d09a236046d686bbea96f07b3a34f4", + "url": "https://api.github.com/repos/webmozart/assert/zipball/9dc4f203e36f2b486149058bade43c851dd97451", + "reference": "9dc4f203e36f2b486149058bade43c851dd97451", "shasum": "" }, "require": { "php": "^5.3.3 || ^7.0", "symfony/polyfill-ctype": "^1.8" }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<3.9.1" + }, "require-dev": { "phpunit/phpunit": "^4.8.36 || ^7.5.13" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.3-dev" - } - }, "autoload": { "psr-4": { "Webmozart\\Assert\\": "src/" @@ -4470,7 +5132,7 @@ "check", "validate" ], - "time": "2019-08-24T08:43:50+00:00" + "time": "2020-06-16T10:16:42+00:00" } ], "aliases": [], @@ -4495,5 +5157,9 @@ "ext-simplexml": "*", "ext-xml": "*" }, - "platform-dev": [] + "platform-dev": [], + "platform-overrides": { + "php": "7.0" + }, + "plugin-api-version": "1.1.0" } diff --git a/database.sql b/database.sql index a26b6f2bfd..1d4f335e4a 100644 --- a/database.sql +++ b/database.sql @@ -1,164 +1,95 @@ -- ------------------------------------------ --- Friendica 2020.06-dev (Red Hot Poker) --- DB_UPDATE_VERSION 1346 +-- Friendica 2021.03-dev (Red Hot Poker) +-- DB_UPDATE_VERSION 1385 -- ------------------------------------------ -- --- TABLE 2fa_app_specific_password +-- TABLE gserver -- -CREATE TABLE IF NOT EXISTS `2fa_app_specific_password` ( - `id` mediumint unsigned NOT NULL auto_increment COMMENT 'Password ID for revocation', - `uid` mediumint unsigned NOT NULL COMMENT 'User ID', - `description` varchar(255) COMMENT 'Description of the usage of the password', - `hashed_password` varchar(255) NOT NULL COMMENT 'Hashed password', - `generated` datetime NOT NULL COMMENT 'Datetime the password was generated', - `last_used` datetime COMMENT 'Datetime the password was last used', - PRIMARY KEY(`id`), - INDEX `uid_description` (`uid`,`description`(190)) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Two-factor app-specific _password'; - --- --- TABLE 2fa_recovery_codes --- -CREATE TABLE IF NOT EXISTS `2fa_recovery_codes` ( - `uid` mediumint unsigned NOT NULL COMMENT 'User ID', - `code` varchar(50) NOT NULL COMMENT 'Recovery code string', - `generated` datetime NOT NULL COMMENT 'Datetime the code was generated', - `used` datetime COMMENT 'Datetime the code was used', - PRIMARY KEY(`uid`,`code`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Two-factor authentication recovery codes'; - --- --- TABLE addon --- -CREATE TABLE IF NOT EXISTS `addon` ( - `id` int unsigned NOT NULL auto_increment COMMENT '', - `name` varchar(50) NOT NULL DEFAULT '' COMMENT 'addon base (file)name', - `version` varchar(50) NOT NULL DEFAULT '' COMMENT 'currently unused', - `installed` boolean NOT NULL DEFAULT '0' COMMENT 'currently always 1', - `hidden` boolean NOT NULL DEFAULT '0' COMMENT 'currently unused', - `timestamp` int unsigned NOT NULL DEFAULT 0 COMMENT 'file timestamp to check for reloads', - `plugin_admin` boolean NOT NULL DEFAULT '0' COMMENT '1 = has admin config, 0 = has no admin config', - PRIMARY KEY(`id`), - UNIQUE INDEX `name` (`name`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='registered addons'; - --- --- TABLE apcontact --- -CREATE TABLE IF NOT EXISTS `apcontact` ( - `url` varbinary(255) NOT NULL COMMENT 'URL of the contact', - `uuid` varchar(255) COMMENT '', - `type` varchar(20) NOT NULL COMMENT '', - `following` varchar(255) COMMENT '', - `followers` varchar(255) COMMENT '', - `inbox` varchar(255) NOT NULL COMMENT '', - `outbox` varchar(255) COMMENT '', - `sharedinbox` varchar(255) COMMENT '', - `manually-approve` boolean COMMENT '', - `nick` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `name` varchar(255) COMMENT '', - `about` text COMMENT '', - `photo` varchar(255) COMMENT '', - `addr` varchar(255) COMMENT '', - `alias` varchar(255) COMMENT '', - `pubkey` text COMMENT '', - `baseurl` varchar(255) COMMENT 'baseurl of the ap contact', - `generator` varchar(255) COMMENT 'Name of the contact\'s system', - `following_count` int unsigned DEFAULT 0 COMMENT 'Number of following contacts', - `followers_count` int unsigned DEFAULT 0 COMMENT 'Number of followers', - `statuses_count` int unsigned DEFAULT 0 COMMENT 'Number of posts', - `updated` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', - PRIMARY KEY(`url`), - INDEX `addr` (`addr`(32)), - INDEX `alias` (`alias`(190)), - INDEX `url` (`followers`(190)) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='ActivityPub compatible contacts - used in the ActivityPub implementation'; - --- --- TABLE attach --- -CREATE TABLE IF NOT EXISTS `attach` ( - `id` int unsigned NOT NULL auto_increment COMMENT 'generated index', - `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'Owner User id', - `hash` varchar(64) NOT NULL DEFAULT '' COMMENT 'hash', - `filename` varchar(255) NOT NULL DEFAULT '' COMMENT 'filename of original', - `filetype` varchar(64) NOT NULL DEFAULT '' COMMENT 'mimetype', - `filesize` int unsigned NOT NULL DEFAULT 0 COMMENT 'size in bytes', - `data` longblob NOT NULL COMMENT 'file data', - `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'creation time', - `edited` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'last edit time', - `allow_cid` mediumtext COMMENT 'Access Control - list of allowed contact.id \'<19><78>', - `allow_gid` mediumtext COMMENT 'Access Control - list of allowed groups', - `deny_cid` mediumtext COMMENT 'Access Control - list of denied contact.id', - `deny_gid` mediumtext COMMENT 'Access Control - list of denied groups', - `backend-class` tinytext COMMENT 'Storage backend class', - `backend-ref` text COMMENT 'Storage backend data reference', - PRIMARY KEY(`id`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='file attachments'; - --- --- TABLE auth_codes --- -CREATE TABLE IF NOT EXISTS `auth_codes` ( - `id` varchar(40) NOT NULL COMMENT '', - `client_id` varchar(20) NOT NULL DEFAULT '' COMMENT '', - `redirect_uri` varchar(200) NOT NULL DEFAULT '' COMMENT '', - `expires` int NOT NULL DEFAULT 0 COMMENT '', - `scope` varchar(250) NOT NULL DEFAULT '' COMMENT '', - PRIMARY KEY(`id`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='OAuth usage'; - --- --- TABLE cache --- -CREATE TABLE IF NOT EXISTS `cache` ( - `k` varbinary(255) NOT NULL COMMENT 'cache key', - `v` mediumtext COMMENT 'cached serialized value', - `expires` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'datetime of cache expiration', - `updated` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'datetime of cache insertion', - PRIMARY KEY(`k`), - INDEX `k_expires` (`k`,`expires`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Stores temporary data'; - --- --- TABLE challenge --- -CREATE TABLE IF NOT EXISTS `challenge` ( +CREATE TABLE IF NOT EXISTS `gserver` ( `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', - `challenge` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `dfrn-id` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `expire` int unsigned NOT NULL DEFAULT 0 COMMENT '', - `type` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `last_update` varchar(255) NOT NULL DEFAULT '' COMMENT '', - PRIMARY KEY(`id`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT=''; - --- --- TABLE clients --- -CREATE TABLE IF NOT EXISTS `clients` ( - `client_id` varchar(20) NOT NULL COMMENT '', - `pw` varchar(20) NOT NULL DEFAULT '' COMMENT '', - `redirect_uri` varchar(200) NOT NULL DEFAULT '' COMMENT '', - `name` text COMMENT '', - `icon` text COMMENT '', - `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', - PRIMARY KEY(`client_id`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='OAuth usage'; - --- --- TABLE config --- -CREATE TABLE IF NOT EXISTS `config` ( - `id` int unsigned NOT NULL auto_increment COMMENT '', - `cat` varbinary(50) NOT NULL DEFAULT '' COMMENT '', - `k` varbinary(50) NOT NULL DEFAULT '' COMMENT '', - `v` mediumtext COMMENT '', + `url` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `nurl` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `version` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `site_name` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `info` text COMMENT '', + `register_policy` tinyint NOT NULL DEFAULT 0 COMMENT '', + `registered-users` int unsigned NOT NULL DEFAULT 0 COMMENT 'Number of registered users', + `directory-type` tinyint DEFAULT 0 COMMENT 'Type of directory service (Poco, Mastodon)', + `poco` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `noscrape` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `network` char(4) NOT NULL DEFAULT '' COMMENT '', + `protocol` tinyint unsigned COMMENT 'The protocol of the server', + `platform` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `relay-subscribe` boolean NOT NULL DEFAULT '0' COMMENT 'Has the server subscribed to the relay system', + `relay-scope` varchar(10) NOT NULL DEFAULT '' COMMENT 'The scope of messages that the server wants to get', + `detection-method` tinyint unsigned COMMENT 'Method that had been used to detect that server', + `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', + `last_poco_query` datetime DEFAULT '0001-01-01 00:00:00' COMMENT '', + `last_contact` datetime DEFAULT '0001-01-01 00:00:00' COMMENT 'Last successful connection request', + `last_failure` datetime DEFAULT '0001-01-01 00:00:00' COMMENT 'Last failed connection request', + `failed` boolean COMMENT 'Connection failed', + `next_contact` datetime DEFAULT '0001-01-01 00:00:00' COMMENT 'Next connection request', PRIMARY KEY(`id`), - UNIQUE INDEX `cat_k` (`cat`,`k`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='main configuration storage'; + UNIQUE INDEX `nurl` (`nurl`(190)), + INDEX `next_contact` (`next_contact`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Global servers'; + +-- +-- TABLE user +-- +CREATE TABLE IF NOT EXISTS `user` ( + `uid` mediumint unsigned NOT NULL auto_increment COMMENT 'sequential ID', + `parent-uid` mediumint unsigned COMMENT 'The parent user that has full control about this user', + `guid` varchar(64) NOT NULL DEFAULT '' COMMENT 'A unique identifier for this user', + `username` varchar(255) NOT NULL DEFAULT '' COMMENT 'Name that this user is known by', + `password` varchar(255) NOT NULL DEFAULT '' COMMENT 'encrypted password', + `legacy_password` boolean NOT NULL DEFAULT '0' COMMENT 'Is the password hash double-hashed?', + `nickname` varchar(255) NOT NULL DEFAULT '' COMMENT 'nick- and user name', + `email` varchar(255) NOT NULL DEFAULT '' COMMENT 'the users email address', + `openid` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `timezone` varchar(128) NOT NULL DEFAULT '' COMMENT 'PHP-legal timezone', + `language` varchar(32) NOT NULL DEFAULT 'en' COMMENT 'default language', + `register_date` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'timestamp of registration', + `login_date` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'timestamp of last login', + `default-location` varchar(255) NOT NULL DEFAULT '' COMMENT 'Default for item.location', + `allow_location` boolean NOT NULL DEFAULT '0' COMMENT '1 allows to display the location', + `theme` varchar(255) NOT NULL DEFAULT '' COMMENT 'user theme preference', + `pubkey` text COMMENT 'RSA public key 4096 bit', + `prvkey` text COMMENT 'RSA private key 4096 bit', + `spubkey` text COMMENT '', + `sprvkey` text COMMENT '', + `verified` boolean NOT NULL DEFAULT '0' COMMENT 'user is verified through email', + `blocked` boolean NOT NULL DEFAULT '0' COMMENT '1 for user is blocked', + `blockwall` boolean NOT NULL DEFAULT '0' COMMENT 'Prohibit contacts to post to the profile page of the user', + `hidewall` boolean NOT NULL DEFAULT '0' COMMENT 'Hide profile details from unkown viewers', + `blocktags` boolean NOT NULL DEFAULT '0' COMMENT 'Prohibit contacts to tag the post of this user', + `unkmail` boolean NOT NULL DEFAULT '0' COMMENT 'Permit unknown people to send private mails to this user', + `cntunkmail` int unsigned NOT NULL DEFAULT 10 COMMENT '', + `notify-flags` smallint unsigned NOT NULL DEFAULT 65535 COMMENT 'email notification options', + `page-flags` tinyint unsigned NOT NULL DEFAULT 0 COMMENT 'page/profile type', + `account-type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', + `prvnets` boolean NOT NULL DEFAULT '0' COMMENT '', + `pwdreset` varchar(255) COMMENT 'Password reset request token', + `pwdreset_time` datetime COMMENT 'Timestamp of the last password reset request', + `maxreq` int unsigned NOT NULL DEFAULT 10 COMMENT '', + `expire` int unsigned NOT NULL DEFAULT 0 COMMENT '', + `account_removed` boolean NOT NULL DEFAULT '0' COMMENT 'if 1 the account is removed', + `account_expired` boolean NOT NULL DEFAULT '0' COMMENT '', + `account_expires_on` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'timestamp when account expires and will be deleted', + `expire_notification_sent` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'timestamp of last warning of account expiration', + `def_gid` int unsigned NOT NULL DEFAULT 0 COMMENT '', + `allow_cid` mediumtext COMMENT 'default permission for this user', + `allow_gid` mediumtext COMMENT 'default permission for this user', + `deny_cid` mediumtext COMMENT 'default permission for this user', + `deny_gid` mediumtext COMMENT 'default permission for this user', + `openidserver` text COMMENT '', + PRIMARY KEY(`uid`), + INDEX `nickname` (`nickname`(32)), + INDEX `parent-uid` (`parent-uid`), + FOREIGN KEY (`parent-uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='The local users'; -- -- TABLE contact @@ -200,6 +131,7 @@ CREATE TABLE IF NOT EXISTS `contact` ( `notify` varchar(255) COMMENT '', `poll` varchar(255) COMMENT '', `confirm` varchar(255) COMMENT '', + `subscribe` varchar(255) COMMENT '', `poco` varchar(255) COMMENT '', `aes_allow` boolean NOT NULL DEFAULT '0' COMMENT '', `ret-aes` boolean NOT NULL DEFAULT '0' COMMENT '', @@ -209,11 +141,13 @@ CREATE TABLE IF NOT EXISTS `contact` ( `last-update` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'Date of the last try to update the contact info', `success_update` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'Date of the last successful contact update', `failure_update` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'Date of the last failed update', + `failed` boolean COMMENT 'Connection failed', `name-date` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', `uri-date` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', `avatar-date` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', `term-date` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', `last-item` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'date of the last post', + `last-discovery` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'date of the last follower discovery', `priority` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', `blocked` boolean NOT NULL DEFAULT '1' COMMENT 'Node-wide block status', `block_reason` text COMMENT 'Node-wide block reason', @@ -222,6 +156,7 @@ CREATE TABLE IF NOT EXISTS `contact` ( `forum` boolean NOT NULL DEFAULT '0' COMMENT 'contact is a forum', `prv` boolean NOT NULL DEFAULT '0' COMMENT 'contact is a private group', `contact-type` tinyint NOT NULL DEFAULT 0 COMMENT '', + `manually-approve` boolean COMMENT '', `hidden` boolean NOT NULL DEFAULT '0' COMMENT '', `archive` boolean NOT NULL DEFAULT '0' COMMENT '', `pending` boolean NOT NULL DEFAULT '1' COMMENT '', @@ -230,6 +165,7 @@ CREATE TABLE IF NOT EXISTS `contact` ( `unsearchable` boolean NOT NULL DEFAULT '0' COMMENT 'Contact prefers to not be searchable', `sensitive` boolean NOT NULL DEFAULT '0' COMMENT 'Contact posts sensitive content', `baseurl` varchar(255) DEFAULT '' COMMENT 'baseurl of the contact', + `gsid` int unsigned COMMENT 'Global Server ID', `reason` text COMMENT '', `closeness` tinyint unsigned NOT NULL DEFAULT 99 COMMENT '', `info` mediumtext COMMENT '', @@ -238,22 +174,247 @@ CREATE TABLE IF NOT EXISTS `contact` ( `bd` date NOT NULL DEFAULT '0001-01-01' COMMENT '', `notify_new_posts` boolean NOT NULL DEFAULT '0' COMMENT '', `fetch_further_information` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', - `ffi_keyword_blacklist` text COMMENT '', + `ffi_keyword_denylist` text COMMENT '', PRIMARY KEY(`id`), INDEX `uid_name` (`uid`,`name`(190)), INDEX `self_uid` (`self`,`uid`), - INDEX `alias_uid` (`alias`(32),`uid`), + INDEX `alias_uid` (`alias`(128),`uid`), INDEX `pending_uid` (`pending`,`uid`), INDEX `blocked_uid` (`blocked`,`uid`), INDEX `uid_rel_network_poll` (`uid`,`rel`,`network`,`poll`(64),`archive`), INDEX `uid_network_batch` (`uid`,`network`,`batch`(64)), - INDEX `addr_uid` (`addr`(32),`uid`), - INDEX `nurl_uid` (`nurl`(32),`uid`), - INDEX `nick_uid` (`nick`(32),`uid`), + INDEX `addr_uid` (`addr`(128),`uid`), + INDEX `nurl_uid` (`nurl`(128),`uid`), + INDEX `nick_uid` (`nick`(128),`uid`), + INDEX `attag_uid` (`attag`(96),`uid`), INDEX `dfrn-id` (`dfrn-id`(64)), - INDEX `issued-id` (`issued-id`(64)) + INDEX `issued-id` (`issued-id`(64)), + INDEX `network_uid_lastupdate` (`network`,`uid`,`last-update`), + INDEX `uid_network_self_lastupdate` (`uid`,`network`,`self`,`last-update`), + INDEX `uid_lastitem` (`uid`,`last-item`), + INDEX `gsid` (`gsid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`gsid`) REFERENCES `gserver` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='contact table'; +-- +-- TABLE item-uri +-- +CREATE TABLE IF NOT EXISTS `item-uri` ( + `id` int unsigned NOT NULL auto_increment, + `uri` varbinary(255) NOT NULL COMMENT 'URI of an item', + `guid` varbinary(255) COMMENT 'A unique identifier for an item', + PRIMARY KEY(`id`), + UNIQUE INDEX `uri` (`uri`), + INDEX `guid` (`guid`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='URI and GUID for items'; + +-- +-- TABLE tag +-- +CREATE TABLE IF NOT EXISTS `tag` ( + `id` int unsigned NOT NULL auto_increment COMMENT '', + `name` varchar(96) NOT NULL DEFAULT '' COMMENT '', + `url` varbinary(255) NOT NULL DEFAULT '' COMMENT '', + PRIMARY KEY(`id`), + UNIQUE INDEX `type_name_url` (`name`,`url`), + INDEX `url` (`url`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='tags and mentions'; + +-- +-- TABLE clients +-- +CREATE TABLE IF NOT EXISTS `clients` ( + `client_id` varchar(20) NOT NULL COMMENT '', + `pw` varchar(20) NOT NULL DEFAULT '' COMMENT '', + `redirect_uri` varchar(200) NOT NULL DEFAULT '' COMMENT '', + `name` text COMMENT '', + `icon` text COMMENT '', + `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', + PRIMARY KEY(`client_id`), + INDEX `uid` (`uid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='OAuth usage'; + +-- +-- TABLE permissionset +-- +CREATE TABLE IF NOT EXISTS `permissionset` ( + `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', + `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'Owner id of this permission set', + `allow_cid` mediumtext COMMENT 'Access Control - list of allowed contact.id \'<19><78>\'', + `allow_gid` mediumtext COMMENT 'Access Control - list of allowed groups', + `deny_cid` mediumtext COMMENT 'Access Control - list of denied contact.id', + `deny_gid` mediumtext COMMENT 'Access Control - list of denied groups', + PRIMARY KEY(`id`), + INDEX `uid_allow_cid_allow_gid_deny_cid_deny_gid` (`uid`,`allow_cid`(50),`allow_gid`(30),`deny_cid`(50),`deny_gid`(30)), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT=''; + +-- +-- TABLE verb +-- +CREATE TABLE IF NOT EXISTS `verb` ( + `id` smallint unsigned NOT NULL auto_increment, + `name` varchar(100) NOT NULL DEFAULT '' COMMENT '', + PRIMARY KEY(`id`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Activity Verbs'; + +-- +-- TABLE 2fa_app_specific_password +-- +CREATE TABLE IF NOT EXISTS `2fa_app_specific_password` ( + `id` mediumint unsigned NOT NULL auto_increment COMMENT 'Password ID for revocation', + `uid` mediumint unsigned NOT NULL COMMENT 'User ID', + `description` varchar(255) COMMENT 'Description of the usage of the password', + `hashed_password` varchar(255) NOT NULL COMMENT 'Hashed password', + `generated` datetime NOT NULL COMMENT 'Datetime the password was generated', + `last_used` datetime COMMENT 'Datetime the password was last used', + PRIMARY KEY(`id`), + INDEX `uid_description` (`uid`,`description`(190)), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Two-factor app-specific _password'; + +-- +-- TABLE 2fa_recovery_codes +-- +CREATE TABLE IF NOT EXISTS `2fa_recovery_codes` ( + `uid` mediumint unsigned NOT NULL COMMENT 'User ID', + `code` varchar(50) NOT NULL COMMENT 'Recovery code string', + `generated` datetime NOT NULL COMMENT 'Datetime the code was generated', + `used` datetime COMMENT 'Datetime the code was used', + PRIMARY KEY(`uid`,`code`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Two-factor authentication recovery codes'; + +-- +-- TABLE addon +-- +CREATE TABLE IF NOT EXISTS `addon` ( + `id` int unsigned NOT NULL auto_increment COMMENT '', + `name` varchar(50) NOT NULL DEFAULT '' COMMENT 'addon base (file)name', + `version` varchar(50) NOT NULL DEFAULT '' COMMENT 'currently unused', + `installed` boolean NOT NULL DEFAULT '0' COMMENT 'currently always 1', + `hidden` boolean NOT NULL DEFAULT '0' COMMENT 'currently unused', + `timestamp` int unsigned NOT NULL DEFAULT 0 COMMENT 'file timestamp to check for reloads', + `plugin_admin` boolean NOT NULL DEFAULT '0' COMMENT '1 = has admin config, 0 = has no admin config', + PRIMARY KEY(`id`), + UNIQUE INDEX `name` (`name`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='registered addons'; + +-- +-- TABLE apcontact +-- +CREATE TABLE IF NOT EXISTS `apcontact` ( + `url` varbinary(255) NOT NULL COMMENT 'URL of the contact', + `uuid` varchar(255) COMMENT '', + `type` varchar(20) NOT NULL COMMENT '', + `following` varchar(255) COMMENT '', + `followers` varchar(255) COMMENT '', + `inbox` varchar(255) NOT NULL COMMENT '', + `outbox` varchar(255) COMMENT '', + `sharedinbox` varchar(255) COMMENT '', + `manually-approve` boolean COMMENT '', + `nick` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `name` varchar(255) COMMENT '', + `about` text COMMENT '', + `photo` varchar(255) COMMENT '', + `addr` varchar(255) COMMENT '', + `alias` varchar(255) COMMENT '', + `pubkey` text COMMENT '', + `subscribe` varchar(255) COMMENT '', + `baseurl` varchar(255) COMMENT 'baseurl of the ap contact', + `gsid` int unsigned COMMENT 'Global Server ID', + `generator` varchar(255) COMMENT 'Name of the contact\'s system', + `following_count` int unsigned DEFAULT 0 COMMENT 'Number of following contacts', + `followers_count` int unsigned DEFAULT 0 COMMENT 'Number of followers', + `statuses_count` int unsigned DEFAULT 0 COMMENT 'Number of posts', + `updated` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', + PRIMARY KEY(`url`), + INDEX `addr` (`addr`(32)), + INDEX `alias` (`alias`(190)), + INDEX `followers` (`followers`(190)), + INDEX `baseurl` (`baseurl`(190)), + INDEX `sharedinbox` (`sharedinbox`(190)), + INDEX `gsid` (`gsid`), + FOREIGN KEY (`gsid`) REFERENCES `gserver` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='ActivityPub compatible contacts - used in the ActivityPub implementation'; + +-- +-- TABLE attach +-- +CREATE TABLE IF NOT EXISTS `attach` ( + `id` int unsigned NOT NULL auto_increment COMMENT 'generated index', + `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'Owner User id', + `hash` varchar(64) NOT NULL DEFAULT '' COMMENT 'hash', + `filename` varchar(255) NOT NULL DEFAULT '' COMMENT 'filename of original', + `filetype` varchar(64) NOT NULL DEFAULT '' COMMENT 'mimetype', + `filesize` int unsigned NOT NULL DEFAULT 0 COMMENT 'size in bytes', + `data` longblob NOT NULL COMMENT 'file data', + `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'creation time', + `edited` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'last edit time', + `allow_cid` mediumtext COMMENT 'Access Control - list of allowed contact.id \'<19><78>', + `allow_gid` mediumtext COMMENT 'Access Control - list of allowed groups', + `deny_cid` mediumtext COMMENT 'Access Control - list of denied contact.id', + `deny_gid` mediumtext COMMENT 'Access Control - list of denied groups', + `backend-class` tinytext COMMENT 'Storage backend class', + `backend-ref` text COMMENT 'Storage backend data reference', + PRIMARY KEY(`id`), + INDEX `uid` (`uid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='file attachments'; + +-- +-- TABLE auth_codes +-- +CREATE TABLE IF NOT EXISTS `auth_codes` ( + `id` varchar(40) NOT NULL COMMENT '', + `client_id` varchar(20) NOT NULL DEFAULT '' COMMENT '', + `redirect_uri` varchar(200) NOT NULL DEFAULT '' COMMENT '', + `expires` int NOT NULL DEFAULT 0 COMMENT '', + `scope` varchar(250) NOT NULL DEFAULT '' COMMENT '', + PRIMARY KEY(`id`), + INDEX `client_id` (`client_id`), + FOREIGN KEY (`client_id`) REFERENCES `clients` (`client_id`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='OAuth usage'; + +-- +-- TABLE cache +-- +CREATE TABLE IF NOT EXISTS `cache` ( + `k` varbinary(255) NOT NULL COMMENT 'cache key', + `v` mediumtext COMMENT 'cached serialized value', + `expires` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'datetime of cache expiration', + `updated` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'datetime of cache insertion', + PRIMARY KEY(`k`), + INDEX `k_expires` (`k`,`expires`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Stores temporary data'; + +-- +-- TABLE challenge +-- +CREATE TABLE IF NOT EXISTS `challenge` ( + `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', + `challenge` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `dfrn-id` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `expire` int unsigned NOT NULL DEFAULT 0 COMMENT '', + `type` varchar(255) NOT NULL DEFAULT '' COMMENT '', + `last_update` varchar(255) NOT NULL DEFAULT '' COMMENT '', + PRIMARY KEY(`id`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT=''; + +-- +-- TABLE config +-- +CREATE TABLE IF NOT EXISTS `config` ( + `id` int unsigned NOT NULL auto_increment COMMENT '', + `cat` varbinary(50) NOT NULL DEFAULT '' COMMENT '', + `k` varbinary(50) NOT NULL DEFAULT '' COMMENT '', + `v` mediumtext COMMENT '', + PRIMARY KEY(`id`), + UNIQUE INDEX `cat_k` (`cat`,`k`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='main configuration storage'; + -- -- TABLE contact-relation -- @@ -261,8 +422,12 @@ CREATE TABLE IF NOT EXISTS `contact-relation` ( `cid` int unsigned NOT NULL DEFAULT 0 COMMENT 'contact the related contact had interacted with', `relation-cid` int unsigned NOT NULL DEFAULT 0 COMMENT 'related contact who had interacted with the contact', `last-interaction` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'Date of the last interaction', + `follow-updated` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'Date of the last update of the contact relationship', + `follows` boolean NOT NULL DEFAULT '0' COMMENT '', PRIMARY KEY(`cid`,`relation-cid`), - INDEX `relation-cid` (`relation-cid`) + INDEX `relation-cid` (`relation-cid`), + FOREIGN KEY (`cid`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`relation-cid`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Contact relations'; -- @@ -278,7 +443,8 @@ CREATE TABLE IF NOT EXISTS `conv` ( `updated` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'edited timestamp', `subject` text COMMENT 'subject of initial message', PRIMARY KEY(`id`), - INDEX `uid` (`uid`) + INDEX `uid` (`uid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='private messages'; -- @@ -298,13 +464,27 @@ CREATE TABLE IF NOT EXISTS `conversation` ( INDEX `received` (`received`) ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Raw data and structure information for messages'; +-- +-- TABLE delayed-post +-- +CREATE TABLE IF NOT EXISTS `delayed-post` ( + `id` int unsigned NOT NULL auto_increment, + `uri` varchar(255) COMMENT 'URI of the post that will be distributed later', + `uid` mediumint unsigned COMMENT 'Owner User id', + `delayed` datetime COMMENT 'delay time', + PRIMARY KEY(`id`), + UNIQUE INDEX `uid_uri` (`uid`,`uri`(190)), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Posts that are about to be distributed at a later time'; + -- -- TABLE diaspora-interaction -- CREATE TABLE IF NOT EXISTS `diaspora-interaction` ( `uri-id` int unsigned NOT NULL COMMENT 'Id of the item-uri table entry that contains the item uri', `interaction` mediumtext COMMENT 'The Diaspora interaction', - PRIMARY KEY(`uri-id`) + PRIMARY KEY(`uri-id`), + FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Signed Diaspora Interaction'; -- @@ -332,7 +512,10 @@ CREATE TABLE IF NOT EXISTS `event` ( `deny_cid` mediumtext COMMENT 'Access Control - list of denied contact.id', `deny_gid` mediumtext COMMENT 'Access Control - list of denied groups', PRIMARY KEY(`id`), - INDEX `uid_start` (`uid`,`start`) + INDEX `uid_start` (`uid`,`start`), + INDEX `cid` (`cid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`cid`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Events'; -- @@ -374,88 +557,12 @@ CREATE TABLE IF NOT EXISTS `fsuggest` ( `photo` varchar(255) NOT NULL DEFAULT '' COMMENT '', `note` text COMMENT '', `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', - PRIMARY KEY(`id`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='friend suggestion stuff'; - --- --- TABLE gcign --- -CREATE TABLE IF NOT EXISTS `gcign` ( - `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', - `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'Local User id', - `gcid` int unsigned NOT NULL DEFAULT 0 COMMENT 'gcontact.id of ignored contact', PRIMARY KEY(`id`), + INDEX `cid` (`cid`), INDEX `uid` (`uid`), - INDEX `gcid` (`gcid`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='contacts ignored by friend suggestions'; - --- --- TABLE gcontact --- -CREATE TABLE IF NOT EXISTS `gcontact` ( - `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', - `name` varchar(255) NOT NULL DEFAULT '' COMMENT 'Name that this contact is known by', - `nick` varchar(255) NOT NULL DEFAULT '' COMMENT 'Nick- and user name of the contact', - `url` varchar(255) NOT NULL DEFAULT '' COMMENT 'Link to the contacts profile page', - `nurl` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `photo` varchar(255) NOT NULL DEFAULT '' COMMENT 'Link to the profile photo', - `connect` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', - `updated` datetime DEFAULT '0001-01-01 00:00:00' COMMENT '', - `last_contact` datetime DEFAULT '0001-01-01 00:00:00' COMMENT '', - `last_failure` datetime DEFAULT '0001-01-01 00:00:00' COMMENT '', - `last_discovery` datetime DEFAULT '0001-01-01 00:00:00' COMMENT 'Date of the last contact discovery', - `archive_date` datetime DEFAULT '0001-01-01 00:00:00' COMMENT '', - `archived` boolean NOT NULL DEFAULT '0' COMMENT '', - `location` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `about` text COMMENT '', - `keywords` text COMMENT 'puplic keywords (interests)', - `gender` varchar(32) NOT NULL DEFAULT '' COMMENT 'Deprecated', - `birthday` varchar(32) NOT NULL DEFAULT '0001-01-01' COMMENT '', - `community` boolean NOT NULL DEFAULT '0' COMMENT '1 if contact is forum account', - `contact-type` tinyint NOT NULL DEFAULT -1 COMMENT '', - `hide` boolean NOT NULL DEFAULT '0' COMMENT '1 = should be hidden from search', - `nsfw` boolean NOT NULL DEFAULT '0' COMMENT '1 = contact posts nsfw content', - `network` char(4) NOT NULL DEFAULT '' COMMENT 'social network protocol', - `addr` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `notify` varchar(255) COMMENT '', - `alias` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `generation` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', - `server_url` varchar(255) NOT NULL DEFAULT '' COMMENT 'baseurl of the contacts server', - PRIMARY KEY(`id`), - UNIQUE INDEX `nurl` (`nurl`(190)), - INDEX `name` (`name`(64)), - INDEX `nick` (`nick`(32)), - INDEX `addr` (`addr`(64)), - INDEX `hide_network_updated` (`hide`,`network`,`updated`), - INDEX `updated` (`updated`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='global contacts'; - --- --- TABLE gfollower --- -CREATE TABLE IF NOT EXISTS `gfollower` ( - `gcid` int unsigned NOT NULL DEFAULT 0 COMMENT 'global contact', - `follower-gcid` int unsigned NOT NULL DEFAULT 0 COMMENT 'global contact of the follower', - `deleted` boolean NOT NULL DEFAULT '0' COMMENT '1 indicates that the connection has been deleted', - PRIMARY KEY(`gcid`,`follower-gcid`), - INDEX `follower-gcid` (`follower-gcid`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Followers of global contacts'; - --- --- TABLE glink --- -CREATE TABLE IF NOT EXISTS `glink` ( - `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', - `cid` int unsigned NOT NULL DEFAULT 0 COMMENT '', - `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', - `gcid` int unsigned NOT NULL DEFAULT 0 COMMENT '', - `zcid` int unsigned NOT NULL DEFAULT 0 COMMENT '', - `updated` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', - PRIMARY KEY(`id`), - UNIQUE INDEX `cid_uid_gcid_zcid` (`cid`,`uid`,`gcid`,`zcid`), - INDEX `gcid` (`gcid`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='\'friends of friends\' linkages derived from poco'; + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`cid`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='friend suggestion stuff'; -- -- TABLE group @@ -467,7 +574,8 @@ CREATE TABLE IF NOT EXISTS `group` ( `deleted` boolean NOT NULL DEFAULT '0' COMMENT '1 indicates the group has been deleted', `name` varchar(255) NOT NULL DEFAULT '' COMMENT 'human readable name of group', PRIMARY KEY(`id`), - INDEX `uid` (`uid`) + INDEX `uid` (`uid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='privacy groups, group info'; -- @@ -479,36 +587,11 @@ CREATE TABLE IF NOT EXISTS `group_member` ( `contact-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'contact.id of the member assigned to the associated group', PRIMARY KEY(`id`), INDEX `contactid` (`contact-id`), - UNIQUE INDEX `gid_contactid` (`gid`,`contact-id`) + UNIQUE INDEX `gid_contactid` (`gid`,`contact-id`), + FOREIGN KEY (`gid`) REFERENCES `group` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`contact-id`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='privacy groups, member info'; --- --- TABLE gserver --- -CREATE TABLE IF NOT EXISTS `gserver` ( - `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', - `url` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `nurl` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `version` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `site_name` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `info` text COMMENT '', - `register_policy` tinyint NOT NULL DEFAULT 0 COMMENT '', - `registered-users` int unsigned NOT NULL DEFAULT 0 COMMENT 'Number of registered users', - `directory-type` tinyint DEFAULT 0 COMMENT 'Type of directory service (Poco, Mastodon)', - `poco` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `noscrape` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `network` char(4) NOT NULL DEFAULT '' COMMENT '', - `platform` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `relay-subscribe` boolean NOT NULL DEFAULT '0' COMMENT 'Has the server subscribed to the relay system', - `relay-scope` varchar(10) NOT NULL DEFAULT '' COMMENT 'The scope of messages that the server wants to get', - `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', - `last_poco_query` datetime DEFAULT '0001-01-01 00:00:00' COMMENT '', - `last_contact` datetime DEFAULT '0001-01-01 00:00:00' COMMENT '', - `last_failure` datetime DEFAULT '0001-01-01 00:00:00' COMMENT '', - PRIMARY KEY(`id`), - UNIQUE INDEX `nurl` (`nurl`(190)) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Global servers'; - -- -- TABLE gserver-tag -- @@ -516,7 +599,8 @@ CREATE TABLE IF NOT EXISTS `gserver-tag` ( `gserver-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'The id of the gserver', `tag` varchar(100) NOT NULL DEFAULT '' COMMENT 'Tag that the server has subscribed', PRIMARY KEY(`gserver-id`,`tag`), - INDEX `tag` (`tag`) + INDEX `tag` (`tag`), + FOREIGN KEY (`gserver-id`) REFERENCES `gserver` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Tags that the server has subscribed'; -- @@ -532,6 +616,16 @@ CREATE TABLE IF NOT EXISTS `hook` ( UNIQUE INDEX `hook_file_function` (`hook`,`file`,`function`) ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='addon hook registry'; +-- +-- TABLE host +-- +CREATE TABLE IF NOT EXISTS `host` ( + `id` tinyint unsigned NOT NULL auto_increment COMMENT 'sequential ID', + `name` varchar(128) NOT NULL DEFAULT '' COMMENT 'The hostname', + PRIMARY KEY(`id`), + UNIQUE INDEX `name` (`name`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Hostname'; + -- -- TABLE inbox-status -- @@ -552,7 +646,7 @@ CREATE TABLE IF NOT EXISTS `inbox-status` ( CREATE TABLE IF NOT EXISTS `intro` ( `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', - `fid` int unsigned NOT NULL DEFAULT 0 COMMENT '', + `fid` int unsigned COMMENT '', `contact-id` int unsigned NOT NULL DEFAULT 0 COMMENT '', `knowyou` boolean NOT NULL DEFAULT '0' COMMENT '', `duplex` boolean NOT NULL DEFAULT '0' COMMENT '', @@ -561,7 +655,11 @@ CREATE TABLE IF NOT EXISTS `intro` ( `datetime` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', `blocked` boolean NOT NULL DEFAULT '1' COMMENT '', `ignore` boolean NOT NULL DEFAULT '0' COMMENT '', - PRIMARY KEY(`id`) + PRIMARY KEY(`id`), + INDEX `contact-id` (`contact-id`), + INDEX `uid` (`uid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`contact-id`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT=''; -- @@ -573,9 +671,9 @@ CREATE TABLE IF NOT EXISTS `item` ( `uri` varchar(255) NOT NULL DEFAULT '' COMMENT '', `uri-id` int unsigned COMMENT 'Id of the item-uri table entry that contains the item uri', `uri-hash` varchar(80) NOT NULL DEFAULT '' COMMENT 'RIPEMD-128 hash from uri', - `parent` int unsigned NOT NULL DEFAULT 0 COMMENT 'item.id of the parent to this item if it is a reply of some form; otherwise this must be set to the id of this item', - `parent-uri` varchar(255) NOT NULL DEFAULT '' COMMENT 'uri of the parent to this item', - `parent-uri-id` int unsigned COMMENT 'Id of the item-uri table that contains the parent uri', + `parent` int unsigned COMMENT 'item.id of the parent to this item if it is a reply of some form; otherwise this must be set to the id of this item', + `parent-uri` varchar(255) NOT NULL DEFAULT '' COMMENT 'uri of the top-level parent to this item', + `parent-uri-id` int unsigned COMMENT 'Id of the item-uri table that contains the top-level parent uri', `thr-parent` varchar(255) NOT NULL DEFAULT '' COMMENT 'If the parent of this item is not the top-level item in the conversation, the uri of the immediate parent; otherwise set to parent-uri', `thr-parent-id` int unsigned COMMENT 'Id of the item-uri table that contains the thread parent uri', `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'Creation timestamp.', @@ -587,8 +685,9 @@ CREATE TABLE IF NOT EXISTS `item` ( `network` char(4) NOT NULL DEFAULT '' COMMENT 'Network from where the item comes from', `owner-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'Link to the contact table with uid=0 of the owner of this item', `author-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'Link to the contact table with uid=0 of the author of this item', + `causer-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'Link to the contact table with uid=0 of the contact that caused the item creation', `icid` int unsigned COMMENT 'Id of the item-content table entry that contains the whole item content', - `iaid` int unsigned COMMENT 'Id of the item-activity table entry that contains the activity data', + `vid` smallint unsigned COMMENT 'Id of the verb table entry that contains the activity verbs', `extid` varchar(255) NOT NULL DEFAULT '' COMMENT '', `post-type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT 'Post type (personal note, bookmark, ...)', `global` boolean NOT NULL DEFAULT '0' COMMENT '', @@ -607,8 +706,9 @@ CREATE TABLE IF NOT EXISTS `item` ( `forum_mode` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', `psid` int unsigned COMMENT 'ID of the permission set of this post', `resource-id` varchar(32) NOT NULL DEFAULT '' COMMENT 'Used to link other tables to items, it identifies the linked resource (e.g. photo) and if set must also set resource_type', - `event-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'Used to link to the event.id', - `attach` mediumtext COMMENT 'JSON structure representing attachments to this item', + `event-id` int unsigned COMMENT 'Used to link to the event.id', + `iaid` int unsigned COMMENT 'Deprecated', + `attach` mediumtext COMMENT 'Deprecated', `allow_cid` mediumtext COMMENT 'Deprecated', `allow_gid` mediumtext COMMENT 'Deprecated', `deny_cid` mediumtext COMMENT 'Deprecated', @@ -662,12 +762,27 @@ CREATE TABLE IF NOT EXISTS `item` ( INDEX `resource-id` (`resource-id`), INDEX `deleted_changed` (`deleted`,`changed`), INDEX `uid_wall_changed` (`uid`,`wall`,`changed`), + INDEX `uid_unseen_wall` (`uid`,`unseen`,`wall`), INDEX `mention_uid_id` (`mention`,`uid`,`id`), INDEX `uid_eventid` (`uid`,`event-id`), INDEX `icid` (`icid`), INDEX `iaid` (`iaid`), + INDEX `vid` (`vid`), INDEX `psid_wall` (`psid`,`wall`), - INDEX `uri-id` (`uri-id`) + INDEX `uri-id` (`uri-id`), + INDEX `parent-uri-id` (`parent-uri-id`), + INDEX `thr-parent-id` (`thr-parent-id`), + INDEX `causer-id` (`causer-id`), + FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`parent-uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`thr-parent-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`owner-id`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT, + FOREIGN KEY (`author-id`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT, + FOREIGN KEY (`causer-id`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT, + FOREIGN KEY (`vid`) REFERENCES `verb` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT, + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`contact-id`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`psid`) REFERENCES `permissionset` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Structure for all posts'; -- @@ -682,7 +797,8 @@ CREATE TABLE IF NOT EXISTS `item-activity` ( PRIMARY KEY(`id`), UNIQUE INDEX `uri-hash` (`uri-hash`), INDEX `uri` (`uri`(191)), - INDEX `uri-id` (`uri-id`) + INDEX `uri-id` (`uri-id`), + FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Activities for items'; -- @@ -696,6 +812,7 @@ CREATE TABLE IF NOT EXISTS `item-content` ( `title` varchar(255) NOT NULL DEFAULT '' COMMENT 'item title', `content-warning` varchar(255) NOT NULL DEFAULT '' COMMENT '', `body` mediumtext COMMENT 'item body content', + `raw-body` mediumtext COMMENT 'Body without embedded media links', `location` varchar(255) NOT NULL DEFAULT '' COMMENT 'text location where this item originated', `coord` varchar(255) NOT NULL DEFAULT '' COMMENT 'longitude/latitude pair representing location where this item originated', `language` text COMMENT 'Language information about this post', @@ -710,23 +827,13 @@ CREATE TABLE IF NOT EXISTS `item-content` ( `verb` varchar(100) NOT NULL DEFAULT '' COMMENT 'ActivityStreams verb', PRIMARY KEY(`id`), UNIQUE INDEX `uri-plink-hash` (`uri-plink-hash`), + FULLTEXT INDEX `title-content-warning-body` (`title`,`content-warning`,`body`), INDEX `uri` (`uri`(191)), INDEX `plink` (`plink`(191)), - INDEX `uri-id` (`uri-id`) + INDEX `uri-id` (`uri-id`), + FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Content for all posts'; --- --- TABLE item-uri --- -CREATE TABLE IF NOT EXISTS `item-uri` ( - `id` int unsigned NOT NULL auto_increment, - `uri` varbinary(255) NOT NULL COMMENT 'URI of an item', - `guid` varbinary(255) COMMENT 'A unique identifier for an item', - PRIMARY KEY(`id`), - UNIQUE INDEX `uri` (`uri`), - INDEX `guid` (`guid`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='URI and GUID for items'; - -- -- TABLE locks -- @@ -750,8 +857,8 @@ CREATE TABLE IF NOT EXISTS `mail` ( `from-name` varchar(255) NOT NULL DEFAULT '' COMMENT 'name of the sender', `from-photo` varchar(255) NOT NULL DEFAULT '' COMMENT 'contact photo link of the sender', `from-url` varchar(255) NOT NULL DEFAULT '' COMMENT 'profile linke of the sender', - `contact-id` varchar(255) NOT NULL DEFAULT '' COMMENT 'contact.id', - `convid` int unsigned NOT NULL DEFAULT 0 COMMENT 'conv.id', + `contact-id` varchar(255) COMMENT 'contact.id', + `convid` int unsigned COMMENT 'conv.id', `title` varchar(255) NOT NULL DEFAULT '' COMMENT '', `body` mediumtext COMMENT '', `seen` boolean NOT NULL DEFAULT '0' COMMENT 'if message visited it is 1', @@ -766,7 +873,8 @@ CREATE TABLE IF NOT EXISTS `mail` ( INDEX `convid` (`convid`), INDEX `uri` (`uri`(64)), INDEX `parent-uri` (`parent-uri`(64)), - INDEX `contactid` (`contact-id`(32)) + INDEX `contactid` (`contact-id`(32)), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='private messages'; -- @@ -786,7 +894,9 @@ CREATE TABLE IF NOT EXISTS `mailacct` ( `movetofolder` varchar(255) NOT NULL DEFAULT '' COMMENT '', `pubmail` boolean NOT NULL DEFAULT '0' COMMENT '', `last_check` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', - PRIMARY KEY(`id`) + PRIMARY KEY(`id`), + INDEX `uid` (`uid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Mail account data for fetching mails'; -- @@ -797,7 +907,10 @@ CREATE TABLE IF NOT EXISTS `manage` ( `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', `mid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', PRIMARY KEY(`id`), - UNIQUE INDEX `uid_mid` (`uid`,`mid`) + UNIQUE INDEX `uid_mid` (`uid`,`mid`), + INDEX `mid` (`mid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`mid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='table of accounts that can manage each other'; -- @@ -813,8 +926,8 @@ CREATE TABLE IF NOT EXISTS `notify` ( `msg` mediumtext COMMENT '', `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'Owner User id', `link` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `iid` int unsigned NOT NULL DEFAULT 0 COMMENT 'item.id', - `parent` int unsigned NOT NULL DEFAULT 0 COMMENT '', + `iid` int unsigned COMMENT 'item.id', + `parent` int unsigned COMMENT '', `uri-id` int unsigned COMMENT 'Item-uri id of the related post', `parent-uri-id` int unsigned COMMENT 'Item-uri id of the parent of the related post', `seen` boolean NOT NULL DEFAULT '0' COMMENT '', @@ -825,7 +938,12 @@ CREATE TABLE IF NOT EXISTS `notify` ( PRIMARY KEY(`id`), INDEX `seen_uid_date` (`seen`,`uid`,`date`), INDEX `uid_date` (`uid`,`date`), - INDEX `uid_type_link` (`uid`,`type`,`link`(190)) + INDEX `uid_type_link` (`uid`,`type`,`link`(190)), + INDEX `uri-id` (`uri-id`), + INDEX `parent-uri-id` (`parent-uri-id`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`parent-uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='notifications'; -- @@ -834,11 +952,19 @@ CREATE TABLE IF NOT EXISTS `notify` ( CREATE TABLE IF NOT EXISTS `notify-threads` ( `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', `notify-id` int unsigned NOT NULL DEFAULT 0 COMMENT '', - `master-parent-item` int unsigned NOT NULL DEFAULT 0 COMMENT '', + `master-parent-item` int unsigned COMMENT '', `master-parent-uri-id` int unsigned COMMENT 'Item-uri id of the parent of the related post', `parent-item` int unsigned NOT NULL DEFAULT 0 COMMENT '', `receiver-uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', - PRIMARY KEY(`id`) + PRIMARY KEY(`id`), + INDEX `master-parent-item` (`master-parent-item`), + INDEX `master-parent-uri-id` (`master-parent-uri-id`), + INDEX `receiver-uid` (`receiver-uid`), + INDEX `notify-id` (`notify-id`), + FOREIGN KEY (`notify-id`) REFERENCES `notify` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`master-parent-item`) REFERENCES `item` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`master-parent-uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`receiver-uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT=''; -- @@ -858,12 +984,14 @@ CREATE TABLE IF NOT EXISTS `oembed` ( -- CREATE TABLE IF NOT EXISTS `openwebauth-token` ( `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', - `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', + `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id - currently unused', `type` varchar(32) NOT NULL DEFAULT '' COMMENT 'Verify type', `token` varchar(255) NOT NULL DEFAULT '' COMMENT 'A generated token', `meta` varchar(255) NOT NULL DEFAULT '' COMMENT '', `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'datetime of creation', - PRIMARY KEY(`id`) + PRIMARY KEY(`id`), + INDEX `uid` (`uid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Store OpenWebAuth token to verify contacts'; -- @@ -889,36 +1017,26 @@ CREATE TABLE IF NOT EXISTS `participation` ( `fid` int unsigned NOT NULL COMMENT '', PRIMARY KEY(`iid`,`server`), INDEX `cid` (`cid`), - INDEX `fid` (`fid`) + INDEX `fid` (`fid`), + FOREIGN KEY (`iid`) REFERENCES `item` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`cid`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`fid`) REFERENCES `fcontact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Storage for participation messages from Diaspora'; -- -- TABLE pconfig -- CREATE TABLE IF NOT EXISTS `pconfig` ( - `id` int unsigned NOT NULL auto_increment COMMENT '', + `id` int unsigned NOT NULL auto_increment COMMENT 'Primary key', `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', - `cat` varbinary(50) NOT NULL DEFAULT '' COMMENT '', - `k` varbinary(100) NOT NULL DEFAULT '' COMMENT '', - `v` mediumtext COMMENT '', + `cat` varchar(50) NOT NULL DEFAULT '' COMMENT 'Category', + `k` varchar(100) NOT NULL DEFAULT '' COMMENT 'Key', + `v` mediumtext COMMENT 'Value', PRIMARY KEY(`id`), - UNIQUE INDEX `uid_cat_k` (`uid`,`cat`,`k`) + UNIQUE INDEX `uid_cat_k` (`uid`,`cat`,`k`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='personal (per user) configuration storage'; --- --- TABLE permissionset --- -CREATE TABLE IF NOT EXISTS `permissionset` ( - `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', - `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'Owner id of this permission set', - `allow_cid` mediumtext COMMENT 'Access Control - list of allowed contact.id \'<19><78>\'', - `allow_gid` mediumtext COMMENT 'Access Control - list of allowed groups', - `deny_cid` mediumtext COMMENT 'Access Control - list of denied contact.id', - `deny_gid` mediumtext COMMENT 'Access Control - list of denied groups', - PRIMARY KEY(`id`), - INDEX `uid_allow_cid_allow_gid_deny_cid_deny_gid` (`allow_cid`(50),`allow_gid`(30),`deny_cid`(50),`deny_gid`(30)) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT=''; - -- -- TABLE photo -- @@ -928,6 +1046,7 @@ CREATE TABLE IF NOT EXISTS `photo` ( `contact-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'contact.id', `guid` char(16) NOT NULL DEFAULT '' COMMENT 'A unique identifier for this photo', `resource-id` char(32) NOT NULL DEFAULT '' COMMENT '', + `hash` char(32) COMMENT 'hash value of the photo', `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'creation date', `edited` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'last edited date', `title` varchar(255) NOT NULL DEFAULT '' COMMENT '', @@ -955,39 +1074,105 @@ CREATE TABLE IF NOT EXISTS `photo` ( INDEX `uid_profile` (`uid`,`profile`), INDEX `uid_album_scale_created` (`uid`,`album`(32),`scale`,`created`), INDEX `uid_album_resource-id_created` (`uid`,`album`(32),`resource-id`,`created`), - INDEX `resource-id` (`resource-id`) + INDEX `resource-id` (`resource-id`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE RESTRICT, + FOREIGN KEY (`contact-id`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='photo storage'; -- --- TABLE poll +-- TABLE post-category -- -CREATE TABLE IF NOT EXISTS `poll` ( - `id` int unsigned NOT NULL auto_increment COMMENT '', +CREATE TABLE IF NOT EXISTS `post-category` ( + `uri-id` int unsigned NOT NULL COMMENT 'Id of the item-uri table entry that contains the item uri', `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', - `q0` text COMMENT '', - `q1` text COMMENT '', - `q2` text COMMENT '', - `q3` text COMMENT '', - `q4` text COMMENT '', - `q5` text COMMENT '', - `q6` text COMMENT '', - `q7` text COMMENT '', - `q8` text COMMENT '', - `q9` text COMMENT '', - PRIMARY KEY(`id`), - INDEX `uid` (`uid`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Currently unused table for storing poll results'; + `type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', + `tid` int unsigned NOT NULL DEFAULT 0 COMMENT '', + PRIMARY KEY(`uri-id`,`uid`,`type`,`tid`), + INDEX `uri-id` (`tid`), + INDEX `uid` (`uid`), + FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`tid`) REFERENCES `tag` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='post relation to categories'; -- --- TABLE poll_result +-- TABLE post-delivery-data -- -CREATE TABLE IF NOT EXISTS `poll_result` ( +CREATE TABLE IF NOT EXISTS `post-delivery-data` ( + `uri-id` int unsigned NOT NULL COMMENT 'Id of the item-uri table entry that contains the item uri', + `postopts` text COMMENT 'External post connectors add their network name to this comma-separated string to identify that they should be delivered to these networks during delivery', + `inform` mediumtext COMMENT 'Additional receivers of the linked item', + `queue_count` mediumint NOT NULL DEFAULT 0 COMMENT 'Initial number of delivery recipients, used as item.delivery_queue_count', + `queue_done` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries, used as item.delivery_queue_done', + `queue_failed` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of unsuccessful deliveries, used as item.delivery_queue_failed', + `activitypub` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries via ActivityPub', + `dfrn` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries via DFRN', + `legacy_dfrn` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries via legacy DFRN', + `diaspora` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries via Diaspora', + `ostatus` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries via OStatus', + PRIMARY KEY(`uri-id`), + FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Delivery data for items'; + +-- +-- TABLE post-media +-- +CREATE TABLE IF NOT EXISTS `post-media` ( `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', - `poll_id` int unsigned NOT NULL DEFAULT 0, - `choice` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', + `uri-id` int unsigned NOT NULL COMMENT 'Id of the item-uri table entry that contains the item uri', + `url` varbinary(511) NOT NULL COMMENT 'Media URL', + `type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT 'Media type', + `mimetype` varchar(60) COMMENT '', + `height` smallint unsigned COMMENT 'Height of the media', + `width` smallint unsigned COMMENT 'Width of the media', + `size` int unsigned COMMENT 'Media size', + `preview` varbinary(255) COMMENT 'Preview URL', + `preview-height` smallint unsigned COMMENT 'Height of the preview picture', + `preview-width` smallint unsigned COMMENT 'Width of the preview picture', + `description` text COMMENT '', PRIMARY KEY(`id`), - INDEX `poll_id` (`poll_id`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='data for polls - currently unused'; + UNIQUE INDEX `uri-id-url` (`uri-id`,`url`), + FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Attached media'; + +-- +-- TABLE post-tag +-- +CREATE TABLE IF NOT EXISTS `post-tag` ( + `uri-id` int unsigned NOT NULL COMMENT 'Id of the item-uri table entry that contains the item uri', + `type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', + `tid` int unsigned NOT NULL DEFAULT 0 COMMENT '', + `cid` int unsigned NOT NULL DEFAULT 0 COMMENT 'Contact id of the mentioned public contact', + PRIMARY KEY(`uri-id`,`type`,`tid`,`cid`), + INDEX `tid` (`tid`), + INDEX `cid` (`cid`), + FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`tid`) REFERENCES `tag` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT, + FOREIGN KEY (`cid`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='post relation to tags'; + +-- +-- TABLE post-user +-- +CREATE TABLE IF NOT EXISTS `post-user` ( + `uri-id` int unsigned NOT NULL COMMENT 'Id of the item-uri table entry that contains the item uri', + `uid` mediumint unsigned NOT NULL COMMENT 'Owner id which owns this copy of the item', + `protocol` tinyint unsigned COMMENT 'Protocol used to deliver the item for this user', + `contact-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'contact.id', + `unseen` boolean NOT NULL DEFAULT '1' COMMENT 'post has not been seen', + `hidden` boolean NOT NULL DEFAULT '0' COMMENT 'Marker to hide the post from the user', + `notification-type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', + `origin` boolean NOT NULL DEFAULT '0' COMMENT 'item originated at this site', + `psid` int unsigned COMMENT 'ID of the permission set of this post', + PRIMARY KEY(`uid`,`uri-id`), + INDEX `uri-id` (`uri-id`), + INDEX `contact-id` (`contact-id`), + INDEX `psid` (`psid`), + FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`contact-id`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`psid`) REFERENCES `permissionset` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='User specific post data'; -- -- TABLE process @@ -1048,7 +1233,8 @@ CREATE TABLE IF NOT EXISTS `profile` ( `net-publish` boolean NOT NULL DEFAULT '0' COMMENT 'publish profile in global directory', PRIMARY KEY(`id`), INDEX `uid_is-default` (`uid`,`is-default`), - FULLTEXT INDEX `pub_keywords` (`pub_keywords`) + FULLTEXT INDEX `pub_keywords` (`pub_keywords`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='user profiles data'; -- @@ -1061,7 +1247,11 @@ CREATE TABLE IF NOT EXISTS `profile_check` ( `dfrn_id` varchar(255) NOT NULL DEFAULT '' COMMENT '', `sec` varchar(255) NOT NULL DEFAULT '' COMMENT '', `expire` int unsigned NOT NULL DEFAULT 0 COMMENT '', - PRIMARY KEY(`id`) + PRIMARY KEY(`id`), + INDEX `uid` (`uid`), + INDEX `cid` (`cid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`cid`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='DFRN remote auth use'; -- @@ -1079,7 +1269,9 @@ CREATE TABLE IF NOT EXISTS `profile_field` ( PRIMARY KEY(`id`), INDEX `uid` (`uid`), INDEX `order` (`order`), - INDEX `psid` (`psid`) + INDEX `psid` (`psid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`psid`) REFERENCES `permissionset` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Custom profile fields'; -- @@ -1097,7 +1289,9 @@ CREATE TABLE IF NOT EXISTS `push_subscriber` ( `renewed` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'Date of last subscription renewal', `secret` varchar(255) NOT NULL DEFAULT '' COMMENT '', PRIMARY KEY(`id`), - INDEX `next_try` (`next_try`) + INDEX `next_try` (`next_try`), + INDEX `uid` (`uid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Used for OStatus: Contains feed subscribers'; -- @@ -1111,7 +1305,9 @@ CREATE TABLE IF NOT EXISTS `register` ( `password` varchar(255) NOT NULL DEFAULT '' COMMENT '', `language` varchar(16) NOT NULL DEFAULT '' COMMENT '', `note` text COMMENT '', - PRIMARY KEY(`id`) + PRIMARY KEY(`id`), + INDEX `uid` (`uid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='registrations requiring admin approval'; -- @@ -1122,7 +1318,8 @@ CREATE TABLE IF NOT EXISTS `search` ( `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', `term` varchar(255) NOT NULL DEFAULT '' COMMENT '', PRIMARY KEY(`id`), - INDEX `uid` (`uid`) + INDEX `uid` (`uid`), + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT=''; -- @@ -1139,88 +1336,20 @@ CREATE TABLE IF NOT EXISTS `session` ( ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='web session storage'; -- --- TABLE term +-- TABLE storage -- -CREATE TABLE IF NOT EXISTS `term` ( - `tid` int unsigned NOT NULL auto_increment COMMENT '', - `oid` int unsigned NOT NULL DEFAULT 0 COMMENT '', - `otype` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', - `type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', - `term` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `url` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `guid` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', - `received` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT '', - `global` boolean NOT NULL DEFAULT '0' COMMENT '', - `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', - PRIMARY KEY(`tid`), - INDEX `term_type` (`term`(64),`type`), - INDEX `oid_otype_type_term` (`oid`,`otype`,`type`,`term`(32)), - INDEX `uid_otype_type_term_global_created` (`uid`,`otype`,`type`,`term`(32),`global`,`created`), - INDEX `uid_otype_type_url` (`uid`,`otype`,`type`,`url`(64)), - INDEX `guid` (`guid`(64)) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='item taxonomy (categories, tags, etc.) table'; - --- --- TABLE tag --- -CREATE TABLE IF NOT EXISTS `tag` ( - `id` int unsigned NOT NULL auto_increment COMMENT '', - `name` varchar(96) NOT NULL DEFAULT '' COMMENT '', - `url` varbinary(255) NOT NULL DEFAULT '' COMMENT '', - PRIMARY KEY(`id`), - UNIQUE INDEX `type_name_url` (`name`,`url`), - INDEX `url` (`url`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='tags and mentions'; - --- --- TABLE post-category --- -CREATE TABLE IF NOT EXISTS `post-category` ( - `uri-id` int unsigned NOT NULL COMMENT 'Id of the item-uri table entry that contains the item uri', - `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', - `type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', - `tid` int unsigned NOT NULL DEFAULT 0 COMMENT '', - PRIMARY KEY(`uri-id`,`uid`,`type`,`tid`), - INDEX `uri-id` (`tid`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='post relation to categories'; - --- --- TABLE post-delivery-data --- -CREATE TABLE IF NOT EXISTS `post-delivery-data` ( - `uri-id` int unsigned NOT NULL COMMENT 'Id of the item-uri table entry that contains the item uri', - `postopts` text COMMENT 'External post connectors add their network name to this comma-separated string to identify that they should be delivered to these networks during delivery', - `inform` mediumtext COMMENT 'Additional receivers of the linked item', - `queue_count` mediumint NOT NULL DEFAULT 0 COMMENT 'Initial number of delivery recipients, used as item.delivery_queue_count', - `queue_done` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries, used as item.delivery_queue_done', - `queue_failed` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of unsuccessful deliveries, used as item.delivery_queue_failed', - `activitypub` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries via ActivityPub', - `dfrn` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries via DFRN', - `legacy_dfrn` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries via legacy DFRN', - `diaspora` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries via Diaspora', - `ostatus` mediumint NOT NULL DEFAULT 0 COMMENT 'Number of successful deliveries via OStatus', - PRIMARY KEY(`uri-id`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Delivery data for items'; - --- --- TABLE post-tag --- -CREATE TABLE IF NOT EXISTS `post-tag` ( - `uri-id` int unsigned NOT NULL COMMENT 'Id of the item-uri table entry that contains the item uri', - `type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', - `tid` int unsigned NOT NULL DEFAULT 0 COMMENT '', - `cid` int unsigned NOT NULL DEFAULT 0 COMMENT 'Contact id of the mentioned public contact', - PRIMARY KEY(`uri-id`,`type`,`tid`,`cid`), - INDEX `uri-id` (`tid`), - INDEX `cid` (`tid`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='post relation to tags'; +CREATE TABLE IF NOT EXISTS `storage` ( + `id` int unsigned NOT NULL auto_increment COMMENT 'Auto incremented image data id', + `data` longblob NOT NULL COMMENT 'file data', + PRIMARY KEY(`id`) +) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Data stored by Database storage backend'; -- -- TABLE thread -- CREATE TABLE IF NOT EXISTS `thread` ( `iid` int unsigned NOT NULL DEFAULT 0 COMMENT 'sequential ID', + `uri-id` int unsigned COMMENT 'Id of the item-uri table entry that contains the item uri', `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', `contact-id` int unsigned NOT NULL DEFAULT 0 COMMENT '', `owner-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'Item owner', @@ -1256,7 +1385,14 @@ CREATE TABLE IF NOT EXISTS `thread` ( INDEX `uid_received` (`uid`,`received`), INDEX `uid_commented` (`uid`,`commented`), INDEX `uid_wall_received` (`uid`,`wall`,`received`), - INDEX `private_wall_origin_commented` (`private`,`wall`,`origin`,`commented`) + INDEX `private_wall_origin_commented` (`private`,`wall`,`origin`,`commented`), + INDEX `uri-id` (`uri-id`), + FOREIGN KEY (`iid`) REFERENCES `item` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`contact-id`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`owner-id`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT, + FOREIGN KEY (`author-id`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Thread related data'; -- @@ -1269,62 +1405,13 @@ CREATE TABLE IF NOT EXISTS `tokens` ( `expires` int NOT NULL DEFAULT 0 COMMENT '', `scope` varchar(200) NOT NULL DEFAULT '' COMMENT '', `uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'User id', - PRIMARY KEY(`id`) + PRIMARY KEY(`id`), + INDEX `client_id` (`client_id`), + INDEX `uid` (`uid`), + FOREIGN KEY (`client_id`) REFERENCES `clients` (`client_id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='OAuth usage'; --- --- TABLE user --- -CREATE TABLE IF NOT EXISTS `user` ( - `uid` mediumint unsigned NOT NULL auto_increment COMMENT 'sequential ID', - `parent-uid` mediumint unsigned NOT NULL DEFAULT 0 COMMENT 'The parent user that has full control about this user', - `guid` varchar(64) NOT NULL DEFAULT '' COMMENT 'A unique identifier for this user', - `username` varchar(255) NOT NULL DEFAULT '' COMMENT 'Name that this user is known by', - `password` varchar(255) NOT NULL DEFAULT '' COMMENT 'encrypted password', - `legacy_password` boolean NOT NULL DEFAULT '0' COMMENT 'Is the password hash double-hashed?', - `nickname` varchar(255) NOT NULL DEFAULT '' COMMENT 'nick- and user name', - `email` varchar(255) NOT NULL DEFAULT '' COMMENT 'the users email address', - `openid` varchar(255) NOT NULL DEFAULT '' COMMENT '', - `timezone` varchar(128) NOT NULL DEFAULT '' COMMENT 'PHP-legal timezone', - `language` varchar(32) NOT NULL DEFAULT 'en' COMMENT 'default language', - `register_date` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'timestamp of registration', - `login_date` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'timestamp of last login', - `default-location` varchar(255) NOT NULL DEFAULT '' COMMENT 'Default for item.location', - `allow_location` boolean NOT NULL DEFAULT '0' COMMENT '1 allows to display the location', - `theme` varchar(255) NOT NULL DEFAULT '' COMMENT 'user theme preference', - `pubkey` text COMMENT 'RSA public key 4096 bit', - `prvkey` text COMMENT 'RSA private key 4096 bit', - `spubkey` text COMMENT '', - `sprvkey` text COMMENT '', - `verified` boolean NOT NULL DEFAULT '0' COMMENT 'user is verified through email', - `blocked` boolean NOT NULL DEFAULT '0' COMMENT '1 for user is blocked', - `blockwall` boolean NOT NULL DEFAULT '0' COMMENT 'Prohibit contacts to post to the profile page of the user', - `hidewall` boolean NOT NULL DEFAULT '0' COMMENT 'Hide profile details from unkown viewers', - `blocktags` boolean NOT NULL DEFAULT '0' COMMENT 'Prohibit contacts to tag the post of this user', - `unkmail` boolean NOT NULL DEFAULT '0' COMMENT 'Permit unknown people to send private mails to this user', - `cntunkmail` int unsigned NOT NULL DEFAULT 10 COMMENT '', - `notify-flags` smallint unsigned NOT NULL DEFAULT 65535 COMMENT 'email notification options', - `page-flags` tinyint unsigned NOT NULL DEFAULT 0 COMMENT 'page/profile type', - `account-type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', - `prvnets` boolean NOT NULL DEFAULT '0' COMMENT '', - `pwdreset` varchar(255) COMMENT 'Password reset request token', - `pwdreset_time` datetime COMMENT 'Timestamp of the last password reset request', - `maxreq` int unsigned NOT NULL DEFAULT 10 COMMENT '', - `expire` int unsigned NOT NULL DEFAULT 0 COMMENT '', - `account_removed` boolean NOT NULL DEFAULT '0' COMMENT 'if 1 the account is removed', - `account_expired` boolean NOT NULL DEFAULT '0' COMMENT '', - `account_expires_on` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'timestamp when account expires and will be deleted', - `expire_notification_sent` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'timestamp of last warning of account expiration', - `def_gid` int unsigned NOT NULL DEFAULT 0 COMMENT '', - `allow_cid` mediumtext COMMENT 'default permission for this user', - `allow_gid` mediumtext COMMENT 'default permission for this user', - `deny_cid` mediumtext COMMENT 'default permission for this user', - `deny_gid` mediumtext COMMENT 'default permission for this user', - `openidserver` text COMMENT '', - PRIMARY KEY(`uid`), - INDEX `nickname` (`nickname`(32)) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='The local users'; - -- -- TABLE userd -- @@ -1344,7 +1431,10 @@ CREATE TABLE IF NOT EXISTS `user-contact` ( `blocked` boolean COMMENT 'Contact is completely blocked for this user', `ignored` boolean COMMENT 'Posts from this contact are ignored', `collapsed` boolean COMMENT 'Posts from this contact are collapsed', - PRIMARY KEY(`uid`,`cid`) + PRIMARY KEY(`uid`,`cid`), + INDEX `cid` (`cid`), + FOREIGN KEY (`cid`) REFERENCES `contact` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='User specific public contact data'; -- @@ -1359,7 +1449,9 @@ CREATE TABLE IF NOT EXISTS `user-item` ( `notification-type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '', PRIMARY KEY(`uid`,`iid`), INDEX `uid_pinned` (`uid`,`pinned`), - INDEX `iid_uid` (`iid`,`uid`) + INDEX `iid_uid` (`iid`,`uid`), + FOREIGN KEY (`iid`) REFERENCES `item` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY (`uid`) REFERENCES `user` (`uid`) ON UPDATE RESTRICT ON DELETE CASCADE ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='User specific item data'; -- @@ -1376,7 +1468,8 @@ CREATE TABLE IF NOT EXISTS `worker-ipc` ( -- CREATE TABLE IF NOT EXISTS `workerqueue` ( `id` int unsigned NOT NULL auto_increment COMMENT 'Auto incremented worker task id', - `parameter` mediumtext COMMENT 'Task command', + `command` varchar(100) COMMENT 'Task command', + `parameter` mediumtext COMMENT 'Task parameter', `priority` tinyint unsigned NOT NULL DEFAULT 0 COMMENT 'Task priority', `created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'Creation date', `pid` int unsigned NOT NULL DEFAULT 0 COMMENT 'Process id of the worker', @@ -1385,23 +1478,16 @@ CREATE TABLE IF NOT EXISTS `workerqueue` ( `retrial` tinyint NOT NULL DEFAULT 0 COMMENT 'Retrial counter', `done` boolean NOT NULL DEFAULT '0' COMMENT 'Marked 1 when the task was done - will be deleted later', PRIMARY KEY(`id`), - INDEX `done_parameter` (`done`,`parameter`(64)), + INDEX `command` (`command`), + INDEX `done_command_parameter` (`done`,`command`,`parameter`(64)), INDEX `done_executed` (`done`,`executed`), - INDEX `done_priority_created` (`done`,`priority`,`created`), + INDEX `done_priority_retrial_created` (`done`,`priority`,`retrial`,`created`), INDEX `done_priority_next_try` (`done`,`priority`,`next_try`), INDEX `done_pid_next_try` (`done`,`pid`,`next_try`), + INDEX `done_pid_retrial` (`done`,`pid`,`retrial`), INDEX `done_pid_priority_created` (`done`,`pid`,`priority`,`created`) ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Background tasks queue entries'; --- --- TABLE storage --- -CREATE TABLE IF NOT EXISTS `storage` ( - `id` int unsigned NOT NULL auto_increment COMMENT 'Auto incremented image data id', - `data` longblob NOT NULL COMMENT 'file data', - PRIMARY KEY(`id`) -) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Data stored by Database storage backend'; - -- -- VIEW category-view -- @@ -1437,6 +1523,69 @@ CREATE VIEW `tag-view` AS SELECT LEFT JOIN `tag` ON `post-tag`.`tid` = `tag`.`id` LEFT JOIN `contact` ON `post-tag`.`cid` = `contact`.`id`; +-- +-- VIEW network-item-view +-- +DROP VIEW IF EXISTS `network-item-view`; +CREATE VIEW `network-item-view` AS SELECT + `item`.`parent-uri-id` AS `uri-id`, + `item`.`parent-uri` AS `uri`, + `item`.`parent` AS `parent`, + `item`.`received` AS `received`, + `item`.`commented` AS `commented`, + `item`.`created` AS `created`, + `item`.`uid` AS `uid`, + `item`.`starred` AS `starred`, + `item`.`mention` AS `mention`, + `item`.`network` AS `network`, + `item`.`unseen` AS `unseen`, + `item`.`gravity` AS `gravity`, + `item`.`contact-id` AS `contact-id`, + `ownercontact`.`contact-type` AS `contact-type` + FROM `item` + INNER JOIN `thread` ON `thread`.`iid` = `item`.`parent` + STRAIGHT_JOIN `contact` ON `contact`.`id` = `thread`.`contact-id` + LEFT JOIN `user-item` ON `user-item`.`iid` = `item`.`id` AND `user-item`.`uid` = `thread`.`uid` + LEFT JOIN `user-contact` AS `author` ON `author`.`uid` = `thread`.`uid` AND `author`.`cid` = `thread`.`author-id` + LEFT JOIN `user-contact` AS `owner` ON `owner`.`uid` = `thread`.`uid` AND `owner`.`cid` = `thread`.`owner-id` + LEFT JOIN `contact` AS `ownercontact` ON `ownercontact`.`id` = `thread`.`owner-id` + WHERE `thread`.`visible` AND NOT `thread`.`deleted` AND NOT `thread`.`moderated` + AND (NOT `contact`.`readonly` AND NOT `contact`.`blocked` AND NOT `contact`.`pending`) + AND (`user-item`.`hidden` IS NULL OR NOT `user-item`.`hidden`) + AND (`author`.`blocked` IS NULL OR NOT `author`.`blocked`) + AND (`owner`.`blocked` IS NULL OR NOT `owner`.`blocked`); + +-- +-- VIEW network-thread-view +-- +DROP VIEW IF EXISTS `network-thread-view`; +CREATE VIEW `network-thread-view` AS SELECT + `item`.`uri-id` AS `uri-id`, + `item`.`uri` AS `uri`, + `item`.`parent-uri-id` AS `parent-uri-id`, + `thread`.`iid` AS `parent`, + `thread`.`received` AS `received`, + `thread`.`commented` AS `commented`, + `thread`.`created` AS `created`, + `thread`.`uid` AS `uid`, + `thread`.`starred` AS `starred`, + `thread`.`mention` AS `mention`, + `thread`.`network` AS `network`, + `thread`.`contact-id` AS `contact-id`, + `ownercontact`.`contact-type` AS `contact-type` + FROM `thread` + STRAIGHT_JOIN `contact` ON `contact`.`id` = `thread`.`contact-id` + STRAIGHT_JOIN `item` ON `item`.`id` = `thread`.`iid` + LEFT JOIN `user-item` ON `user-item`.`iid` = `item`.`id` AND `user-item`.`uid` = `thread`.`uid` + LEFT JOIN `user-contact` AS `author` ON `author`.`uid` = `thread`.`uid` AND `author`.`cid` = `thread`.`author-id` + LEFT JOIN `user-contact` AS `owner` ON `owner`.`uid` = `thread`.`uid` AND `owner`.`cid` = `thread`.`owner-id` + LEFT JOIN `contact` AS `ownercontact` ON `ownercontact`.`id` = `thread`.`owner-id` + WHERE `thread`.`visible` AND NOT `thread`.`deleted` AND NOT `thread`.`moderated` + AND (NOT `contact`.`readonly` AND NOT `contact`.`blocked` AND NOT `contact`.`pending`) + AND (`user-item`.`hidden` IS NULL OR NOT `user-item`.`hidden`) + AND (`author`.`blocked` IS NULL OR NOT `author`.`blocked`) + AND (`owner`.`blocked` IS NULL OR NOT `owner`.`blocked`); + -- -- VIEW owner-view -- @@ -1494,18 +1643,18 @@ CREATE VIEW `owner-view` AS SELECT `contact`.`term-date` AS `term-date`, `contact`.`last-item` AS `last-item`, `contact`.`priority` AS `priority`, - `contact`.`blocked` AS `blocked`, + `user`.`blocked` AS `blocked`, `contact`.`block_reason` AS `block_reason`, `contact`.`readonly` AS `readonly`, `contact`.`writable` AS `writable`, `contact`.`forum` AS `forum`, `contact`.`prv` AS `prv`, `contact`.`contact-type` AS `contact-type`, + `contact`.`manually-approve` AS `manually-approve`, `contact`.`hidden` AS `hidden`, `contact`.`archive` AS `archive`, `contact`.`pending` AS `pending`, `contact`.`deleted` AS `deleted`, - `contact`.`rating` AS `rating`, `contact`.`unsearchable` AS `unsearchable`, `contact`.`sensitive` AS `sensitive`, `contact`.`baseurl` AS `baseurl`, @@ -1517,7 +1666,7 @@ CREATE VIEW `owner-view` AS SELECT `contact`.`bd` AS `bd`, `contact`.`notify_new_posts` AS `notify_new_posts`, `contact`.`fetch_further_information` AS `fetch_further_information`, - `contact`.`ffi_keyword_blacklist` AS `ffi_keyword_blacklist`, + `contact`.`ffi_keyword_denylist` AS `ffi_keyword_denylist`, `user`.`parent-uid` AS `parent-uid`, `user`.`guid` AS `guid`, `user`.`nickname` AS `nickname`, @@ -1625,5 +1774,3 @@ CREATE VIEW `workerqueue-view` AS SELECT FROM `process` INNER JOIN `workerqueue` ON `workerqueue`.`pid` = `process`.`pid` WHERE NOT `workerqueue`.`done`; - - diff --git a/doc/API-Mastodon.md b/doc/API-Mastodon.md index d9e6f22b66..588947cc3a 100644 --- a/doc/API-Mastodon.md +++ b/doc/API-Mastodon.md @@ -15,10 +15,13 @@ These endpoints use the [Mastodon API entities](https://docs.joinmastodon.org/en ## Implemented endpoints +- [`GET /api/v1//accounts/:id`](https://docs.joinmastodon.org/methods/accounts/#retrieve-information) +- [`GET /api/v1//accounts/:id/statuses`](https://docs.joinmastodon.org/methods/accounts/#retrieve-information) - [`GET /api/v1/custom_emojis`](https://docs.joinmastodon.org/methods/instance/custom_emojis/) - Doesn't return unicode emojis since they aren't using an image URL +- [`GET /api/v1/directory`](https://docs.joinmastodon.org/methods/instance/directory/) - [`GET /api/v1/follow_requests`](https://docs.joinmastodon.org/methods/accounts/follow_requests#pending-follows) - Returned IDs are specific to follow requests - [`POST /api/v1/follow_requests/:id/authorize`](https://docs.joinmastodon.org/methods/accounts/follow_requests#accept-follow) @@ -33,6 +36,8 @@ These endpoints use the [Mastodon API entities](https://docs.joinmastodon.org/en - [`GET /api/v1/instance`](https://docs.joinmastodon.org/methods/instance#fetch-instance) - [`GET /api/v1/instance/peers`](https://docs.joinmastodon.org/methods/instance#list-of-connected-domains) +- [`GET /api/v1/timelines/public`](https://docs.joinmastodon.org/methods/timelines/) +- [`GET /api/v1/trends`](https://docs.joinmastodon.org/methods/instance/trends/) ## Non-implemented endpoints diff --git a/doc/API-Twitter.md b/doc/API-Twitter.md index d352e528de..fab26ae5b5 100644 --- a/doc/API-Twitter.md +++ b/doc/API-Twitter.md @@ -152,19 +152,29 @@ These endpoints use the [Friendica API entities](help/API-Entities). - [GET api/friendships/incoming](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-incoming) - Unsupported parameters - `stringify_ids` -- [GET api/followers/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids) - - Unsupported parameters: - - `user_id`: Relationships aren't returned for other users than self - - `screen_name`: Relationships aren't returned for other users than self -- [GET api/friends/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids) - - Unsupported parameters: - - `user_id`: Relationships aren't returned for other users than self - - `screen_name`: Relationships aren't returned for other users than self + +- - [GET api/followers/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids) + - [GET api/followers/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list) + - [GET api/friends/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids) + - [GET api/friends/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list) + - Additional parameters: + - `since_id`: You can use the `next_cursor` value to load the next page. + - `max_id`: You can use the inverse of the `previous_cursor` value to load the previous page. + - Unsupported parameter: + - `skip_status`: No status is returned even if it isn't set to true. + - Caveats: + - `cursor` trumps `since_id` trumps `max_id` if any combination is provided. + - `user_id` must be the ID of a contact associated with a local user account. + - `screen_name` must be associated with a local user account. + - `screen_name` trumps `user_id` if both are provided (undocumented Twitter behavior). + - Will succeed but return an empty array for users hiding their contact lists. - [POST api/friendships/destroy](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/post-friendships-destroy) + + ## Non-implemented endpoints - [GET oauth/authenticate](https://developer.twitter.com/en/docs/basics/authentication/api-reference/authenticate) @@ -188,8 +198,6 @@ These endpoints use the [Friendica API entities](help/API-Entities). - [POST lists/subscribers/destroy](https://developer.twitter.com/en/docs/accounts-and-users/create-manage-lists/api-reference/post-lists-subscribers-destroy) -- [GET followers/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list) -- [GET friends/list](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list) - [GET friendships/lookup](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-lookup) - [GET friendships/no_retweets/ids](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-no_retweets-ids) - [GET friendships/outgoing](https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friendships-outgoing) diff --git a/doc/Addons.md b/doc/Addons.md index bf8f9eef45..c1861c7913 100644 --- a/doc/Addons.md +++ b/doc/Addons.md @@ -466,6 +466,19 @@ Hook data is a `\FastRoute\RouterCollector` object that should be used to add ad **Notice**: The class whose name is provided in the route handler must be reachable via auto-loader. +### probe_detect + +Called before trying to detect the target network of a URL. +If any registered hook function sets the `result` key of the hook data array, it will be returned immediately. +Hook functions should also return immediately if the hook data contains an existing result. + +Hook data: + +- **uri** (input): the profile URI. +- **network** (input): the target network (can be empty for auto-detection). +- **uid** (input): the user to return the contact data for (can be empty for public contacts). +- **result** (output): Set by the hook function to indicate a successful detection. + ## Complete list of hook callbacks Here is a complete list of all hook callbacks with file locations (as of 24-Sep-2018). Please see the source for details of any hooks not documented above. @@ -505,10 +518,6 @@ Here is a complete list of all hook callbacks with file locations (as of 24-Sep- Hook::callAll('item_photo_menu', $args); Hook::callAll('jot_tool', $jotplugins); -### include/items.php - - Hook::callAll('page_info_data', $data); - ### mod/directory.php Hook::callAll('directory_item', $arr); @@ -595,10 +604,6 @@ Here is a complete list of all hook callbacks with file locations (as of 24-Sep- Hook::callAll('post_local_end', $arr); -### mod/lockview.php - - Hook::callAll('lockview_content', $item); - ### mod/uexport.php Hook::callAll('uexport_options', $options); @@ -670,6 +675,10 @@ Here is a complete list of all hook callbacks with file locations (as of 24-Sep- Hook::callAll('register_account', $uid); Hook::callAll('remove_user', $user); +### src/Module/PermissionTooltip.php + + Hook::callAll('lockview_content', $item); + ### src/Content/ContactBlock.php Hook::callAll('contact_block_end', $arr); diff --git a/doc/BBCode.md b/doc/BBCode.md index cab51bd09f..753bc69425 100644 --- a/doc/BBCode.md +++ b/doc/BBCode.md @@ -65,17 +65,17 @@ table.bbcodes > * > tr > th { Friendica - [img]https://raw.githubusercontent.com/friendica/friendica/master/images/friendica-32.jpg[/img] - Immagine/foto + [img]https://raw.githubusercontent.com/friendica/friendica/stable/images/friendica-32.jpg[/img] + Immagine/foto - [img=https://raw.githubusercontent.com/friendica/friendica/master/images/friendica-32.jpg]The Friendica Logo[/img] - The Friendica Logo + [img=https://raw.githubusercontent.com/friendica/friendica/stable/images/friendica-32.jpg]The Friendica Logo[/img] + The Friendica Logo - [img=64x32]https://raw.githubusercontent.com/friendica/friendica/master/images/friendica-32.jpg[/img]
+ [img=64x32]https://raw.githubusercontent.com/friendica/friendica/stable/images/friendica-32.jpg[/img]

Note: provided height is simply discarded. - + [size=xx-small]small text[/size] @@ -502,10 +502,6 @@ You can embed video, audio and more in a message. [embed]URL[/embed] Embed OEmbed rich content. - - [iframe]URL[/iframe] - General embed, iframe size is limited by the theme size for video players. - [url]*url*[/url] If *url* supports oembed or opengraph specifications the embedded object will be shown (eg, documents from scribd). @@ -613,15 +609,34 @@ On Mastodon this field is used for the content warning. Result - If you need to put literal bbcode in a message, [noparse], [nobb] or [pre] are used to escape bbcode: + If you need to put literal BBCode in a message, [noparse], [nobb] or [pre] blocks prevent BBCode conversion: + Note: [code] has priority over [noparse], [nobb] and [pre] which makes them display as BBCode tags in code blocks instead of being removed. + [code] blocks inside [noparse] will still be converted to a code block. [b]bold[/b] + + Additionally, [noparse] and [pre] blocks prevent mention and hashtag conversion to links: + + + @user@domain.tld #hashtag + + + Additionally, [pre] blocks preserve spaces: + + +       Spaces + [nosmile] is used to disable smilies on a post by post basis

diff --git a/doc/Chats.md b/doc/Chats.md index 29067e1280..df3e7c784d 100644 --- a/doc/Chats.md +++ b/doc/Chats.md @@ -43,7 +43,7 @@ At first you have to get the current version. You can either pull it from [Githu $> cd /var/www/virtual/YOURSPACE/html/addon; git pull -Or you can download a tar archive here: [jappixmini.tgz](https://github.com/friendica/friendica-addons/blob/master/jappixmini.tgz) (click at „view raw“). +Or you can download a tar archive here: [jappixmini.tgz](https://github.com/friendica/friendica-addons/blob/stable/jappixmini.tgz) (click at „view raw“). Just unpack the file and rename the directory to „jappixmini“. Next, upload this directory and the .tgz-file into your addon directory of your friendica installation. diff --git a/doc/Forums.md b/doc/Forums.md index 03657b6afe..d58a8565a5 100644 --- a/doc/Forums.md +++ b/doc/Forums.md @@ -4,49 +4,46 @@ Forums * [Home](help) -Friendica also lets you create forums and/or celebrity accounts. +Friendica also lets you create community forums and other types of accounts that can function as discussion forums, celebrity accounts, announcement channels, news reflectors, or organization pages, depending on how you want to interact with others. Management of these pages can be delegated to other accounts, or a parent account can be designated to easily toggle multiple identities. -Every page in Friendica has a nickname and these must all be unique. -This applies to all forums, whether they are normal profiles or forum profiles. +Every page in Friendica has a nickname and these must all be unique. This applies to all forums, whether they are normal profiles or forum profiles. -Therefore the first thing you need to do to create a new forum is to register a new account for the forum. -Please note that the site administrator can restrict and/or regulate the registration of new accounts. - -If you create a second account on a system and use the same email address or OpenID account as an existing account, you will no longer be able to use the email address (or OpenID) to log in to the account. -You should log in using the account nickname instead. - -On the new account, visit the 'Settings' page. -Towards the end of the page are "Advanced Account/Page Type Settings". -Typically you would use "Normal Account" for a normal personal account. -This is the default selection. -Community Forum/Celebrity Accounts provide the ability for people to become friends/fans of the forum without requiring approval. - -The exact setting you would use depends on how you wish to interact with people who join the page. -The "Soapbox" setting lets the page owner control all communications. -Everything you post will go out to the forum members, but there will be no opportunity for interaction. -This setting would typically be used for announcements or corporate communications. - -The most common setting is the "Community Forum". -This creates a forum page where all members can freely interact. - -The "Automatic Friend Account" is typically used for personal profile forums where you wish to automatically approve any friendship/connection requests. - -Managing Multiple forums +Managing Accounts --- -We recommend that you create group forums with the same email address and password as your normal account. -If you do this, you will find a new "Manage" tab on the menu bar which lets you toggle identities easily and manage your forums. -You are not required to do this, but the alternative is to log out and log back into the other account to manage alternate forums. -This could get cumbersome if you manage several different forums/identities. +To create a new linked account that can be used as a forum, log in to your normal account and go to Settings > Manage Accounts. +Here you can register additional accounts with new nicknames that will be linked to your primary account. -You may also appoint a delegate to manage your forum. -Do this by visiting the [Delegation Setup Page](settings/delegation). -This will provide you with a list of contacts on this system under "Potential Delegates". +You may appoint a delegate to manage your new account (e.g. forum page). +The Delegates section of Manage Accounts page will provide you with a list of contacts on this instance under "Potential Delegates". Selecting one or more persons will give them access to manage your forum. They will be able to edit contacts, profiles, and all content for this account/page. Please use this facility wisely. -Delegated managers will not be able to alter basic account settings such as passwords or page types and/or remove the account. +Delegated managers will not be able to alter basic account settings, such as passwords or page types, or remove the account. +Additionally, this page is also where you can choose to designate an account as a parent user. +If your primary account is designated as the parent user, you will be able to easily toggle identities and manage your forums or other types of accounts. + +Types of Accounts +--- + +On the new account, visit the Settings > Account page. +Towards the end of the page is a section for "Advanced account types". +Typically you would use "Personal Page - Standard" for a normal personal account with manual approval of “friends” and “followers.” +This is the default selection. +On this page you can change the type of account if desired. + +The other subtypes of a Personal Page are “Soapbox” and “Love-all.” +A Soapbox account is an announcement channel that automatically approvals follower requests. +Everything posted by the account will go out to the followers, but there will be no opportunity for interaction. +This setting would typically be used for announcements or corporate communications. +“Love-all” automatically approves contacts as friends. + +In addition to Personal Page, there are options for Organization Page, News Page, and Community Forum. +Organization and New Pages automatically approve contact requests as followers. + +Community Forum provide the ability for people to become friends/fans of the forum without requiring approval. +This creates a forum page where all members can freely interact. Posting to Community forums --- diff --git a/doc/Github.md b/doc/Github.md index ca467e5254..5fbc3788ee 100644 --- a/doc/Github.md +++ b/doc/Github.md @@ -22,7 +22,7 @@ Our Git Branches There are two relevant branches in the main repo on GitHub: -1. master: This branch contains stable releases only. +1. stable: This branch contains stable releases only. 2. develop: This branch contains the latest code. This is what you want to work with. @@ -43,7 +43,7 @@ Release branches A release branch is created when the develop branch contains all features it should have. A release branch is used for a few things. -1. It allows last-minute bug fixing before the release goes to master branch. +1. It allows last-minute bug fixing before the release goes to stable branch. 2. It allows meta-data changes (README, CHANGELOG, etc.) for version bumps and documentation changes. 3. It makes sure the develop branch can receive new features that are **not** part of this release. diff --git a/doc/Home.md b/doc/Home.md index 9ed552bd3a..bd6fad7471 100644 --- a/doc/Home.md +++ b/doc/Home.md @@ -35,6 +35,7 @@ Friendica Documentation and Resources * [Using SSL with Friendica](help/SSL) * [Config values that can only be set in config/local.config.php](help/Config) * [Improve Performance](help/Improve-Performance) +* [Migrate](help/Migrate) * [Administration Tools](help/tools) **Developer Manual** diff --git a/doc/Install.md b/doc/Install.md index f37521d759..8d66425a83 100644 --- a/doc/Install.md +++ b/doc/Install.md @@ -33,7 +33,7 @@ The account will expire after 7 days, but you can ask the server admin to keep y * Apache with mod-rewrite enabled and "Options All" so you can use a local `.htaccess` file * PHP 7+ (PHP 7.1+ is recommended for performance and official support) * PHP *command line* access with register_argc_argv set to true in the php.ini file - * Curl, GD, PDO, MySQLi, hash, xml, zip and OpenSSL extensions + * Curl, GD, PDO, mbstrings, MySQLi, hash, xml, zip and OpenSSL extensions * The POSIX module of PHP needs to be activated (e.g. [RHEL, CentOS](http://www.bigsoft.co.uk/blog/index.php/2014/12/08/posix-php-commands-not-working-under-centos-7) have disabled it) * some form of email server or email gateway such that PHP mail() works * MySQL 5.6+ or an equivalent alternative for MySQL (MariaDB, Percona Server etc.) @@ -47,7 +47,6 @@ For alternative server configurations (such as Nginx server and MariaDB database ### Optional * PHP ImageMagick extension (php-imagick) for animated GIF support. -* [Composer](https://getcomposer.org/) for a git install ## Installation procedure @@ -61,6 +60,8 @@ If this is nothing for you, you might be interested in ### Get Friendica +Download the full archive of the stable release of Friendica core and the addons from [the project homepage](https://friendi.ca/resources/download-files/). +Make sure that the version of the Friendica archive and the addons match. Unpack the Friendica files into the root of your web server document area. If you copy the directory tree to your webserver, make sure that you also copy `.htaccess-dist` - as "dot" files are often hidden and aren't normally copied. @@ -72,7 +73,7 @@ This makes the software much easier to update. The Linux commands to clone the repository into a directory "mywebsite" would be - git clone https://github.com/friendica/friendica.git -b master mywebsite + git clone https://github.com/friendica/friendica.git -b stable mywebsite cd mywebsite bin/composer.phar install --no-dev @@ -88,7 +89,7 @@ Get the addons by going into your website folder. Clone the addon repository (separately): - git clone https://github.com/friendica/friendica-addons.git -b master addon + git clone https://github.com/friendica/friendica-addons.git -b stable addon If you want to use the development version of Friendica you can switch to the develop branch in the repository by running @@ -435,7 +436,7 @@ provided by one of our members. > > This is obvious as soon as you notice that the friendica-cron uses `proc_open` > to execute PHP scripts that also use `proc_open`, but it took me quite some time to find that out. -> I hope this saves some time for other people using suhosin with function blacklists. +> I hope this saves some time for other people using suhosin with function blocklists. ### Unable to create all mysql tables on MySQL 5.7.17 or newer diff --git a/doc/Message-Flow.md b/doc/Message-Flow.md index 9692ae88cb..e967985695 100644 --- a/doc/Message-Flow.md +++ b/doc/Message-Flow.md @@ -4,9 +4,7 @@ Friendica Message Flow This page documents some of the details of how messages get from one person to another in the Friendica network. There are multiple paths, using multiple protocols and message formats. -Those attempting to understand these message flows should become familiar with (at the minimum) the [DFRN protocol document](https://github.com/friendica/friendica/blob/master/spec/dfrn2.pdf) and the message passing elements of the OStatus stack (salmon and Pubsubhubbub). - -Most message passing involves the file include/items.php, which has functions for several feed-related import/export activities. +Those attempting to understand these message flows should become familiar with (at the minimum) the [DFRN protocol document](https://github.com/friendica/friendica/blob/stable/spec/dfrn2.pdf) and the message passing elements of the OStatus stack (salmon and Pubsubhubbub). When a message is posted, all immediate deliveries to all networks are made using include/notifier.php, which chooses how (and to whom) to deliver the message. This file also invokes the local side of all deliveries including DFRN-notify. diff --git a/doc/Migrate.md b/doc/Migrate.md new file mode 100644 index 0000000000..e116d029f6 --- /dev/null +++ b/doc/Migrate.md @@ -0,0 +1,92 @@ +Migrating to a new server installation +=============== + +* [Home](help) + +## Preparation + +### New server +Set up your new server as described [here](Install); follow the installation procedure until you have created a database. + +### Heads up to users +Inform your users of an upcoming interruption to your service. +To ensure data consistency, your server needs to be offline during some steps of the migration processes. + +You may also find these addons useful for communicating with your users prior to the migration process: +* blackout +* notifyall + +### Storage +Check your storage backend with ``bin/console storage list`` in the root folder. +The output should look like this: +```` +Sel | Name +----------------------- + | Filesystem + * | Database +```` + +If you are *not* using ``Database`` run the following commands: +1. ``bin/console storage set Database`` to activate the database backend. +2. ``bin/console storage move`` to initiate moving the stored image files. + +This process may take a long time depending on the size of your storage and your server's capacity. +Prior to initiating this process, you may want to check the number of files in the storage with the following command: ``tree -if -I index.html /path/to/storage/``. + +### Cleaning up +Before transferring your database, you may want to clean it up; ensure the expiration of database items is set to a reasonable value and activated via the administrator panel. +*Admin* > *Site* > *Performance* > Enable "Clean up database" +After adjusting these settings, the database cleaning up processes will be initiated according to your configured daily cron job. + +To review the size of your database, log into MySQL with ``mysql -p`` run the following query: +```` +SELECT table_schema AS "Database", SUM(data_length + index_length) / 1024 / 1024 / 1024 AS "Size (GB)" FROM information_schema.TABLES GROUP BY table_schema; +```` + +You should see an output like this: +```` ++--------------------+----------------+ +| Database | Size (GB) | ++--------------------+----------------+ +| friendica_db | 8.054092407227 | +| [..........] | [...........] | ++--------------------+----------------+ +```` + +Finally, you may also want to optimise your database with the following command: ``mysqloptimize -p friendica-db`` + +### Going offline +Stop background tasks and put your server in maintenance mode. +1. If you had set up a worker cron job like this ``*/10 * * * * cd /var/www/friendica; /usr/bin/php bin/worker.php`` run ``crontab -e`` and comment out this line. Alternatively if you deploy a worker daemon, disable this instead. +2. Put your server into maintenance mode: ``bin/console maintenance 1 "We are currently upgrading our system and will be back soon."`` + +## Dumping DB +Export your database: ``mysqldump -p friendica_db > friendica_db-$(date +%Y%m%d).sql`` and possibly compress it. + +## Transferring to new server +Transfer your database and a copy of your configuration file ``config/local.config.php.copy`` to your new server installation. + +## Restoring your DB +Import your database on your new server: ``mysql -p friendica_db < your-friendica_db-file.sql`` + +## Completing migration + +### Configuration file +Copy your old server's configuration file to ``config/local.config.php``. +Ensure the newly created database credentials are identical to the setting in the configuration file; otherwise update them accordingly. + +### Cron job for worker +Set up the required daily cron job. +Run ``crontab -e`` and add the following line according to your system specification +``*/10 * * * * cd /var/www/friendica; /usr/bin/php bin/worker.php`` + +### DNS settings +Adjust your DNS records by pointing them to your new server. + +## Troubleshooting +If you are unable to login to your newly migrated Friendica installation, check your web server's error and access logs and mysql logs for obvious issues. + +If still unable to resolve the problem, it's likely an issue with your [installation](Install). +In this case, you may try to an entirely new Friendica installation on your new server, but use a different FQDN and DNS name. +Once you have this up and running, take it offline and purge the database and configuration file and try migrating to this installation. + diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000000..4902b4cdf6 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,21 @@ +# About the docs of the Friendica Project + +**Note**: It is expected that some of the links in these files wont work in the Friendica repository as they are supposed to work on an installed Friendica node. + +## User and Admin documentation + +Every Friendica node has the _current_ version of the user and admin documentation available in the `/help` location. +The documentation is mainly done in English, but the pages can be translated and some are already to German. +If you want to help expanding the documentation or the translation, please register an account at the [Friendica wiki](https://wiki.friendi.ca) where the [texts are maintained](https://wiki.friendi.ca/docs). +The documentation is periodically merged back from there to the _development_ branch of Friendica. + +Images that you use in the documentation should be located in the `img` sub-directory of this directory. +Translations are located in sub-directories named after the language codes, e.g. `de`. +Depending on the selected interface language the different translations will be applied, or the `en` original will be used as a fall-back. + +## Developers Documentation + +We provide a configuration file for [Doxygen](https://www.doxygen.nl/index.html) in the root of the Friendica repository. +With that you should be able to extract some documentation from the source code. + +In addition there are some documentation files about the database structure in `doc`db`. diff --git a/doc/Settings.md b/doc/Settings.md index f2af9617cd..16b7ec38a4 100644 --- a/doc/Settings.md +++ b/doc/Settings.md @@ -74,7 +74,7 @@ You can chose between the following modes: ##### Invitation based registry Additionally to the setting in the admin panel, you can decide if registrations are only possible using an invitation code or not. -To enable invitation based registration, you have to set the `invitation_only` setting in the [config/local.config.php](/help/Config) file. +To enable invitation based registration, you have to set the `invitation_only` setting to `true` in the `system` section of the [config/local.config.php](/help/Config) file. If you want to use this method, the registration policy has to be set to either *open* or *requires approval*. #### Check Full Names @@ -182,7 +182,7 @@ By default, any (valid) email address is allowed in registrations. #### Allow Users to set remote_self -If you enable the `Allow Users to set remote_self` users can select Atom feeds from their contact list being their *remote self* in the advanced contact settings. +If you enable the `Allow Users to set remote_self` users can select Atom feeds from their contact list being their *remote self* in the contact settings. Which means that postings by the remote self are automatically reposted by Friendica in their names. This feature can be used to let the user mirror e.g. blog postings into their Friendica postings. @@ -240,15 +240,9 @@ This section allows you to configure the background process that is triggered by The process does check the available system resources before creating a new worker for a task. Because of this, it may happen that the maximum number of worker processes you allow will not be reached. -If your server setup does not allow you to use the `proc_open` function of PHP, please disable it in this section. - The tasks for the background process have priorities. To guarantee that important tasks are executed even though the system has a lot of work to do, it is useful to enable the *fastlane*. -Should you not be able to run a cron job on your server, you can also activate the *frontend* worker. -If you have done so, you can call `example.com/worker` (replace example.com with your actual domain name) on a regular basis from an external service. -This will then trigger the execution of the background process. - ### Relocate ## Users diff --git a/doc/Update.md b/doc/Update.md index 7f8a0fcae5..c4fe161863 100644 --- a/doc/Update.md +++ b/doc/Update.md @@ -36,11 +36,11 @@ The addon tree has to be updated separately like so: git pull For both repositories: -The default branch to use is the ``master`` branch, which is the stable version of Friendica. +The default branch to use is the ``stable`` branch, which is the stable version of Friendica. It is updated about four times a year on a fixed schedule. If you want to use and test bleeding edge code please checkout the ``develop`` branch. -The new features and fixes will be merged from ``develop`` into ``master`` after a release candidate period before each release. +The new features and fixes will be merged from ``develop`` into ``stable`` after a release candidate period before each release. Warning: The ``develop`` branch is unstable, and breaks on average once a month for at most 24 hours until a patch is submitted and merged. Be sure to pull frequently if you choose the ``develop`` branch. diff --git a/doc/database.md b/doc/database.md index b58fba9d98..e135b19ae7 100644 --- a/doc/database.md +++ b/doc/database.md @@ -18,9 +18,6 @@ Database Tables | [event](help/database/db_event) | Events | | [fcontact](help/database/db_fcontact) | friend suggestion stuff | | [fsuggest](help/database/db_fsuggest) | friend suggestion stuff | -| [gcign](help/database/db_gcign) | contacts ignored by friend suggestions | -| [gcontact](help/database/db_gcontact) | global contacts | -| [glink](help/database/db_glink) | "friends of friends" linkages derived from poco | | [group](help/database/db_group) | privacy groups, group info | | [group_member](help/database/db_group_member) | privacy groups, member info | | [gserver](help/database/db_gserver) | | diff --git a/doc/database/db_contact.md b/doc/database/db_contact.md index eb5c505d13..2963a1a2a4 100644 --- a/doc/database/db_contact.md +++ b/doc/database/db_contact.md @@ -67,6 +67,6 @@ Table contact | bd | | date | NO | | 0001-01-01 | | | notify_new_posts | | tinyint(1) | NO | | 0 | | | fetch_further_information | | tinyint(1) | NO | | 0 | | -| ffi_keyword_blacklist | | mediumtext | NO | | NULL | | +| ffi_keyword_denylist | | mediumtext | NO | | NULL | | Return to [database documentation](help/database) diff --git a/doc/database/db_gcign.md b/doc/database/db_gcign.md deleted file mode 100644 index 9f5bbce76c..0000000000 --- a/doc/database/db_gcign.md +++ /dev/null @@ -1,10 +0,0 @@ -Table gcign -=========== - -| Field | Description | Type | Null | Key | Default | Extra | -| ----- | ------------------------------ | ------- | ---- | --- | ------- | --------------- | -| id | sequential ID | int(11) | NO | PRI | NULL | auto_increment | -| uid | local user.id | int(11) | NO | MUL | 0 | | -| gcid | gcontact.id of ignored contact | int(11) | NO | MUL | 0 | | - -Return to [database documentation](help/database) diff --git a/doc/database/db_gcontact.md b/doc/database/db_gcontact.md deleted file mode 100644 index 3c0118a33d..0000000000 --- a/doc/database/db_gcontact.md +++ /dev/null @@ -1,32 +0,0 @@ -Table gcontact -============== - -| Field |Description | Type | Null | Key | Default | Extra | -|--------------|------------------------------------|------------------|------|-----|---------------------|----------------| -| id | sequential ID | int(10) unsigned | NO | PRI | NULL | auto_increment | -| name | Name that this contact is known by | varchar(255) | NO | | | | -| nick | Nick- and user name of the contact | varchar(255) | NO | | | | -| url | Link to the contacts profile page | varchar(255) | NO | | | | -| nurl | | varchar(255) | NO | MUL | | | -| photo | Link to the profile photo | varchar(255) | NO | | | | -| connect | | varchar(255) | NO | | | | -| created | | datetime | NO | | 0001-01-01 00:00:00 | | -| updated | | datetime | YES | MUL | 0001-01-01 00:00:00 | | -| last_contact | | datetime | YES | | 0001-01-01 00:00:00 | | -| last_failure | | datetime | YES | | 0001-01-01 00:00:00 | | -| location | | varchar(255) | NO | | | | -| about | | text | NO | | NULL | | -| keywords | puplic keywords (interests) | text | NO | | NULL | | -| gender | | varchar(32) | NO | | | | -| birthday | | varchar(32) | NO | | 0001-01-01 | | -| community | 1 if contact is forum account | tinyint(1) | NO | | 0 | | -| hide | 1 = should be hidden from search | tinyint(1) | NO | | 0 | | -| nsfw | 1 = contact posts nsfw content | tinyint(1) | NO | | 0 | | -| network | social network protocol | varchar(255) | NO | | | | -| addr | | varchar(255) | NO | | | | -| notify | | text | NO | | | | -| alias | | varchar(255) | NO | | | | -| generation | | tinyint(3) | NO | | 0 | | -| server_url | baseurl of the contacts server | varchar(255) | NO | | | | - -Return to [database documentation](help/database) diff --git a/doc/database/db_glink.md b/doc/database/db_glink.md deleted file mode 100644 index 734eb04b9d..0000000000 --- a/doc/database/db_glink.md +++ /dev/null @@ -1,13 +0,0 @@ -Table glink -=========== - -| Field | Description | Type | Null | Key | Default | Extra | -|---------|------------------|------------------|------|-----|---------------------|----------------| -| id | sequential ID | int(10) unsigned | NO | PRI | NULL | auto_increment | -| cid | | int(11) | NO | MUL | 0 | | -| uid | | int(11) | NO | | 0 | | -| gcid | | int(11) | NO | MUL | 0 | | -| zcid | | int(11) | NO | MUL | 0 | | -| updated | | datetime | NO | | 0001-01-01 00:00:00 | | - -Return to [database documentation](help/database) diff --git a/doc/de/Addons.md b/doc/de/Addons.md index b54f011bfe..2ff7495497 100644 --- a/doc/de/Addons.md +++ b/doc/de/Addons.md @@ -226,10 +226,6 @@ Eine komplette Liste aller Hook-Callbacks mit den zugehörigen Dateien (am 01-Ap Hook::callAll('item_photo_menu', $args); Hook::callAll('jot_tool', $jotplugins); -### include/items.php - - Hook::callAll('page_info_data', $data); - ### mod/directory.php Hook::callAll('directory_item', $arr); @@ -316,10 +312,6 @@ Eine komplette Liste aller Hook-Callbacks mit den zugehörigen Dateien (am 01-Ap Hook::callAll('post_local_end', $arr); -### mod/lockview.php - - Hook::callAll('lockview_content', $item); - ### mod/uexport.php Hook::callAll('uexport_options', $options); @@ -426,6 +418,10 @@ Eine komplette Liste aller Hook-Callbacks mit den zugehörigen Dateien (am 01-Ap Hook::callAll('storage_instance', $data); +### src/Module/PermissionTooltip.php + + Hook::callAll('lockview_content', $item); + ### src/Worker/Directory.php Hook::callAll('globaldir_update', $arr); diff --git a/doc/de/BBCode.md b/doc/de/BBCode.md index 1db798427b..ded52cdb78 100644 --- a/doc/de/BBCode.md +++ b/doc/de/BBCode.md @@ -65,17 +65,17 @@ table.bbcodes > * > tr > th { Friendica - [img]https://raw.githubusercontent.com/friendica/friendica/master/images/friendica-32.jpg[/img] - Immagine/foto + [img]https://raw.githubusercontent.com/friendica/friendica/stable/images/friendica-32.jpg[/img] + Immagine/foto - [img=https://raw.githubusercontent.com/friendica/friendica/master/images/friendica-32.jpg]Das Friendica Logo[/img] - Das Friendica Logo + [img=https://raw.githubusercontent.com/friendica/friendica/stable/images/friendica-32.jpg]Das Friendica Logo[/img] + Das Friendica Logo - [img=64x32]https://raw.githubusercontent.com/friendica/friendica/master/images/friendica-32.jpg[/img]
+ [img=64x32]https://raw.githubusercontent.com/friendica/friendica/stable/images/friendica-32.jpg[/img]

Note: provided height is simply discarded. - + [size=xx-small]kleiner Text[/size] @@ -482,10 +482,6 @@ Du kannst Videos, Musikdateien und weitere Dinge in Beiträgen einbinden. [embed]URL[/embed] OEmbed rich content einbetten. - - [iframe]URL[/iframe] - General embed, iframe size is limited by the theme size for video players. - [url]*url*[/url] Wenn *url* die OEmbed- oder Opengraph-Spezifikationen unterstützt, wird das Objekt eingebettet (z.B. Dokumente von scribd). diff --git a/doc/de/Chats.md b/doc/de/Chats.md index 9c1a82b189..83c55e991c 100644 --- a/doc/de/Chats.md +++ b/doc/de/Chats.md @@ -49,7 +49,7 @@ Per Git: cd /var/www/<Pfad zu Deiner friendica-Installation>/addon; git pull

-oder als normaler Download von hier: https://github.com/friendica/friendica-addons/blob/master/jappixmini.tgz (auf „view raw“ klicken) +oder als normaler Download von hier: https://github.com/friendica/friendica-addons/blob/stable/jappixmini.tgz (auf „view raw“ klicken) Entpacke diese Datei (ggf. den entpackten Ordner in „jappixmini“ umbenennen) und lade sowohl den entpackten Ordner komplett als auch die .tgz Datei in den Addon Ordner Deiner Friendica Installation hoch. diff --git a/doc/de/Install.md b/doc/de/Install.md index 8225993e42..8b9434a33b 100644 --- a/doc/de/Install.md +++ b/doc/de/Install.md @@ -55,7 +55,7 @@ Wenn du die Möglichkeit hierzu hast, empfehlen wir dir "git" zu nutzen, um die Das macht die Aktualisierung wesentlich einfacher. Der Linux-Code, mit dem man die Dateien direkt in ein Verzeichnis wie "meinewebseite" kopiert, ist - git clone https://github.com/friendica/friendica.git -b master mywebsite + git clone https://github.com/friendica/friendica.git -b stable mywebsite cd mywebsite bin/composer.phar install @@ -70,7 +70,7 @@ Falls Addons installiert werden sollen: Gehe in den Friendica-Ordner Und die Addon Repository klonst: - git clone https://github.com/friendica/friendica-addons.git -b master addon + git clone https://github.com/friendica/friendica-addons.git -b stable addon Um das Addon-Verzeichnis aktuell zu halten, solltest du in diesem Pfad ein "git pull"-Befehl eintragen diff --git a/doc/de/Message-Flow.md b/doc/de/Message-Flow.md index 8ef8704d13..ef2a0a2715 100644 --- a/doc/de/Message-Flow.md +++ b/doc/de/Message-Flow.md @@ -6,9 +6,7 @@ Friendica Nachrichtenfluss Diese Seite soll einige Infos darüber dokumentieren, wie Nachrichten innerhalb von Friendica von einer Person zur anderen übertragen werden. Es gibt verschiedene Pfade, die verschiedene Protokolle und Nachrichtenformate nutzen. -Diejenigen, die den Nachrichtenfluss genauer verstehen wollen, sollten sich mindestens mit dem DFRN-Protokoll ([Dokument mit den DFRN Spezifikationen](https://github.com/friendica/friendica/blob/master/spec/dfrn2.pdf)) und den Elementen zur Nachrichtenverarbeitung des OStatus Stack informieren (salmon und Pubsubhubbub). - -Der Großteil der Nachrichtenverarbeitung nutzt die Datei include/items.php, welche Funktionen für verschiedene Feed-bezogene Import-/Exportaktivitäten liefert. +Diejenigen, die den Nachrichtenfluss genauer verstehen wollen, sollten sich mindestens mit dem DFRN-Protokoll ([Dokument mit den DFRN Spezifikationen](https://github.com/friendica/friendica/blob/stable/spec/dfrn2.pdf)) und den Elementen zur Nachrichtenverarbeitung des OStatus Stack informieren (salmon und Pubsubhubbub). Wenn eine Nachricht veröffentlicht wird, werden alle Übermittlungen an alle Netzwerke mit include/notifier.php durchgeführt, welche entscheidet, wie und an wen die Nachricht geliefert wird. Diese Datei bindet dabei die lokale Bearbeitung aller Übertragungen ein inkl. dfrn-notify. diff --git a/doc/de/Settings.md b/doc/de/Settings.md index abb3817570..34b349e885 100644 --- a/doc/de/Settings.md +++ b/doc/de/Settings.md @@ -172,7 +172,7 @@ Wildcards werden akzeptiert Standardmäßig sind alle gültigen Email-Adressen e #### Nutzern erlauben das remote_self Flag zu setzen -Webb du die Option `Nutzern erlauben das remote_self Flag zu setzen` aktivierst, können alle Nutzer Atom Feeds in den erweiterten Einstellungen des Kontakts als "Entferntes Konto" markieren. +Webb du die Option `Nutzern erlauben das remote_self Flag zu setzen` aktivierst, können alle Nutzer Atom Feeds in den Kontakteinstellungen als "Entferntes Konto" markieren. Dadurch werden automatisch alle Beiträge dieser Feeds für diesen Nutzer gespiegelt und an die Kontakte bei Friendica verteilt. Dieses Feature kann z.B. dafür genutzt werden Blogbeiträge zu spiegeln. @@ -227,15 +227,9 @@ In diesem Abschnitt kann der Hintergrund-Prozess konfiguriert werden. Bevor ein neuer *Worker* Prozess gestartet wird, überprüft das System, dass die vorhandenen Resourchen ausrechend sind, Aus diesem Grund kann es sein, dass die maximale Zahl der Hintergrungprozesse nicht erreicht wird. -Sollte die PHP Funktion `proc_open` auf dem Server nicht verfügbar sein, kann die Verwendung durch Friendica hier unterbunden werden. - Die Aufgaben die im Hintergrund erledigt werden, haben Prioritäten zugeteilt. Um garantieren zu können, das wichtige Prozesse schnellst möglich abgearbeitet werden können, selbst wenn das System gerade stark belastet ist, sollte die *fastlane* aktiviert sein. -Wenn es auf deinem Server nicht möglich ist, einen cron Job zu starten, kannst du den *frontend* Worker einschalten. -Nachdem dies geschehen ist, kannst du `example.com/worker` (tausche example.com mit dem echten Domainnamen aus) aufrufen werden. -Dadurch werden dann die Aufgaben aktiviert, die der cron Job sonst aktivieren würde. - ### Umsiedeln ## Nutzer diff --git a/doc/img/editor_frio.png b/doc/img/editor_frio.png index d37081b833..d969f261bb 100644 Binary files a/doc/img/editor_frio.png and b/doc/img/editor_frio.png differ diff --git a/doc/img/editor_vier.png b/doc/img/editor_vier.png index 5278b0ff12..7ffac7fc71 100644 Binary files a/doc/img/editor_vier.png and b/doc/img/editor_vier.png differ diff --git a/doc/img/vier_icons.png b/doc/img/vier_icons.png index 4dc8ae03f9..b880e9562a 100644 Binary files a/doc/img/vier_icons.png and b/doc/img/vier_icons.png differ diff --git a/doc/smarty3-templates.md b/doc/smarty3-templates.md index b39fa117e7..f3f7c3e608 100644 --- a/doc/smarty3-templates.md +++ b/doc/smarty3-templates.md @@ -87,7 +87,7 @@ Field parameter: 1. Label for the input box, 2. Current value of the variable, 3. Help text for the input box, -4. if set to "required" modern browser will check that this input box is filled when submitting the form, +4. Should be set to the translation of "Required" to mark this field as required, 5. if set to "autofocus" modern browser will put the cursur into this box once the page is loaded, 6. if set, it will be used for the input type, default is `text` (possible types: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#%3Cinput%3E_types). @@ -122,7 +122,7 @@ Field parameter: 1. Label for the field, 2. Value for the field, e.g. the old password, 3. Help text for the input field, -4. if set to "required" modern browser will check that this field is filled out, +4. Should be set to the translation of "Required" to mark this field as required, 5. if set to "autofocus" modern browser will put the cursor automatically into this input field. ### field_radio.tpl @@ -176,5 +176,5 @@ Field parameter: 0. Name of the input field, 1. Label for the input box, 2. Current text for the box, -3. Help text for the input box. -4. if set to "required" modern browser will check that this input box is filled when submitting the form, +3. Help text for the input box, +4. Should be set to the translation of "Required" to mark this field as required. diff --git a/doc/themes.md b/doc/themes.md index 94dc47da11..b7bb2e2262 100644 --- a/doc/themes.md +++ b/doc/themes.md @@ -3,7 +3,7 @@ * [Home](help) To change the look of friendica you have to touch the themes. -The current default theme is [Vier](https://github.com/friendica/friendica/tree/master/view/theme/vier) but there are numerous others. +The current default theme is [Vier](https://github.com/friendica/friendica/tree/stable/view/theme/vier) but there are numerous others. Have a look at [friendica-themes.com](http://friendica-themes.com) for an overview of the existing themes. In case none of them suits your needs, there are several ways to change a theme. diff --git a/doc/tools.md b/doc/tools.md index 8746e9c150..1c3b8e119c 100644 --- a/doc/tools.md +++ b/doc/tools.md @@ -27,6 +27,7 @@ The console provides the following commands: * typo: Checks for parse errors in Friendica files * postupdate: Execute pending post update scripts (can last days) * storage: Manage storage backend +* relay: Manage ActivityPub relay servers Please consult *bin/console help* on the command line interface of your server for details about the commands. diff --git a/doc/translations.md b/doc/translations.md index a29b0d63bb..23ac4a62d7 100644 --- a/doc/translations.md +++ b/doc/translations.md @@ -8,7 +8,7 @@ Friendica translations The Friendica translation process is based on `gettext` PO files. Basic worflow: -1. `xgettext` is used to collect translation strings across the project in the master PO file located in `view/lang/C/messages.po`. +1. `xgettext` is used to collect translation strings across the project in the authoritative PO file located in `view/lang/C/messages.po`. 2. This file makes translations strings available at [the Transifex Friendica page](https://www.transifex.com/Friendica/friendica/dashboard/). 3. The translation itself is done at Transifex by volunteers. 4. The resulting PO files by languages are manually updated in `view/lang//messages.po`. diff --git a/friendica_test_data.sql b/friendica_test_data.sql index 3728da7714..779a9c6a35 100644 --- a/friendica_test_data.sql +++ b/friendica_test_data.sql @@ -330,7 +330,7 @@ CREATE TABLE `contact` ( `bd` date NOT NULL DEFAULT '0001-01-01', `notify_new_posts` tinyint(1) NOT NULL DEFAULT '0', `fetch_further_information` tinyint(3) unsigned NOT NULL DEFAULT '0', - `ffi_keyword_blacklist` text, + `ffi_keyword_denylist` text, PRIMARY KEY (`id`), KEY `uid_name` (`uid`,`name`(190)), KEY `self_uid` (`self`,`uid`), @@ -1492,7 +1492,7 @@ CREATE TABLE `photo` ( LOCK TABLES `photo` WRITE; /*!40000 ALTER TABLE `photo` DISABLE KEYS */; -INSERT INTO `photo` VALUES (1,1,0,'9305bf00a1f6a976','59483894715bf00a1f6a24a891914929','2018-11-17 12:31:27','2018-11-17 12:31:27','','','Profilbilder','person-300.jpg','image/jpeg',300,300,11007,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0\0 \n!\"1A#3BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/\3\\"u\OX\GXq\\ڭ/\+xߌ\[VJ$+\\c|>ob_j\m\0&\Q\<\E\-AZv\ Ɍԏ.y\\M+z\Ga\\J\&\\Q\'۫\|0\&G(\p\<\s0\\zy|r\Sh]\\n\H|j=[P\`vm\PYESGMM\\\$\h\0S9\\J\LJL}XIQC\$kgD̛\:ռJt\\WUu}A\L\JsC\V\m؂Pb@ |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1>CO$yQ2Ka(#q\:\\O\\9\WPKg\\\ν\C(@l\n1|l߷\0=\x\?\Sg\{\KI˵\\=k\a&\\\S(#x\kY&N\\97m\ZY\ڨuE(@8b\{\٭\\\sݜ\v~\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\&\Z\΍o\\\9A/0\p4\xF|i\\C\\uXm.S4Uq\n\ʟ\rM\%rV\[\\<\o]\ضͮVe\\[\\'\dك\\2<8QF5Ѓ\Z4X1\G_\ss\4OKWv_xڍ3\\~9N\\\B2r\0!t\\D\Eu\+@\0E\0cFF872\\Zہ\e%\\>\ԁq\^cog/\o\+\ɘ\e\dwJwOŷGi{oq5\Ϗc\"rL:\!\ne\B91f\'Ө/O\gdcB{fDM&\\=؆k*e\\~v1\c\\i\\\\)\#[H<|nږVV\i{\}IL\Kf 9\#\򙿍KrԷ\86P\\1\\XW5\\#ᬙ\0\ـDx\\m#>\7.\eDDDDDDDDDDD\\d \2\`&<) cf2琏vp\1\\\8k\9\1M~C\\\ҵ|բ\\9Ug{\\v05\U,SH x&B|\"\\X\\t\l{\\\\_v\ [\;d卮$RH\z\?a!\8 \dmfD\<+n&|\2\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]\\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\dJ>\rms1/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\Z\\J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8R\7$ș>W n\I#\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\Wp\'\\\\\\"\"\"(\c̞94\m\i\"J\_0\\{\rkA\"\{\\jwƯX*\Z[\m3Ɋ\\\\l\ʋc W^V,g_Z\0c}ƒd\b\x\m&)\<# E.OF\:\0\'܇#v\Bp\\{\\5nq\<8g\\DDDDE/Q/jE\׺\l\\}~rY[g0@\\G#֊3O\n0\\8\E\\ϞXӕ\;k/*|lÍm{]\\ZuT;\˹x\h2C26dFfwԝ\\dz\|\?\Sk0$\C\y&\KlbX|\0I]f<|U\t]f\6ڹ{V\\\5,ҩ~\ME\\\dW\C\ J,.\\{;=}Iқ\Z\\ry\HJI0YNJAL\\\p/\UZt+](}\|@ȣB\S\:f{\\\Vtٻ$<>ql\09O\\gAz<V\-i\g\\\2K[-g8\G)3P[\6~+{\U\˪Eę;,\ pans!\\.\f\8\\a\HkaW\7\P\\\BRX\eM,I\C^\q\+\R3X\+X\hdF)}V\M\q(8\\cDW5\ 7\c\c\w|㳬\0e\\ZiY밚9[\\ckZs.u\G9\x\\'JaΦ=\v{Mʙd}v\\أ2L\\V.hjHh\|q#\}2 ^\rޞ\\3j:\\\5KG\0S\2&\.\'\hN \mSfǩ(\5\ljb1FƍL x\ƌ \0F֌A\шCkX\5cp\c \x\\|t{@\\\\xF\x;c\#\\(\\Z9116̋\*}TUc#8*i9w\"\6\1\\b\Kߴk\\\׫\\{:k\\2bs\Vg8i̊SLr\0rtho\\OcN~n\\ԇv\\i\Wg>E\Z4cQ\r`\#Ub\G:4\9t\"\"\"\1E \0\07\3\ \"n^B\X1s\\rcq;8\3Qw\v\\/;ku6\\\c\gZ\~2noŠLZ64Y)g\&.#\\\-c\ߩcJ\皭I\ \Z\\Ut\$\o̸x\(,cB\+\Ǫj֋\i\u%n\u(\"m]t1\q\Ŏ,acw;?\"y{\"\"\"\"\"\"\"\"\"\"\"(J瞵\\"Y\\Ys[\\zs\X\7*\\QHi OiG=n^7{\ɾ8{}\}\dԬG޵܋\\\7\\"w\?̯\\έ fSC6\]Q*s+.َ\'\cKa\)k5\%;-c*.!UsZW\=]XVQ 6\\(\wՌDDZ\Vg\\\gnըu\H.3\\Nk\Zm\\Z\8#_\\7\\D\^lٖS%\\˓>\|͝:i\*dْʗ.Q\ȓ \ys=\1^\{\02\]ӊ>\S2\w\Ճ&ma*qAZKȦMZ\0`Ћ\L\cߚ\"\"\"\"\"\"\"\"\"\"\"\"\0C(&$\"\Z@\aB`BaaF9\ \ܱ\\Z\g\UnpwM{ˣ\'Z\Fr\\T\cck;\\r\L{\\\\n\\b\b\0i&ۘml$+k~N\s\N\r\Pϴ\\ \+Z)3!꼉\\M=t\\'\P͎aͭc\IV\:\\\DE?S\r5\\\&ns=\\t㲽a~9ǴsUF\ϻp\\.\p^\U\G)qv.@\h! fW[\ubx\\"H aAn~7\ñ|\'UWQOQ5Օ\n  X\\b ah5kq\\=\0]9\{\"\"\"\"\"\"\"\"\"\",h\q\*\\8q\ku]ebr\o\$\nw6s%\@\H\"0\Z>@\g\m\n2zη.vl䭐h[XNՁf\e\Y3v.\X\Z얺+s2T\]t\\\K}\Z\c]\nª%v\ݎ=@\nh4䷱\\5X\G9{`t\ I\\'\Ɗs\ܧ\\\\\%r\\jH3ܭFڷ*,\\J\7Q*\<F]\6@\'dcJ(C\ZOXL\1\3\0\>i\g/\\}\h}dF\e}xN\\\K\R>\0r\'X\$bO9Y\\N<`v\\d\[\WH\=dș\\Xg\\o^4!m\7 Gދv\}\\\\y\Z(5gضK9q)`̰85ǛcȈ\r|p\\\tO\\ ,ڲ{\oi\6fXL>@l\\;G79v(\\h.uK\[cOkWT\3̫j\J\L\]I:1\s҉\s{\\\$7\o~E[X!84{m\!aJ\\H\ B!|Xǻ8no\\h{\0Ñ\P֭H\%4)79ÆPc\\v3\]*\[/(\q\\r\nK\UB $Kca-\#Ǝ\"\sD&q jx\{xΉ\Zjó\pM8;#4\jb\b?G\\Ŵ :\y+\lZ:\k;Ȳ$f;lm\\ Ξioc\\&T\ X[8qy)lA!bTh\GW\\"ֹ_\O\~\p\I\\\\Z\-\im\\c1 UTVP&J>Zw½y\\\pU \\yR\\\8\2$g\\J\$\˟l{{\Z\"\"\"\"*}Q-l?݋~\ޱ*[{o}o\\.5f\\2{B\\8;m \ϗ>Ćي\Ɓ_y\W\#\\.\\\\JMP\\hȸy\\E줟8m\\R\5$\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃ\q\n;\\\M^(ѽ\$0eK^m<\󟕎U֨\n\:\A\ZkJ\Z٤}~\ȟCCk\\n5$\մ /O_Kŗ\[\AqO \f\8K/\%\\x昙\~\X\\0\\Y\O1\\r.\W\\\\\0\F\\0ya?%5|\a~q\gITV:5D\0+\F]\"\"\"\"\"ž\t{\׾@\o?j\m},7kuqe)Z)Xp\@ [(\H$2b@\ Um\\0˝S`H\x[m\$\\Zxq\\\$\xo\\@YF3+hq\Ӹy_GּDI\]\=6n=٢F\y8\z.g2W\8qWh֏X\1lʶ\\Z\%}9\_\0w5.\zݴ}s[ko3_\\\n\N\\Ÿ\Z,ʋ&9\QFT\"\"\"\"\"\"\"\".(\Z0\"D`@\0!c\!\!汌k\a\UR\2=\\[gf\lvZ\cF!C\.J\y`_:\:М\$&in\"W\;y\ɧfu>ptC|\nӐ9\n\)ka\[ih\\4-(5p\\\-~ΗWT {ʛz/Ѿ\ם/}ա\\z\l&^ߺ$q\[\\8\eݵM2Iֲ\"[Q3 \\\\\DDDDDDD^a\+Ľ\\Ú\R\?\\\U_j{4K\ZfF %\r\d \Ex\"a(,RJ \0O+\\\\EA\ \}_f\\rv\;TH\y/3S\j\\,ӨgÃ!ulK[\X^|\\0nr\ =jS.m\\H\"Xj܉\mRKk,66E,c`3\d 1d?\\Geb\\6#<\(\j \c~\/Q+ \I\\M20\\Sw_O`\8\?$rg\\\o1\"\!u\s-\\Ytⰻi\Z\\:7\Z\\0̬,\ZQqpo^wY\~4\r{F䚢\r\\Dœ#J\.,X3eM|Ղ%\\;3ƃ1aR#0 6& \ADn2 ˘AØ\-ss79\qȈ)@w絞W9w\\l\B\m_\q[IQ\'9.˵lQ\rE\ ɝ\'\Z\ (A\Ozfv)d\\^f\J\\k\6>h\\"\\t\\Q6f\iZf q\\~\}r\0vOG\5{&^q\r.:)cD\uv\*mk\\l{\J\Z|x\\ݩظ\O\,\mGH\Z.N\\Ipc\W\` G\*6ڑ\2\\ BkWM#ҵ\b\\~;;Y\n)2\0\;4>\u:}4\lb\o\㽭w>\ha\\\@خeN\m;yKnwF\ﱂK:9;1\q8O\5\\2ܷ\Ƿ7\\#r\o[<\r\mu$bs\\VJs\Z7/~qͶ~\kKQ\\%i&\\Qd7\ā܇\o\EJֽ\i\v\\\\\\sK\\\k\4\Ȝ\,D~G)\\vʊ610\\;`/\-w\\\ٽ\C\(\I\-jh\ZvQ\ĉQ\0(UA9lp\07\e\#\\s\\QS\/\\7L:\\\ǒd\f\ c\\X0,5#2Zn&7\;o\9\\V}\\r\\"ޠ\+\W\\K \Aߜ\\\^\0\"\"\"\"\O=2^\S:\lSM\'\\\c\\Dz߾Xǃ9Ǿ}x\\icoqPn\N쎭\'\\p% \xV ԭeNr\g$v.?\0m\5\@!k\y\\\\i\\\8\0# HLyBY\q2\ai\\0ߟv\7\~Y@\уx$;yF\/\'c\!\h\\\L\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\V\0  RDDDD_\Al(\ #ZonZ\Z=\\\\c-ssg\s\L]z\2~q9(# \kc\1ٍ[\lc9SDDDDDDDDDDDDQ`\\e\\]ό\r\p\#|\\0XjB\1fWVx\\\Zܘ֔ 8n2Be2{\\3\X\m\"\"\"\"\"GmC\\\\Zia<\\w.X坲\\!=\c(\\|\01\0\l\"\"\"\"\"\"\"\"\"\"\"\"~\\0\gi\V2\0/<9\\\\\\\E*V[\?\\r#\\ĉ\K\Ӿ\0\hT\v3DDDDZ\\qO.\*\s\6RZCl (\zq`\n>\n\^\dNsƈ\c\\c?PfFh\\\ [(p%\ \\Gtı$30\3?ɇ\_\=8\<,?ڄA\,\r^Q0\?710`xȱ\k\\\*|*fuՕMdx3)w$?g\c1&\Zs\ۂ\\\_\}\DDDDDDDDDDDE\7=F|\\u\re55\\\Cѕ\|ax\\\\ 3\\=ѡQ\kՍ{+hjk\Ga\d\Za{Z\9\\0; n\g8kqc\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/\',4,1,'','','',''),(2,1,0,'9305bf00a1f6a976','59483894715bf00a1f6a24a891914929','2018-11-17 12:31:27','2018-11-17 12:31:27','','','Profilbilder','person-300.jpg','image/jpeg',80,80,2354,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0+\0\0\0\0\0\0\0\0 \n!#\"AB\\0\0\0?\0\\8\8\8㚑\1V\\7M\PCm;\n\f˲\A\"&\n(\\\z\r+\\*N\1dVq+:}\wN\\z/۝ \\I̮֩\uJ7\ZIA\y\d2m`\^N|HKFow\nRɭc5mö0\~Sr\\\t<Ŏ8\Mi\-2\Cz*\}\\`C\_\qț\\\'0\g(督v\\pQaYV\[=AXFR\\Y\Zp|6RPoSz\\B9d\\\V؛jl.e6Nڔ\"S\\pσ\U{\r<є\\*F@VԘ:WV`b6]hX\\F\G8a\n\\\hNcsѿ7?\Z*GLn.{^\m}kY(IF\s(\#\#Y+{|2&%hZ\OL~\0\:\vڗ-\\E\;=!\0\DZ\vW\^Q\\"\5y\0\Z\G\لJ ]\٥\CT\\ZiR.!-:eZ_>(q԰J\dHqya\0\\T9\\\/ZŹ\潩DO\\"f4D\rR\wDPm5-\\\]P>A\r+\sњK^_\z\A\t\n3V\)\ٚ@.gE0{H\0.`FZP\qi7k\\\=4ąםL\c\\+c\\\\ΩN h\HH\\&\O\r^m\\K1R\\\0z\?W\'m\\r`\ƵD\*\Z.4.\D\)&\Z\"\1yȐ_\2\eǧXv:r\^*d!!ܭ;@\3\=?\'kR\ \Fq\u*\\"ˆ0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬OcaYr\]C\(%g\F6X͟+\\\a0Qԫ$]#cJ4 \HZ&f:\dפ3b\\rs`*\%IZp\nJ%I\2c8\q|\8\?g\qϞ8\~S&S\II(\\rnws\ZQ3q\$L\%r\ZS4\j\0*8}\e92(w\f\r\\\GAW\#\0xؘxxZ2*.86\\d@f[a\\}sm}Bِn\6=\"+o^Vpo9\k;:\84\\*n8j\`d|\һi\.\\v l\rsn\2#җ!E\*2P\gS\!Z^뒠\\*\2p\\v#f\~\\֫\﷤B/j\U)^ĉ,y-`\0$|kkם:4\0\Mj8Pߨ)\\(Y\\0-yVQې&E\0@>,\>\\8\\c\K?\m a\nm3\;sR$\%Զ\*ͭL԰\\\M\q\ua= \^LrE\} ĚXRra]ޱMd~4\D\e-8\\eᲬ6[§q\t\\\TB:)M\aG`t\+w\0ٍ\XCoKˎp$QK\ vY 3\%\K㈲\\\p㌌ᅸ;KVP\\!M*y̫8\u-;99\r\rB}\^xt;^oVZR9\g\',5,1,'','','',''),(3,1,0,'9305bf00a1f6a976','59483894715bf00a1f6a24a891914929','2018-11-17 12:31:27','2018-11-17 12:31:27','','','Profilbilder','person-300.jpg','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\(: +jo6;+\Ya>\U\aZ\\Hbqc1oz \\iق%a\-]\o)s\^\x[-4й\\\;\jT[P\lQ\\--\\v\$\ g3N]c\F|J^f<:Ī\u mYȬƼ1[\Z,En֨ESpRƆ\AKp\"K5\c\\k\{\=z\\eV<\}O^ԋʜ9E\\!\rf*9ʱ\'94nT\+NS?Ƈ!j5X\&\<1)d\-\\\u)_\]lQ91Y\¸K!\\`5R_\\\y\P\Ұ \h{G\\1ԏ P\\\ۦ%<\U0]y?gp)vI Ϭ\\\'\\uY\\"I۔…a\\aYXj[G\ؤ\r\0\B\<\\uoy\j:齟!5\6O\\\D|@V\<|\\r\\Rľ\\vM/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\Ή6\u\xL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@lD<>U\\ \03EW\0\\W%oſm\]\\;\\͋l\\\f]ռ|\&A=1\r\#Å`]1\\E㛑\ؙ|~N\*Yc++*a j\Z?ЋK}$L\Z\\Q\β\0P4`4h#\\0\G\0Y!\шBZk\\nas\",=\|z\\#s/kyqM R]\S\-Hkiu\66\rf\\fGx$\7z\[\vF ^\l{\\\\'$àH\y \^D#\bz\:XdvF4/dD۸\l3\͈f]\\c\>6~\\2ϱ8ke\ɇ\\6\kenqƗˠn\g/1كs\F|\ \\d\ƪ\\ IK\[@\QK\Z\m_)X<}M!>.,bb:=\\\䯻|G\jO[Z;d卮$W\\z\?a!\8 \dmfL\<++n&|\6\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]S\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\J>\rms>)/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\ZX\܍J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8 9R\7\ș>Wn\I#\\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\W\'\\\\\\"\"\"(\c̞94\m\i!ʾ\_0\\{\rkA\"\y\\jwƯX*\Z[\l3Ɋ\\\Il\ʋc W^\Y;\\8j\!`IuMP\8\hh1\y,G-n\Vͤmr(7`\vjYyeS\:9 ȯ&!Y ]\vz7\ZđyL>^\~M\\b\aWa`o V}z`6%\@_֫\`n\V>Q7;\eF\c\t\z\vHn%By)Bh`ϯ~\0: \\)\M\gk?J\\rŒZ\\kSv\!}\\]\`ٶ.6\m<8w=<\\\\w\ap{xT\N[F¦\"E,\d\\wV}=%\|w;4\us]U#\ua#XBΑ;#\\1uhB`;xMSZ\uM;N\}n5EDaî<ᕷuhO\\Myy῵|Ʌ[\\\||;9\-\X_q\\QG6s\\0˱*|נq=v\\|i\\E$ꝋ4\+HB)Y\m|أ0^3#H(Ae\v0\_ \U\|}\\B\ruefƒ`4zZ&c\rfZ\61\\0,9vsDDDDDDDDDDDX\\>\u:U\0\p\$\\7^\[\\\H\>l\K+\Zؑ*D`5|aс\Ϭ۠\e(o\\\\\\[ ж;ͬ\cIJf\D]\y<5\-tV\9 d;n\\\\=B5ƺTKZ\O߻z^\i\oc[[\j\\\ݛ`bO)?4S6\8>^g~+v~P\A\\l5&տ\Qd\>\ZViŔyVq\\\"4b\<#\ZQB\|\\e\0iY\HOk9z\\\rͅGǴ\\"6S+\\\ṷv2_(5:\#|\\\x>\q\SV<\%RޢF1\;&D\\\\"\?\{-\\DhB\\nu\Z;\\gٷ\40Pjϱmvr\RsO&=n!WGai(qk)6Ǒ\Z\\n+Y\\)iekp\\SmA̰|\2)#5\\vnr\Qհ\\\\\]&`֮,/f3WsO4\֕ҙ \nlc\;0\a\]H:o)\fU\\r7d+FC8ph:\9oB”`\"\B?8vp\\\\\/-\"\"֡[B\'a\\hj5MR\'IR\!2%F\x\uq-k\/\请_D \\v\n.&3\ZH0\UCedz\\g\\+מ \~]p\iQ\0ǚ.^(\0c#*F|\\IO9\\\l\q숈Gx\\0v-ǚĨm\\S` ՚\3L\\ Ps \\bm>df+G\Z|Q\/\_L{h\#u)6yCp_\"\\i\\\_\Z^|\~e\4\eK\1䝵\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃq\n;\\\M(\$\ \nVT\\\9X\[j\\:\\lJJ8\5\O&.JǨ\\ȍo\'Ģ#0\o\A\nK:v\Ym7[\n\C@\ͪ,\57\ȉY\\5~(\?\\\6]\([\r\\\\\\\\+h\r>K`NJܖDlF5\\C\aշ]7x\=\'m\w\n\9}}UAcߌ\tr\\\]$\s8\}:\"\"\"\"\"\";\~z3\\\݅\\\0\V\qJ\lڎ>\*\ð\\d\rM)Hz}\\\\T%EEe\r\re\\݄*jjRlm\lueet1\Zd E dʒQ\0y^\\\?O*],\c\525\pkyڢE$s\|ɚW6Id\m&C> bZ޲\\Us\HwȈÖO\Rsoh3H\c8\r\|gʇ\B޶P\Kۤf\\{ͽl7R\mԑ\+3\XI+\\kܽ\\6\ciu/\G l#n\DX}\\7\m(jA L\j\\28\0Dy$E\r\\4Qx\\;\\\Yrwl\6O׭\NMH\?Y\\Zo\Z\q,$Fq+\s\EPr_&tS\\g\nf\T;\0\UΨ\u E-s\r[d\\E&\G _.\4ќ\Wst?\\\]=J\\g1qV\\`\\"`k\i\SG: R6>2ƌFr\\"\"\"\"\\.C\/\ȕn\:\\6Uro鿍93Z\r9\? ~[g?\\/|9\S$>7\"r\m\*( \\\H\\ vXL\{{f L\\'SеqPi\\FS$aD\0TA \\\V\/\/)\s\ڈQO)a\{nqT\\0Cfd \\\u<\[8 a\\ףw1x\x1\7Gb{\>[\X\\"__ &\\vZtQ|\\r\\kbm:\ϧ>\=lMq\.`ݙM \"\\\\/\X\4\0\o-\\ŝ˘eL\u}Of\\\#~\3u$i\+X瀱\a\\6p>4֨;9ҾR\06H\P׶[Xem9\\iI\Zc#@kI[.n6I\iqҾ\-\\0\\RoV>7\<\=\\\\kH{v\wdui?ۄ(Nkǂk,bp\81#Yw\So<\O ț_+ϒ\\r\dp\\W{Ng7\0ZBe\\9!\\\H\\k\\\ \~{PW2k?~p\+\\\\|>M$\\'\\0Pc#˳>]n\rO\4 ǗvGwc$bh\`j\]zF1\0\"1\˜\\\1keˆ\w\ns e\l\-\s}XčJ\9\}0\0|{c\\\\URO4mevX6H\a\|q5R \Z\\\\\rvKs\9ضBc\\[_`Wj\W\}/돷\\;\>\0\\\H~!iФ` \s\4\j_\85z\D\p<\\]\\\"\~5kDDDDEu\Z9\VU5\\ئ6\\H~ϲ\c0L4\\7\'7\\\"\"\"\"\"\"\"\"\"\"\".\4\\sl)b\ #\ {\V?`Y|\\q\hi\t\c^\\\Z\j\y`\k\ֱ~\0\\\[;\~\Ȉ\',4,0,'','','',''),(5,0,2,'9305bf00acda37f7','52226322615bf00acd9acbf086558812','2018-11-17 12:50:03','2018-11-17 12:50:03','','','Contact Photos','1.jpg','image/jpeg',80,80,2355,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0+\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\9ZM\*x\\.d% \r1!hu\GS6J\\9jdžBÏ-_e9+\r 0p\m<\\Ƌ\?Jt>\l\\ϭu1y[U:^R\0rT Ƕ>,G6\\9\ \\\oCN0!..-ӭ/\nC)!i\V\*\q㕈w{\C\Jg]U%,}$\\p\\I׉\\5Wț$DEWI.f\"\\A\պ\0t\KDu\Y\\Z%ލjR\ \n\ 24\\J 0f0L<\#N\Ҏv@F 37\\WKLE҂C2IH\5)]i\8>*ƌޖ~\c\B\\m]cȢ~ڔ\rvb?N\Z \\\\S 6a\0\0p|;8\s:S7`3x\52\,\y\)\\r \\ɻ$\DW\tiR\ fԷ=\\0B\\mg/Ň\{\Z\\P^\Z\|\!v\Z&I\^\n\X<\0f\\,,zq+u\c.\\A2\\;\\ZjI\r#;Sؓz\,`ͤgR2/\QeN-鵍u\U [R\ڽb:XبFm C2;- B1xo:\V\\0Ww\W?[\ڪ\N\\\XbA:e]X3㭶1\4\\՞i\\\V[\>$\.kI\ LɻyܪaP\\8R\\0t %WY/O\ZO\\0\Do- \a\\ ^-\,R\\\u\!Y \tu\\U5t\\\B[Q\\\\'SY\"P\\n\>VQ\ | #x˥\4\qm\Z\n\5\#g-ӞW]ۦZjybN-\r53c-{{o\+\D\\\P*#T-L\\ZCM\,fjY@\g\\LnF\#N\W\\-8SHY\\t\I\N,\ϳ\\S\^\+w~Ak>\m\l}\h\ (MN\4,^ J1(\2\b>\\\{(U՚ۭ)jdvvӢ\\ڞT\Rw\\ƌת1\\\3Z^F\¶\r\$㦣c\a\R&X$\\\\h$ca8\"\Z\$B\u!q6!X\gO\rv\\]ZA/3{^PQ[ˎ_\\4%5\\\0%_rLe\rC\^2\ 8c 0\\\\#\\,\16\M(m \\' N1c{xyՠ/5[-&\\Z\_\!dGlx\\"\"\䭒><%R\eն\*\+\\c|>ob_j\m\0&\Q\<\E\-AZv\ Ɍԏ.y\\M+z\Ga\\J\&\\Q\'۫\|0\&G(\p\<\s0\\zy|r\Sh]\\n\H|j=[P\`vm\PYESGMM\\\$\h\0S9\\J\LJL}XIQC\$kgD̛\:ռJt\\WUu}A\L\JsC\V\m؂Pb@ |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1>CO$yQ2Ka(#q\:\\O\\9\WPKg\\\ν\C(@l\n1|l߷\0=\x\?\Sg\{\KI˵\\=k\a&\\\S(#x\kY&N\\97m\ZY\ڨuE(@8b\{\٭\\\sݜ\v~\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\&\Z\΍o\\\9A/0\p4\xF|i\\C\\uXm.S4Uq\n\ʟ\rM\%rV\[\\<\o]\ضͮVe\\[\\'\dك\\2<8QF5Ѓ\Z4X1\G_\ss\4OKWv_xڍ3\\~9N\\\B2r\0!t\\D\Eu\+@\0E\0cFF872\\Zہ\e%\\>\ԁq\^cog/\o\+\ɘ\e\dwJwOŷGi{oq5\Ϗc\"rL:\!\ne\B91f\'Ө/O\gdcB{fDM&\\=؆k*e\\~v1\c\\i\\\\)\#[H<|nږVV\i{\}IL\Kf 9\#\򙿍KrԷ\86P\\1\\XW5\\#ᬙ\0\ـDx\\m#>\7.\eDDDDDDDDDDD\\d \2\`&<) cf2琏vp\1\\\8k\9\1M~C\\\ҵ|բ\\9Ug{\\v05\U,SH x&B|\"\\X\\t\l{\\\\_v\ [\;d卮$RH\z\?a!\8 \dmfD\<+n&|\2\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]\\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\dJ>\rms1/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\Z\\J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8R\7$ș>W n\I#\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\Wp\'\\\\\\"\"\"(\c̞94\m\i\"J\_0\\{\rkA\"\{\\jwƯX*\Z[\m3Ɋ\\\\l\ʋc W^V,g_Z\0c}ƒd\b\x\m&)\<# E.OF\:\0\'܇#v\Bp\\{\\5nq\<8g\\DDDDE/Q/jE\׺\l\\}~rY[g0@\\G#֊3O\n0\\8\E\\ϞXӕ\;k/*|lÍm{]\\ZuT;\˹x\h2C26dFfwԝ\\dz\|\?\Sk0$\C\y&\KlbX|\0I]f<|U\t]f\6ڹ{V\\\5,ҩ~\ME\\\dW\C\ J,.\\{;=}Iқ\Z\\ry\HJI0YNJAL\\\p/\UZt+](}\|@ȣB\S\:f{\\\Vtٻ$<>ql\09O\\gAz<V\-i\g\\\2K[-g8\G)3P[\6~+{\U\˪Eę;,\ pans!\\.\f\8\\a\HkaW\7\P\\\BRX\eM,I\C^\q\+\R3X\+X\hdF)}V\M\q(8\\cDW5\ 7\c\c\w|㳬\0e\\ZiY밚9[\\ckZs.u\G9\x\\'JaΦ=\v{Mʙd}v\\أ2L\\V.hjHh\|q#\}2 ^\rޞ\\3j:\\\5KG\0S\2&\.\'\hN \mSfǩ(\5\ljb1FƍL x\ƌ \0F֌A\шCkX\5cp\c \x\\|t{@\\\\xF\x;c\#\\(\\Z9116̋\*}TUc#8*i9w\"\6\1\\b\Kߴk\\\׫\\{:k\\2bs\Vg8i̊SLr\0rtho\\OcN~n\\ԇv\\i\Wg>E\Z4cQ\r`\#Ub\G:4\9t\"\"\"\1E \0\07\3\ \"n^B\X1s\\rcq;8\3Qw\v\\/;ku6\\\c\gZ\~2noŠLZ64Y)g\&.#\\\-c\ߩcJ\皭I\ \Z\\Ut\$\o̸x\(,cB\+\Ǫj֋\i\u%n\u(\"m]t1\q\Ŏ,acw;?\"y{\"\"\"\"\"\"\"\"\"\"\"(J瞵\\"Y\\Ys[\\zs\X\7*\\QHi OiG=n^7{\ɾ8{}\}\dԬG޵܋\\\7\\"w\?̯\\έ fSC6\]Q*s+.َ\'\cKa\)k5\%;-c*.!UsZW\=]XVQ 6\\(\wՌDDZ\Vg\\\gnըu\H.3\\Nk\Zm\\Z\8#_\\7\\D\^lٖS%\\˓>\|͝:i\*dْʗ.Q\ȓ \ys=\1^\{\02\]ӊ>\S2\w\Ճ&ma*qAZKȦMZ\0`Ћ\L\cߚ\"\"\"\"\"\"\"\"\"\"\"\"\0C(&$\"\Z@\aB`BaaF9\ \ܱ\\Z\g\UnpwM{ˣ\'Z\Fr\\T\cck;\\r\L{\\\\n\\b\b\0i&ۘml$+k~N\s\N\r\Pϴ\\ \+Z)3!꼉\\M=t\\'\P͎aͭc\IV\:\\\DE?S\r5\\\&ns=\\t㲽a~9ǴsUF\ϻp\\.\p^\U\G)qv.@\h! fW[\ubx\\"H aAn~7\ñ|\'UWQOQ5Օ\n  X\\b ah5kq\\=\0]9\{\"\"\"\"\"\"\"\"\"\",h\q\*\\8q\ku]ebr\o\$\nw6s%\@\H\"0\Z>@\g\m\n2zη.vl䭐h[XNՁf\e\Y3v.\X\Z얺+s2T\]t\\\K}\Z\c]\nª%v\ݎ=@\nh4䷱\\5X\G9{`t\ I\\'\Ɗs\ܧ\\\\\%r\\jH3ܭFڷ*,\\J\7Q*\<F]\6@\'dcJ(C\ZOXL\1\3\0\>i\g/\\}\h}dF\e}xN\\\K\R>\0r\'X\$bO9Y\\N<`v\\d\[\WH\=dș\\Xg\\o^4!m\7 Gދv\}\\\\y\Z(5gضK9q)`̰85ǛcȈ\r|p\\\tO\\ ,ڲ{\oi\6fXL>@l\\;G79v(\\h.uK\[cOkWT\3̫j\J\L\]I:1\s҉\s{\\\$7\o~E[X!84{m\!aJ\\H\ B!|Xǻ8no\\h{\0Ñ\P֭H\%4)79ÆPc\\v3\]*\[/(\q\\r\nK\UB $Kca-\#Ǝ\"\sD&q jx\{xΉ\Zjó\pM8;#4\jb\b?G\\Ŵ :\y+\lZ:\k;Ȳ$f;lm\\ Ξioc\\&T\ X[8qy)lA!bTh\GW\\"ֹ_\O\~\p\I\\\\Z\-\im\\c1 UTVP&J>Zw½y\\\pU \\yR\\\8\2$g\\J\$\˟l{{\Z\"\"\"\"*}Q-l?݋~\ޱ*[{o}o\\.5f\\2{B\\8;m \ϗ>Ćي\Ɓ_y\W\#\\.\\\\JMP\\hȸy\\E줟8m\\R\5$\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃ\q\n;\\\M^(ѽ\$0eK^m<\󟕎U֨\n\:\A\ZkJ\Z٤}~\ȟCCk\\n5$\մ /O_Kŗ\[\AqO \f\8K/\%\\x昙\~\X\\0\\Y\O1\\r.\W\\\\\0\F\\0ya?%5|\a~q\gITV:5D\0+\F]\"\"\"\"\"ž\t{\׾@\o?j\m},7kuqe)Z)Xp\@ [(\H$2b@\ Um\\0˝S`H\x[m\$\\Zxq\\\$\xo\\@YF3+hq\Ӹy_GּDI\]\=6n=٢F\y8\z.g2W\8qWh֏X\1lʶ\\Z\%}9\_\0w5.\zݴ}s[ko3_\\\n\N\\Ÿ\Z,ʋ&9\QFT\"\"\"\"\"\"\"\".(\Z0\"D`@\0!c\!\!汌k\a\UR\2=\\[gf\lvZ\cF!C\.J\y`_:\:М\$&in\"W\;y\ɧfu>ptC|\nӐ9\n\)ka\[ih\\4-(5p\\\-~ΗWT {ʛz/Ѿ\ם/}ա\\z\l&^ߺ$q\[\\8\eݵM2Iֲ\"[Q3 \\\\\DDDDDDD^a\+Ľ\\Ú\R\?\\\U_j{4K\ZfF %\r\d \Ex\"a(,RJ \0O+\\\\EA\ \}_f\\rv\;TH\y/3S\j\\,ӨgÃ!ulK[\X^|\\0nr\ =jS.m\\H\"Xj܉\mRKk,66E,c`3\d 1d?\\Geb\\6#<\(\j \c~\/Q+ \I\\M20\\Sw_O`\8\?$rg\\\o1\"\!u\s-\\Ytⰻi\Z\\:7\Z\\0̬,\ZQqpo^wY\~4\r{F䚢\r\\Dœ#J\.,X3eM|Ղ%\\;3ƃ1aR#0 6& \ADn2 ˘AØ\-ss79\qȈ)@w絞W9w\\l\B\m_\q[IQ\'9.˵lQ\rE\ ɝ\'\Z\ (A\Ozfv)d\\^f\J\\k\6>h\\"\\t\\Q6f\iZf q\\~\}r\0vOG\5{&^q\r.:)cD\uv\*mk\\l{\J\Z|x\\ݩظ\O\,\mGH\Z.N\\Ipc\W\` G\*6ڑ\2\\ BkWM#ҵ\b\\~;;Y\n)2\0\;4>\u:}4\lb\o\㽭w>\ha\\\@خeN\m;yKnwF\ﱂK:9;1\q8O\5\\2ܷ\Ƿ7\\#r\o[<\r\mu$bs\\VJs\Z7/~qͶ~\kKQ\\%i&\\Qd7\ā܇\o\EJֽ\i\v\\\\\\sK\\\k\4\Ȝ\,D~G)\\vʊ610\\;`/\-w\\\ٽ\C\(\I\-jh\ZvQ\ĉQ\0(UA9lp\07\e\#\\s\\QS\/\\7L:\\\ǒd\f\ c\\X0,5#2Zn&7\;o\9\\V}\\r\\"ޠ\+\W\\K \Aߜ\\\^\0\"\"\"\"\O=2^\S:\lSM\'\\\c\\Dz߾Xǃ9Ǿ}x\\icoqPn\N쎭\'\\p% \xV ԭeNr\g$v.?\0m\5\@!k\y\\\\i\\\8\0# HLyBY\q2\ai\\0ߟv\7\~Y@\уx$;yF\/\'c\!\h\\\L\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\V\0  RDDDD_\Al(\ #ZonZ\Z=\\\\c-ssg\s\L]z\2~q9(# \kc\1ٍ[\lc9SDDDDDDDDDDDDQ`\\e\\]ό\r\p\#|\\0XjB\1fWVx\\\Zܘ֔ 8n2Be2{\\3\X\m\"\"\"\"\"GmC\\\\Zia<\\w.X坲\\!=\c(\\|\01\0\l\"\"\"\"\"\"\"\"\"\"\"\"~\\0\gi\V2\0/<9\\\\\\\E*V[\?\\r#\\ĉ\K\Ӿ\0\hT\v3DDDDZ\\qO.\*\s\6RZCl (\zq`\n>\n\^\dNsƈ\c\\c?PfFh\\\ [(p%\ \\Gtı$30\3?ɇ\_\=8\<,?ڄA\,\r^Q0\?710`xȱ\k\\\*|*fuՕMdx3)w$?g\c1&\Zs\ۂ\\\_\}\DDDDDDDDDDDE\7=F|\\u\re55\\\Cѕ\|ax\\\\ 3\\=ѡQ\kՍ{+hjk\Ga\d\Za{Z\9\\0; n\g8kqc\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/\',4,1,'','','',''),(8,2,0,'9305bf00c122b3bd','90913473615bf00c122ac78338492980','2018-11-17 12:39:46','2018-11-17 12:39:46','','','Profile Photos','person-300.jpg','image/jpeg',80,80,2354,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0+\0\0\0\0\0\0\0\0 \n!#\"AB\\0\0\0?\0\\8\8\8㚑\1V\\7M\PCm;\n\f˲\A\"&\n(\\\z\r+\\*N\1dVq+:}\wN\\z/۝ \\I̮֩\uJ7\ZIA\y\d2m`\^N|HKFow\nRɭc5mö0\~Sr\\\t<Ŏ8\Mi\-2\Cz*\}\\`C\_\qț\\\'0\g(督v\\pQaYV\[=AXFR\\Y\Zp|6RPoSz\\B9d\\\V؛jl.e6Nڔ\"S\\pσ\U{\r<є\\*F@VԘ:WV`b6]hX\\F\G8a\n\\\hNcsѿ7?\Z*GLn.{^\m}kY(IF\s(\#\#Y+{|2&%hZ\OL~\0\:\vڗ-\\E\;=!\0\DZ\vW\^Q\\"\5y\0\Z\G\لJ ]\٥\CT\\ZiR.!-:eZ_>(q԰J\dHqya\0\\T9\\\/ZŹ\潩DO\\"f4D\rR\wDPm5-\\\]P>A\r+\sњK^_\z\A\t\n3V\)\ٚ@.gE0{H\0.`FZP\qi7k\\\=4ąםL\c\\+c\\\\ΩN h\HH\\&\O\r^m\\K1R\\\0z\?W\'m\\r`\ƵD\*\Z.4.\D\)&\Z\"\1yȐ_\2\eǧXv:r\^*d!!ܭ;@\3\=?\'kR\ \Fq\u*\\"ˆ0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬OcaYr\]C\(%g\F6X͟+\\\a0Qԫ$]#cJ4 \HZ&f:\dפ3b\\rs`*\%IZp\nJ%I\2c8\q|\8\?g\qϞ8\~S&S\II(\\rnws\ZQ3q\$L\%r\ZS4\j\0*8}\e92(w\f\r\\\GAW\#\0xؘxxZ2*.86\\d@f[a\\}sm}Bِn\6=\"+o^Vpo9\k;:\84\\*n8j\`d|\һi\.\\v l\rsn\2#җ!E\*2P\gS\!Z^뒠\\*\2p\\v#f\~\\֫\﷤B/j\U)^ĉ,y-`\0$|kkם:4\0\Mj8Pߨ)\\(Y\\0-yVQې&E\0@>,\>\\8\\c\K?\m a\nm3\;sR$\%Զ\*ͭL԰\\\M\q\ua= \^LrE\} ĚXRra]ޱMd~4\D\e-8\\eᲬ6[§q\t\\\TB:)M\aG`t\+w\0ٍ\XCoKˎp$QK\ vY 3\%\K㈲\\\p㌌ᅸ;KVP\\!M*y̫8\u-;99\r\rB}\^xt;^oVZR9\g\',5,1,'','','',''),(9,2,0,'9305bf00c122b3bd','90913473615bf00c122ac78338492980','2018-11-17 12:39:46','2018-11-17 12:39:46','','','Profile Photos','person-300.jpg','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\(: +jo6;+\Ya>\U\aZ\\Hbqc1oz \\iق%a\-]\o)s\^\x[-4й\\\;\jT[P\lQ\\--\\v\$\ g3N]c\F|J^f<:Ī\u mYȬƼ1[\Z,En֨ESpRƆ\AKp\"K5\c\\k\{\=z\\eV<\}O^ԋʜ9E\\!\rf*9ʱ\'94nT\+NS?Ƈ!j5X\&\<1)d\-\\\u)_\]lQ91Y\¸K!\\`5R_\\\y\P\Ұ \h{G\\1ԏ P\\\ۦ%<\U0]y?gp)vI Ϭ\\\'\\uY\\"I۔…a\\aYXj[G\ؤ\r\0\B\<\\uoy\j:齟!5\6O\\\D|@+\\c|>ob_j\m\0&\Q\<\E\-AZv\ Ɍԏ.y\\M+z\Ga\\J\&\\Q\'۫\|0\&G(\p\<\s0\\zy|r\Sh]\\n\H|j=[P\`vm\PYESGMM\\\$\h\0S9\\J\LJL}XIQC\$kgD̛\:ռJt\\WUu}A\L\JsC\V\m؂Pb@ |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1>CO$yQ2Ka(#q\:\\O\\9\WPKg\\\ν\C(@l\n1|l߷\0=\x\?\Sg\{\KI˵\\=k\a&\\\S(#x\kY&N\\97m\ZY\ڨuE(@8b\{\٭\\\sݜ\v~\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\&\Z\΍o\\\9A/0\p4\xF|i\\C\\uXm.S4Uq\n\ʟ\rM\%rV\[\\<\o]\ضͮVe\\[\\'\dك\\2<8QF5Ѓ\Z4X1\G_\ss\4OKWv_xڍ3\\~9N\\\B2r\0!t\\D\Eu\+@\0E\0cFF872\\Zہ\e%\\>\ԁq\^cog/\o\+\ɘ\e\dwJwOŷGi{oq5\Ϗc\"rL:\!\ne\B91f\'Ө/O\gdcB{fDM&\\=؆k*e\\~v1\c\\i\\\\)\#[H<|nږVV\i{\}IL\Kf 9\#\򙿍KrԷ\86P\\1\\XW5\\#ᬙ\0\ـDx\\m#>\7.\eDDDDDDDDDDD\\d \2\`&<) cf2琏vp\1\\\8k\9\1M~C\\\ҵ|բ\\9Ug{\\v05\U,SH x&B|\"\\X\\t\l{\\\\_v\ [\;d卮$RH\z\?a!\8 \dmfD\<+n&|\2\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]\\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\dJ>\rms1/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\Z\\J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8R\7$ș>W n\I#\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\Wp\'\\\\\\"\"\"(\c̞94\m\i\"J\_0\\{\rkA\"\{\\jwƯX*\Z[\m3Ɋ\\\\l\ʋc W^V,g_Z\0c}ƒd\b\x\m&)\<# E.OF\:\0\'܇#v\Bp\\{\\5nq\<8g\\DDDDE/Q/jE\׺\l\\}~rY[g0@\\G#֊3O\n0\\8\E\\ϞXӕ\;k/*|lÍm{]\\ZuT;\˹x\h2C26dFfwԝ\\dz\|\?\Sk0$\C\y&\KlbX|\0I]f<|U\t]f\6ڹ{V\\\5,ҩ~\ME\\\dW\C\ J,.\\{;=}Iқ\Z\\ry\HJI0YNJAL\\\p/\UZt+](}\|@ȣB\S\:f{\\\Vtٻ$<>ql\09O\\gAz<V\-i\g\\\2K[-g8\G)3P[\6~+{\U\˪Eę;,\ pans!\\.\f\8\\a\HkaW\7\P\\\BRX\eM,I\C^\q\+\R3X\+X\hdF)}V\M\q(8\\cDW5\ 7\c\c\w|㳬\0e\\ZiY밚9[\\ckZs.u\G9\x\\'JaΦ=\v{Mʙd}v\\أ2L\\V.hjHh\|q#\}2 ^\rޞ\\3j:\\\5KG\0S\2&\.\'\hN \mSfǩ(\5\ljb1FƍL x\ƌ \0F֌A\шCkX\5cp\c \x\\|t{@\\\\xF\x;c\#\\(\\Z9116̋\*}TUc#8*i9w\"\6\1\\b\Kߴk\\\׫\\{:k\\2bs\Vg8i̊SLr\0rtho\\OcN~n\\ԇv\\i\Wg>E\Z4cQ\r`\#Ub\G:4\9t\"\"\"\1E \0\07\3\ \"n^B\X1s\\rcq;8\3Qw\v\\/;ku6\\\c\gZ\~2noŠLZ64Y)g\&.#\\\-c\ߩcJ\皭I\ \Z\\Ut\$\o̸x\(,cB\+\Ǫj֋\i\u%n\u(\"m]t1\q\Ŏ,acw;?\"y{\"\"\"\"\"\"\"\"\"\"\"(J瞵\\"Y\\Ys[\\zs\X\7*\\QHi OiG=n^7{\ɾ8{}\}\dԬG޵܋\\\7\\"w\?̯\\έ fSC6\]Q*s+.َ\'\cKa\)k5\%;-c*.!UsZW\=]XVQ 6\\(\wՌDDZ\Vg\\\gnըu\H.3\\Nk\Zm\\Z\8#_\\7\\D\^lٖS%\\˓>\|͝:i\*dْʗ.Q\ȓ \ys=\1^\{\02\]ӊ>\S2\w\Ճ&ma*qAZKȦMZ\0`Ћ\L\cߚ\"\"\"\"\"\"\"\"\"\"\"\"\0C(&$\"\Z@\aB`BaaF9\ \ܱ\\Z\g\UnpwM{ˣ\'Z\Fr\\T\cck;\\r\L{\\\\n\\b\b\0i&ۘml$+k~N\s\N\r\Pϴ\\ \+Z)3!꼉\\M=t\\'\P͎aͭc\IV\:\\\DE?S\r5\\\&ns=\\t㲽a~9ǴsUF\ϻp\\.\p^\U\G)qv.@\h! fW[\ubx\\"H aAn~7\ñ|\'UWQOQ5Օ\n  X\\b ah5kq\\=\0]9\{\"\"\"\"\"\"\"\"\"\",h\q\*\\8q\ku]ebr\o\$\nw6s%\@\H\"0\Z>@\g\m\n2zη.vl䭐h[XNՁf\e\Y3v.\X\Z얺+s2T\]t\\\K}\Z\c]\nª%v\ݎ=@\nh4䷱\\5X\G9{`t\ I\\'\Ɗs\ܧ\\\\\%r\\jH3ܭFڷ*,\\J\7Q*\<F]\6@\'dcJ(C\ZOXL\1\3\0\>i\g/\\}\h}dF\e}xN\\\K\R>\0r\'X\$bO9Y\\N<`v\\d\[\WH\=dș\\Xg\\o^4!m\7 Gދv\}\\\\y\Z(5gضK9q)`̰85ǛcȈ\r|p\\\tO\\ ,ڲ{\oi\6fXL>@l\\;G79v(\\h.uK\[cOkWT\3̫j\J\L\]I:1\s҉\s{\\\$7\o~E[X!84{m\!aJ\\H\ B!|Xǻ8no\\h{\0Ñ\P֭H\%4)79ÆPc\\v3\]*\[/(\q\\r\nK\UB $Kca-\#Ǝ\"\sD&q jx\{xΉ\Zjó\pM8;#4\jb\b?G\\Ŵ :\y+\lZ:\k;Ȳ$f;lm\\ Ξioc\\&T\ X[8qy)lA!bTh\GW\\"ֹ_\O\~\p\I\\\\Z\-\im\\c1 UTVP&J>Zw½y\\\pU \\yR\\\8\2$g\\J\$\˟l{{\Z\"\"\"\"*}Q-l?݋~\ޱ*[{o}o\\.5f\\2{B\\8;m \ϗ>Ćي\Ɓ_y\W\#\\.\\\\JMP\\hȸy\\E줟8m\\R\5$\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃ\q\n;\\\M^(ѽ\$0eK^m<\󟕎U֨\n\:\A\ZkJ\Z٤}~\ȟCCk\\n5$\մ /O_Kŗ\[\AqO \f\8K/\%\\x昙\~\X\\0\\Y\O1\\r.\W\\\\\0\F\\0ya?%5|\a~q\gITV:5D\0+\F]\"\"\"\"\"ž\t{\׾@\o?j\m},7kuqe)Z)Xp\@ [(\H$2b@\ Um\\0˝S`H\x[m\$\\Zxq\\\$\xo\\@YF3+hq\Ӹy_GּDI\]\=6n=٢F\y8\z.g2W\8qWh֏X\1lʶ\\Z\%}9\_\0w5.\zݴ}s[ko3_\\\n\N\\Ÿ\Z,ʋ&9\QFT\"\"\"\"\"\"\"\".(\Z0\"D`@\0!c\!\!汌k\a\UR\2=\\[gf\lvZ\cF!C\.J\y`_:\:М\$&in\"W\;y\ɧfu>ptC|\nӐ9\n\)ka\[ih\\4-(5p\\\-~ΗWT {ʛz/Ѿ\ם/}ա\\z\l&^ߺ$q\[\\8\eݵM2Iֲ\"[Q3 \\\\\DDDDDDD^a\+Ľ\\Ú\R\?\\\U_j{4K\ZfF %\r\d \Ex\"a(,RJ \0O+\\\\EA\ \}_f\\rv\;TH\y/3S\j\\,ӨgÃ!ulK[\X^|\\0nr\ =jS.m\\H\"Xj܉\mRKk,66E,c`3\d 1d?\\Geb\\6#<\(\j \c~\/Q+ \I\\M20\\Sw_O`\8\?$rg\\\o1\"\!u\s-\\Ytⰻi\Z\\:7\Z\\0̬,\ZQqpo^wY\~4\r{F䚢\r\\Dœ#J\.,X3eM|Ղ%\\;3ƃ1aR#0 6& \ADn2 ˘AØ\-ss79\qȈ)@w絞W9w\\l\B\m_\q[IQ\'9.˵lQ\rE\ ɝ\'\Z\ (A\Ozfv)d\\^f\J\\k\6>h\\"\\t\\Q6f\iZf q\\~\}r\0vOG\5{&^q\r.:)cD\uv\*mk\\l{\J\Z|x\\ݩظ\O\,\mGH\Z.N\\Ipc\W\` G\*6ڑ\2\\ BkWM#ҵ\b\\~;;Y\n)2\0\;4>\u:}4\lb\o\㽭w>\ha\\\@خeN\m;yKnwF\ﱂK:9;1\q8O\5\\2ܷ\Ƿ7\\#r\o[<\r\mu$bs\\VJs\Z7/~qͶ~\kKQ\\%i&\\Qd7\ā܇\o\EJֽ\i\v\\\\\\sK\\\k\4\Ȝ\,D~G)\\vʊ610\\;`/\-w\\\ٽ\C\(\I\-jh\ZvQ\ĉQ\0(UA9lp\07\e\#\\s\\QS\/\\7L:\\\ǒd\f\ c\\X0,5#2Zn&7\;o\9\\V}\\r\\"ޠ\+\W\\K \Aߜ\\\^\0\"\"\"\"\O=2^\S:\lSM\'\\\c\\Dz߾Xǃ9Ǿ}x\\icoqPn\N쎭\'\\p% \xV ԭeNr\g$v.?\0m\5\@!k\y\\\\i\\\8\0# HLyBY\q2\ai\\0ߟv\7\~Y@\уx$;yF\/\'c\!\h\\\L\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\V\0  RDDDD_\Al(\ #ZonZ\Z=\\\\c-ssg\s\L]z\2~q9(# \kc\1ٍ[\lc9SDDDDDDDDDDDDQ`\\e\\]ό\r\p\#|\\0XjB\1fWVx\\\Zܘ֔ 8n2Be2{\\3\X\m\"\"\"\"\"GmC\\\\Zia<\\w.X坲\\!=\c(\\|\01\0\l\"\"\"\"\"\"\"\"\"\"\"\"~\\0\gi\V2\0/<9\\\\\\\E*V[\?\\r#\\ĉ\K\Ӿ\0\hT\v3DDDDZ\\qO.\*\s\6RZCl (\zq`\n>\n\^\dNsƈ\c\\c?PfFh\\\ [(p%\ \\Gtı$30\3?ɇ\_\=8\<,?ڄA\,\r^Q0\?710`xȱ\k\\\*|*fuՕMdx3)w$?g\c1&\Zs\ۂ\\\_\}\DDDDDDDDDDDE\7=F|\\u\re55\\\Cѕ\|ax\\\\ 3\\=ѡQ\kՍ{+hjk\Ga\d\Za{Z\9\\0; n\g8kqc\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/\',4,1,'','','',''),(11,3,0,'9305bf00c27303da','17543934065bf00c273021a183499779','2018-11-17 12:40:07','2018-11-17 12:40:07','','','Profile Photos','person-300.jpg','image/jpeg',80,80,2354,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0+\0\0\0\0\0\0\0\0 \n!#\"AB\\0\0\0?\0\\8\8\8㚑\1V\\7M\PCm;\n\f˲\A\"&\n(\\\z\r+\\*N\1dVq+:}\wN\\z/۝ \\I̮֩\uJ7\ZIA\y\d2m`\^N|HKFow\nRɭc5mö0\~Sr\\\t<Ŏ8\Mi\-2\Cz*\}\\`C\_\qț\\\'0\g(督v\\pQaYV\[=AXFR\\Y\Zp|6RPoSz\\B9d\\\V؛jl.e6Nڔ\"S\\pσ\U{\r<є\\*F@VԘ:WV`b6]hX\\F\G8a\n\\\hNcsѿ7?\Z*GLn.{^\m}kY(IF\s(\#\#Y+{|2&%hZ\OL~\0\:\vڗ-\\E\;=!\0\DZ\vW\^Q\\"\5y\0\Z\G\لJ ]\٥\CT\\ZiR.!-:eZ_>(q԰J\dHqya\0\\T9\\\/ZŹ\潩DO\\"f4D\rR\wDPm5-\\\]P>A\r+\sњK^_\z\A\t\n3V\)\ٚ@.gE0{H\0.`FZP\qi7k\\\=4ąםL\c\\+c\\\\ΩN h\HH\\&\O\r^m\\K1R\\\0z\?W\'m\\r`\ƵD\*\Z.4.\D\)&\Z\"\1yȐ_\2\eǧXv:r\^*d!!ܭ;@\3\=?\'kR\ \Fq\u*\\"ˆ0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬OcaYr\]C\(%g\F6X͟+\\\a0Qԫ$]#cJ4 \HZ&f:\dפ3b\\rs`*\%IZp\nJ%I\2c8\q|\8\?g\qϞ8\~S&S\II(\\rnws\ZQ3q\$L\%r\ZS4\j\0*8}\e92(w\f\r\\\GAW\#\0xؘxxZ2*.86\\d@f[a\\}sm}Bِn\6=\"+o^Vpo9\k;:\84\\*n8j\`d|\һi\.\\v l\rsn\2#җ!E\*2P\gS\!Z^뒠\\*\2p\\v#f\~\\֫\﷤B/j\U)^ĉ,y-`\0$|kkם:4\0\Mj8Pߨ)\\(Y\\0-yVQې&E\0@>,\>\\8\\c\K?\m a\nm3\;sR$\%Զ\*ͭL԰\\\M\q\ua= \^LrE\} ĚXRra]ޱMd~4\D\e-8\\eᲬ6[§q\t\\\TB:)M\aG`t\+w\0ٍ\XCoKˎp$QK\ vY 3\%\K㈲\\\p㌌ᅸ;KVP\\!M*y̫8\u-;99\r\rB}\^xt;^oVZR9\g\',5,1,'','','',''),(12,3,0,'9305bf00c27303da','17543934065bf00c273021a183499779','2018-11-17 12:40:07','2018-11-17 12:40:07','','','Profile Photos','person-300.jpg','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\(: +jo6;+\Ya>\U\aZ\\Hbqc1oz \\iق%a\-]\o)s\^\x[-4й\\\;\jT[P\lQ\\--\\v\$\ g3N]c\F|J^f<:Ī\u mYȬƼ1[\Z,En֨ESpRƆ\AKp\"K5\c\\k\{\=z\\eV<\}O^ԋʜ9E\\!\rf*9ʱ\'94nT\+NS?Ƈ!j5X\&\<1)d\-\\\u)_\]lQ91Y\¸K!\\`5R_\\\y\P\Ұ \h{G\\1ԏ P\\\ۦ%<\U0]y?gp)vI Ϭ\\\'\\uY\\"I۔…a\\aYXj[G\ؤ\r\0\B\<\\uoy\j:齟!5\6O\\\D|@+\\c|>ob_j\m\0&\Q\<\E\-AZv\ Ɍԏ.y\\M+z\Ga\\J\&\\Q\'۫\|0\&G(\p\<\s0\\zy|r\Sh]\\n\H|j=[P\`vm\PYESGMM\\\$\h\0S9\\J\LJL}XIQC\$kgD̛\:ռJt\\WUu}A\L\JsC\V\m؂Pb@ |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1>CO$yQ2Ka(#q\:\\O\\9\WPKg\\\ν\C(@l\n1|l߷\0=\x\?\Sg\{\KI˵\\=k\a&\\\S(#x\kY&N\\97m\ZY\ڨuE(@8b\{\٭\\\sݜ\v~\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\&\Z\΍o\\\9A/0\p4\xF|i\\C\\uXm.S4Uq\n\ʟ\rM\%rV\[\\<\o]\ضͮVe\\[\\'\dك\\2<8QF5Ѓ\Z4X1\G_\ss\4OKWv_xڍ3\\~9N\\\B2r\0!t\\D\Eu\+@\0E\0cFF872\\Zہ\e%\\>\ԁq\^cog/\o\+\ɘ\e\dwJwOŷGi{oq5\Ϗc\"rL:\!\ne\B91f\'Ө/O\gdcB{fDM&\\=؆k*e\\~v1\c\\i\\\\)\#[H<|nږVV\i{\}IL\Kf 9\#\򙿍KrԷ\86P\\1\\XW5\\#ᬙ\0\ـDx\\m#>\7.\eDDDDDDDDDDD\\d \2\`&<) cf2琏vp\1\\\8k\9\1M~C\\\ҵ|բ\\9Ug{\\v05\U,SH x&B|\"\\X\\t\l{\\\\_v\ [\;d卮$RH\z\?a!\8 \dmfD\<+n&|\2\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]\\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\dJ>\rms1/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\Z\\J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8R\7$ș>W n\I#\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\Wp\'\\\\\\"\"\"(\c̞94\m\i\"J\_0\\{\rkA\"\{\\jwƯX*\Z[\m3Ɋ\\\\l\ʋc W^V,g_Z\0c}ƒd\b\x\m&)\<# E.OF\:\0\'܇#v\Bp\\{\\5nq\<8g\\DDDDE/Q/jE\׺\l\\}~rY[g0@\\G#֊3O\n0\\8\E\\ϞXӕ\;k/*|lÍm{]\\ZuT;\˹x\h2C26dFfwԝ\\dz\|\?\Sk0$\C\y&\KlbX|\0I]f<|U\t]f\6ڹ{V\\\5,ҩ~\ME\\\dW\C\ J,.\\{;=}Iқ\Z\\ry\HJI0YNJAL\\\p/\UZt+](}\|@ȣB\S\:f{\\\Vtٻ$<>ql\09O\\gAz<V\-i\g\\\2K[-g8\G)3P[\6~+{\U\˪Eę;,\ pans!\\.\f\8\\a\HkaW\7\P\\\BRX\eM,I\C^\q\+\R3X\+X\hdF)}V\M\q(8\\cDW5\ 7\c\c\w|㳬\0e\\ZiY밚9[\\ckZs.u\G9\x\\'JaΦ=\v{Mʙd}v\\أ2L\\V.hjHh\|q#\}2 ^\rޞ\\3j:\\\5KG\0S\2&\.\'\hN \mSfǩ(\5\ljb1FƍL x\ƌ \0F֌A\шCkX\5cp\c \x\\|t{@\\\\xF\x;c\#\\(\\Z9116̋\*}TUc#8*i9w\"\6\1\\b\Kߴk\\\׫\\{:k\\2bs\Vg8i̊SLr\0rtho\\OcN~n\\ԇv\\i\Wg>E\Z4cQ\r`\#Ub\G:4\9t\"\"\"\1E \0\07\3\ \"n^B\X1s\\rcq;8\3Qw\v\\/;ku6\\\c\gZ\~2noŠLZ64Y)g\&.#\\\-c\ߩcJ\皭I\ \Z\\Ut\$\o̸x\(,cB\+\Ǫj֋\i\u%n\u(\"m]t1\q\Ŏ,acw;?\"y{\"\"\"\"\"\"\"\"\"\"\"(J瞵\\"Y\\Ys[\\zs\X\7*\\QHi OiG=n^7{\ɾ8{}\}\dԬG޵܋\\\7\\"w\?̯\\έ fSC6\]Q*s+.َ\'\cKa\)k5\%;-c*.!UsZW\=]XVQ 6\\(\wՌDDZ\Vg\\\gnըu\H.3\\Nk\Zm\\Z\8#_\\7\\D\^lٖS%\\˓>\|͝:i\*dْʗ.Q\ȓ \ys=\1^\{\02\]ӊ>\S2\w\Ճ&ma*qAZKȦMZ\0`Ћ\L\cߚ\"\"\"\"\"\"\"\"\"\"\"\"\0C(&$\"\Z@\aB`BaaF9\ \ܱ\\Z\g\UnpwM{ˣ\'Z\Fr\\T\cck;\\r\L{\\\\n\\b\b\0i&ۘml$+k~N\s\N\r\Pϴ\\ \+Z)3!꼉\\M=t\\'\P͎aͭc\IV\:\\\DE?S\r5\\\&ns=\\t㲽a~9ǴsUF\ϻp\\.\p^\U\G)qv.@\h! fW[\ubx\\"H aAn~7\ñ|\'UWQOQ5Օ\n  X\\b ah5kq\\=\0]9\{\"\"\"\"\"\"\"\"\"\",h\q\*\\8q\ku]ebr\o\$\nw6s%\@\H\"0\Z>@\g\m\n2zη.vl䭐h[XNՁf\e\Y3v.\X\Z얺+s2T\]t\\\K}\Z\c]\nª%v\ݎ=@\nh4䷱\\5X\G9{`t\ I\\'\Ɗs\ܧ\\\\\%r\\jH3ܭFڷ*,\\J\7Q*\<F]\6@\'dcJ(C\ZOXL\1\3\0\>i\g/\\}\h}dF\e}xN\\\K\R>\0r\'X\$bO9Y\\N<`v\\d\[\WH\=dș\\Xg\\o^4!m\7 Gދv\}\\\\y\Z(5gضK9q)`̰85ǛcȈ\r|p\\\tO\\ ,ڲ{\oi\6fXL>@l\\;G79v(\\h.uK\[cOkWT\3̫j\J\L\]I:1\s҉\s{\\\$7\o~E[X!84{m\!aJ\\H\ B!|Xǻ8no\\h{\0Ñ\P֭H\%4)79ÆPc\\v3\]*\[/(\q\\r\nK\UB $Kca-\#Ǝ\"\sD&q jx\{xΉ\Zjó\pM8;#4\jb\b?G\\Ŵ :\y+\lZ:\k;Ȳ$f;lm\\ Ξioc\\&T\ X[8qy)lA!bTh\GW\\"ֹ_\O\~\p\I\\\\Z\-\im\\c1 UTVP&J>Zw½y\\\pU \\yR\\\8\2$g\\J\$\˟l{{\Z\"\"\"\"*}Q-l?݋~\ޱ*[{o}o\\.5f\\2{B\\8;m \ϗ>Ćي\Ɓ_y\W\#\\.\\\\JMP\\hȸy\\E줟8m\\R\5$\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃ\q\n;\\\M^(ѽ\$0eK^m<\󟕎U֨\n\:\A\ZkJ\Z٤}~\ȟCCk\\n5$\մ /O_Kŗ\[\AqO \f\8K/\%\\x昙\~\X\\0\\Y\O1\\r.\W\\\\\0\F\\0ya?%5|\a~q\gITV:5D\0+\F]\"\"\"\"\"ž\t{\׾@\o?j\m},7kuqe)Z)Xp\@ [(\H$2b@\ Um\\0˝S`H\x[m\$\\Zxq\\\$\xo\\@YF3+hq\Ӹy_GּDI\]\=6n=٢F\y8\z.g2W\8qWh֏X\1lʶ\\Z\%}9\_\0w5.\zݴ}s[ko3_\\\n\N\\Ÿ\Z,ʋ&9\QFT\"\"\"\"\"\"\"\".(\Z0\"D`@\0!c\!\!汌k\a\UR\2=\\[gf\lvZ\cF!C\.J\y`_:\:М\$&in\"W\;y\ɧfu>ptC|\nӐ9\n\)ka\[ih\\4-(5p\\\-~ΗWT {ʛz/Ѿ\ם/}ա\\z\l&^ߺ$q\[\\8\eݵM2Iֲ\"[Q3 \\\\\DDDDDDD^a\+Ľ\\Ú\R\?\\\U_j{4K\ZfF %\r\d \Ex\"a(,RJ \0O+\\\\EA\ \}_f\\rv\;TH\y/3S\j\\,ӨgÃ!ulK[\X^|\\0nr\ =jS.m\\H\"Xj܉\mRKk,66E,c`3\d 1d?\\Geb\\6#<\(\j \c~\/Q+ \I\\M20\\Sw_O`\8\?$rg\\\o1\"\!u\s-\\Ytⰻi\Z\\:7\Z\\0̬,\ZQqpo^wY\~4\r{F䚢\r\\Dœ#J\.,X3eM|Ղ%\\;3ƃ1aR#0 6& \ADn2 ˘AØ\-ss79\qȈ)@w絞W9w\\l\B\m_\q[IQ\'9.˵lQ\rE\ ɝ\'\Z\ (A\Ozfv)d\\^f\J\\k\6>h\\"\\t\\Q6f\iZf q\\~\}r\0vOG\5{&^q\r.:)cD\uv\*mk\\l{\J\Z|x\\ݩظ\O\,\mGH\Z.N\\Ipc\W\` G\*6ڑ\2\\ BkWM#ҵ\b\\~;;Y\n)2\0\;4>\u:}4\lb\o\㽭w>\ha\\\@خeN\m;yKnwF\ﱂK:9;1\q8O\5\\2ܷ\Ƿ7\\#r\o[<\r\mu$bs\\VJs\Z7/~qͶ~\kKQ\\%i&\\Qd7\ā܇\o\EJֽ\i\v\\\\\\sK\\\k\4\Ȝ\,D~G)\\vʊ610\\;`/\-w\\\ٽ\C\(\I\-jh\ZvQ\ĉQ\0(UA9lp\07\e\#\\s\\QS\/\\7L:\\\ǒd\f\ c\\X0,5#2Zn&7\;o\9\\V}\\r\\"ޠ\+\W\\K \Aߜ\\\^\0\"\"\"\"\O=2^\S:\lSM\'\\\c\\Dz߾Xǃ9Ǿ}x\\icoqPn\N쎭\'\\p% \xV ԭeNr\g$v.?\0m\5\@!k\y\\\\i\\\8\0# HLyBY\q2\ai\\0ߟv\7\~Y@\уx$;yF\/\'c\!\h\\\L\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\V\0  RDDDD_\Al(\ #ZonZ\Z=\\\\c-ssg\s\L]z\2~q9(# \kc\1ٍ[\lc9SDDDDDDDDDDDDQ`\\e\\]ό\r\p\#|\\0XjB\1fWVx\\\Zܘ֔ 8n2Be2{\\3\X\m\"\"\"\"\"GmC\\\\Zia<\\w.X坲\\!=\c(\\|\01\0\l\"\"\"\"\"\"\"\"\"\"\"\"~\\0\gi\V2\0/<9\\\\\\\E*V[\?\\r#\\ĉ\K\Ӿ\0\hT\v3DDDDZ\\qO.\*\s\6RZCl (\zq`\n>\n\^\dNsƈ\c\\c?PfFh\\\ [(p%\ \\Gtı$30\3?ɇ\_\=8\<,?ڄA\,\r^Q0\?710`xȱ\k\\\*|*fuՕMdx3)w$?g\c1&\Zs\ۂ\\\_\}\DDDDDDDDDDDE\7=F|\\u\re55\\\Cѕ\|ax\\\\ 3\\=ѡQ\kՍ{+hjk\Ga\d\Za{Z\9\\0; n\g8kqc\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/\',4,1,'','','',''),(14,4,0,'9305bf00c3cedc12','24803714715bf00c3ced9f6942163975','2018-11-17 12:40:28','2018-11-17 12:40:28','','','Profile Photos','person-300.jpg','image/jpeg',80,80,2354,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0+\0\0\0\0\0\0\0\0 \n!#\"AB\\0\0\0?\0\\8\8\8㚑\1V\\7M\PCm;\n\f˲\A\"&\n(\\\z\r+\\*N\1dVq+:}\wN\\z/۝ \\I̮֩\uJ7\ZIA\y\d2m`\^N|HKFow\nRɭc5mö0\~Sr\\\t<Ŏ8\Mi\-2\Cz*\}\\`C\_\qț\\\'0\g(督v\\pQaYV\[=AXFR\\Y\Zp|6RPoSz\\B9d\\\V؛jl.e6Nڔ\"S\\pσ\U{\r<є\\*F@VԘ:WV`b6]hX\\F\G8a\n\\\hNcsѿ7?\Z*GLn.{^\m}kY(IF\s(\#\#Y+{|2&%hZ\OL~\0\:\vڗ-\\E\;=!\0\DZ\vW\^Q\\"\5y\0\Z\G\لJ ]\٥\CT\\ZiR.!-:eZ_>(q԰J\dHqya\0\\T9\\\/ZŹ\潩DO\\"f4D\rR\wDPm5-\\\]P>A\r+\sњK^_\z\A\t\n3V\)\ٚ@.gE0{H\0.`FZP\qi7k\\\=4ąםL\c\\+c\\\\ΩN h\HH\\&\O\r^m\\K1R\\\0z\?W\'m\\r`\ƵD\*\Z.4.\D\)&\Z\"\1yȐ_\2\eǧXv:r\^*d!!ܭ;@\3\=?\'kR\ \Fq\u*\\"ˆ0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬OcaYr\]C\(%g\F6X͟+\\\a0Qԫ$]#cJ4 \HZ&f:\dפ3b\\rs`*\%IZp\nJ%I\2c8\q|\8\?g\qϞ8\~S&S\II(\\rnws\ZQ3q\$L\%r\ZS4\j\0*8}\e92(w\f\r\\\GAW\#\0xؘxxZ2*.86\\d@f[a\\}sm}Bِn\6=\"+o^Vpo9\k;:\84\\*n8j\`d|\һi\.\\v l\rsn\2#җ!E\*2P\gS\!Z^뒠\\*\2p\\v#f\~\\֫\﷤B/j\U)^ĉ,y-`\0$|kkם:4\0\Mj8Pߨ)\\(Y\\0-yVQې&E\0@>,\>\\8\\c\K?\m a\nm3\;sR$\%Զ\*ͭL԰\\\M\q\ua= \^LrE\} ĚXRra]ޱMd~4\D\e-8\\eᲬ6[§q\t\\\TB:)M\aG`t\+w\0ٍ\XCoKˎp$QK\ vY 3\%\K㈲\\\p㌌ᅸ;KVP\\!M*y̫8\u-;99\r\rB}\^xt;^oVZR9\g\',5,1,'','','',''),(15,4,0,'9305bf00c3cedc12','24803714715bf00c3ced9f6942163975','2018-11-17 12:40:28','2018-11-17 12:40:28','','','Profile Photos','person-300.jpg','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\(: +jo6;+\Ya>\U\aZ\\Hbqc1oz \\iق%a\-]\o)s\^\x[-4й\\\;\jT[P\lQ\\--\\v\$\ g3N]c\F|J^f<:Ī\u mYȬƼ1[\Z,En֨ESpRƆ\AKp\"K5\c\\k\{\=z\\eV<\}O^ԋʜ9E\\!\rf*9ʱ\'94nT\+NS?Ƈ!j5X\&\<1)d\-\\\u)_\]lQ91Y\¸K!\\`5R_\\\y\P\Ұ \h{G\\1ԏ P\\\ۦ%<\U0]y?gp)vI Ϭ\\\'\\uY\\"I۔…a\\aYXj[G\ؤ\r\0\B\<\\uoy\j:齟!5\6O\\\D|@+\\c|>ob_j\m\0&\Q\<\E\-AZv\ Ɍԏ.y\\M+z\Ga\\J\&\\Q\'۫\|0\&G(\p\<\s0\\zy|r\Sh]\\n\H|j=[P\`vm\PYESGMM\\\$\h\0S9\\J\LJL}XIQC\$kgD̛\:ռJt\\WUu}A\L\JsC\V\m؂Pb@ |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1>CO$yQ2Ka(#q\:\\O\\9\WPKg\\\ν\C(@l\n1|l߷\0=\x\?\Sg\{\KI˵\\=k\a&\\\S(#x\kY&N\\97m\ZY\ڨuE(@8b\{\٭\\\sݜ\v~\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\&\Z\΍o\\\9A/0\p4\xF|i\\C\\uXm.S4Uq\n\ʟ\rM\%rV\[\\<\o]\ضͮVe\\[\\'\dك\\2<8QF5Ѓ\Z4X1\G_\ss\4OKWv_xڍ3\\~9N\\\B2r\0!t\\D\Eu\+@\0E\0cFF872\\Zہ\e%\\>\ԁq\^cog/\o\+\ɘ\e\dwJwOŷGi{oq5\Ϗc\"rL:\!\ne\B91f\'Ө/O\gdcB{fDM&\\=؆k*e\\~v1\c\\i\\\\)\#[H<|nږVV\i{\}IL\Kf 9\#\򙿍KrԷ\86P\\1\\XW5\\#ᬙ\0\ـDx\\m#>\7.\eDDDDDDDDDDD\\d \2\`&<) cf2琏vp\1\\\8k\9\1M~C\\\ҵ|բ\\9Ug{\\v05\U,SH x&B|\"\\X\\t\l{\\\\_v\ [\;d卮$RH\z\?a!\8 \dmfD\<+n&|\2\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]\\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\dJ>\rms1/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\Z\\J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8R\7$ș>W n\I#\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\Wp\'\\\\\\"\"\"(\c̞94\m\i\"J\_0\\{\rkA\"\{\\jwƯX*\Z[\m3Ɋ\\\\l\ʋc W^V,g_Z\0c}ƒd\b\x\m&)\<# E.OF\:\0\'܇#v\Bp\\{\\5nq\<8g\\DDDDE/Q/jE\׺\l\\}~rY[g0@\\G#֊3O\n0\\8\E\\ϞXӕ\;k/*|lÍm{]\\ZuT;\˹x\h2C26dFfwԝ\\dz\|\?\Sk0$\C\y&\KlbX|\0I]f<|U\t]f\6ڹ{V\\\5,ҩ~\ME\\\dW\C\ J,.\\{;=}Iқ\Z\\ry\HJI0YNJAL\\\p/\UZt+](}\|@ȣB\S\:f{\\\Vtٻ$<>ql\09O\\gAz<V\-i\g\\\2K[-g8\G)3P[\6~+{\U\˪Eę;,\ pans!\\.\f\8\\a\HkaW\7\P\\\BRX\eM,I\C^\q\+\R3X\+X\hdF)}V\M\q(8\\cDW5\ 7\c\c\w|㳬\0e\\ZiY밚9[\\ckZs.u\G9\x\\'JaΦ=\v{Mʙd}v\\أ2L\\V.hjHh\|q#\}2 ^\rޞ\\3j:\\\5KG\0S\2&\.\'\hN \mSfǩ(\5\ljb1FƍL x\ƌ \0F֌A\шCkX\5cp\c \x\\|t{@\\\\xF\x;c\#\\(\\Z9116̋\*}TUc#8*i9w\"\6\1\\b\Kߴk\\\׫\\{:k\\2bs\Vg8i̊SLr\0rtho\\OcN~n\\ԇv\\i\Wg>E\Z4cQ\r`\#Ub\G:4\9t\"\"\"\1E \0\07\3\ \"n^B\X1s\\rcq;8\3Qw\v\\/;ku6\\\c\gZ\~2noŠLZ64Y)g\&.#\\\-c\ߩcJ\皭I\ \Z\\Ut\$\o̸x\(,cB\+\Ǫj֋\i\u%n\u(\"m]t1\q\Ŏ,acw;?\"y{\"\"\"\"\"\"\"\"\"\"\"(J瞵\\"Y\\Ys[\\zs\X\7*\\QHi OiG=n^7{\ɾ8{}\}\dԬG޵܋\\\7\\"w\?̯\\έ fSC6\]Q*s+.َ\'\cKa\)k5\%;-c*.!UsZW\=]XVQ 6\\(\wՌDDZ\Vg\\\gnըu\H.3\\Nk\Zm\\Z\8#_\\7\\D\^lٖS%\\˓>\|͝:i\*dْʗ.Q\ȓ \ys=\1^\{\02\]ӊ>\S2\w\Ճ&ma*qAZKȦMZ\0`Ћ\L\cߚ\"\"\"\"\"\"\"\"\"\"\"\"\0C(&$\"\Z@\aB`BaaF9\ \ܱ\\Z\g\UnpwM{ˣ\'Z\Fr\\T\cck;\\r\L{\\\\n\\b\b\0i&ۘml$+k~N\s\N\r\Pϴ\\ \+Z)3!꼉\\M=t\\'\P͎aͭc\IV\:\\\DE?S\r5\\\&ns=\\t㲽a~9ǴsUF\ϻp\\.\p^\U\G)qv.@\h! fW[\ubx\\"H aAn~7\ñ|\'UWQOQ5Օ\n  X\\b ah5kq\\=\0]9\{\"\"\"\"\"\"\"\"\"\",h\q\*\\8q\ku]ebr\o\$\nw6s%\@\H\"0\Z>@\g\m\n2zη.vl䭐h[XNՁf\e\Y3v.\X\Z얺+s2T\]t\\\K}\Z\c]\nª%v\ݎ=@\nh4䷱\\5X\G9{`t\ I\\'\Ɗs\ܧ\\\\\%r\\jH3ܭFڷ*,\\J\7Q*\<F]\6@\'dcJ(C\ZOXL\1\3\0\>i\g/\\}\h}dF\e}xN\\\K\R>\0r\'X\$bO9Y\\N<`v\\d\[\WH\=dș\\Xg\\o^4!m\7 Gދv\}\\\\y\Z(5gضK9q)`̰85ǛcȈ\r|p\\\tO\\ ,ڲ{\oi\6fXL>@l\\;G79v(\\h.uK\[cOkWT\3̫j\J\L\]I:1\s҉\s{\\\$7\o~E[X!84{m\!aJ\\H\ B!|Xǻ8no\\h{\0Ñ\P֭H\%4)79ÆPc\\v3\]*\[/(\q\\r\nK\UB $Kca-\#Ǝ\"\sD&q jx\{xΉ\Zjó\pM8;#4\jb\b?G\\Ŵ :\y+\lZ:\k;Ȳ$f;lm\\ Ξioc\\&T\ X[8qy)lA!bTh\GW\\"ֹ_\O\~\p\I\\\\Z\-\im\\c1 UTVP&J>Zw½y\\\pU \\yR\\\8\2$g\\J\$\˟l{{\Z\"\"\"\"*}Q-l?݋~\ޱ*[{o}o\\.5f\\2{B\\8;m \ϗ>Ćي\Ɓ_y\W\#\\.\\\\JMP\\hȸy\\E줟8m\\R\5$\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃ\q\n;\\\M^(ѽ\$0eK^m<\󟕎U֨\n\:\A\ZkJ\Z٤}~\ȟCCk\\n5$\մ /O_Kŗ\[\AqO \f\8K/\%\\x昙\~\X\\0\\Y\O1\\r.\W\\\\\0\F\\0ya?%5|\a~q\gITV:5D\0+\F]\"\"\"\"\"ž\t{\׾@\o?j\m},7kuqe)Z)Xp\@ [(\H$2b@\ Um\\0˝S`H\x[m\$\\Zxq\\\$\xo\\@YF3+hq\Ӹy_GּDI\]\=6n=٢F\y8\z.g2W\8qWh֏X\1lʶ\\Z\%}9\_\0w5.\zݴ}s[ko3_\\\n\N\\Ÿ\Z,ʋ&9\QFT\"\"\"\"\"\"\"\".(\Z0\"D`@\0!c\!\!汌k\a\UR\2=\\[gf\lvZ\cF!C\.J\y`_:\:М\$&in\"W\;y\ɧfu>ptC|\nӐ9\n\)ka\[ih\\4-(5p\\\-~ΗWT {ʛz/Ѿ\ם/}ա\\z\l&^ߺ$q\[\\8\eݵM2Iֲ\"[Q3 \\\\\DDDDDDD^a\+Ľ\\Ú\R\?\\\U_j{4K\ZfF %\r\d \Ex\"a(,RJ \0O+\\\\EA\ \}_f\\rv\;TH\y/3S\j\\,ӨgÃ!ulK[\X^|\\0nr\ =jS.m\\H\"Xj܉\mRKk,66E,c`3\d 1d?\\Geb\\6#<\(\j \c~\/Q+ \I\\M20\\Sw_O`\8\?$rg\\\o1\"\!u\s-\\Ytⰻi\Z\\:7\Z\\0̬,\ZQqpo^wY\~4\r{F䚢\r\\Dœ#J\.,X3eM|Ղ%\\;3ƃ1aR#0 6& \ADn2 ˘AØ\-ss79\qȈ)@w絞W9w\\l\B\m_\q[IQ\'9.˵lQ\rE\ ɝ\'\Z\ (A\Ozfv)d\\^f\J\\k\6>h\\"\\t\\Q6f\iZf q\\~\}r\0vOG\5{&^q\r.:)cD\uv\*mk\\l{\J\Z|x\\ݩظ\O\,\mGH\Z.N\\Ipc\W\` G\*6ڑ\2\\ BkWM#ҵ\b\\~;;Y\n)2\0\;4>\u:}4\lb\o\㽭w>\ha\\\@خeN\m;yKnwF\ﱂK:9;1\q8O\5\\2ܷ\Ƿ7\\#r\o[<\r\mu$bs\\VJs\Z7/~qͶ~\kKQ\\%i&\\Qd7\ā܇\o\EJֽ\i\v\\\\\\sK\\\k\4\Ȝ\,D~G)\\vʊ610\\;`/\-w\\\ٽ\C\(\I\-jh\ZvQ\ĉQ\0(UA9lp\07\e\#\\s\\QS\/\\7L:\\\ǒd\f\ c\\X0,5#2Zn&7\;o\9\\V}\\r\\"ޠ\+\W\\K \Aߜ\\\^\0\"\"\"\"\O=2^\S:\lSM\'\\\c\\Dz߾Xǃ9Ǿ}x\\icoqPn\N쎭\'\\p% \xV ԭeNr\g$v.?\0m\5\@!k\y\\\\i\\\8\0# HLyBY\q2\ai\\0ߟv\7\~Y@\уx$;yF\/\'c\!\h\\\L\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\V\0  RDDDD_\Al(\ #ZonZ\Z=\\\\c-ssg\s\L]z\2~q9(# \kc\1ٍ[\lc9SDDDDDDDDDDDDQ`\\e\\]ό\r\p\#|\\0XjB\1fWVx\\\Zܘ֔ 8n2Be2{\\3\X\m\"\"\"\"\"GmC\\\\Zia<\\w.X坲\\!=\c(\\|\01\0\l\"\"\"\"\"\"\"\"\"\"\"\"~\\0\gi\V2\0/<9\\\\\\\E*V[\?\\r#\\ĉ\K\Ӿ\0\hT\v3DDDDZ\\qO.\*\s\6RZCl (\zq`\n>\n\^\dNsƈ\c\\c?PfFh\\\ [(p%\ \\Gtı$30\3?ɇ\_\=8\<,?ڄA\,\r^Q0\?710`xȱ\k\\\*|*fuՕMdx3)w$?g\c1&\Zs\ۂ\\\_\}\DDDDDDDDDDDE\7=F|\\u\re55\\\Cѕ\|ax\\\\ 3\\=ѡQ\kՍ{+hjk\Ga\d\Za{Z\9\\0; n\g8kqc\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/\',4,1,'','','',''),(17,5,0,'9305bf00c9709025','12966450605bf00c9708ea1748285071','2018-11-17 12:41:59','2018-11-17 12:41:59','','','Profile Photos','person-300.jpg','image/jpeg',80,80,2354,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0+\0\0\0\0\0\0\0\0 \n!#\"AB\\0\0\0?\0\\8\8\8㚑\1V\\7M\PCm;\n\f˲\A\"&\n(\\\z\r+\\*N\1dVq+:}\wN\\z/۝ \\I̮֩\uJ7\ZIA\y\d2m`\^N|HKFow\nRɭc5mö0\~Sr\\\t<Ŏ8\Mi\-2\Cz*\}\\`C\_\qț\\\'0\g(督v\\pQaYV\[=AXFR\\Y\Zp|6RPoSz\\B9d\\\V؛jl.e6Nڔ\"S\\pσ\U{\r<є\\*F@VԘ:WV`b6]hX\\F\G8a\n\\\hNcsѿ7?\Z*GLn.{^\m}kY(IF\s(\#\#Y+{|2&%hZ\OL~\0\:\vڗ-\\E\;=!\0\DZ\vW\^Q\\"\5y\0\Z\G\لJ ]\٥\CT\\ZiR.!-:eZ_>(q԰J\dHqya\0\\T9\\\/ZŹ\潩DO\\"f4D\rR\wDPm5-\\\]P>A\r+\sњK^_\z\A\t\n3V\)\ٚ@.gE0{H\0.`FZP\qi7k\\\=4ąםL\c\\+c\\\\ΩN h\HH\\&\O\r^m\\K1R\\\0z\?W\'m\\r`\ƵD\*\Z.4.\D\)&\Z\"\1yȐ_\2\eǧXv:r\^*d!!ܭ;@\3\=?\'kR\ \Fq\u*\\"ˆ0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬OcaYr\]C\(%g\F6X͟+\\\a0Qԫ$]#cJ4 \HZ&f:\dפ3b\\rs`*\%IZp\nJ%I\2c8\q|\8\?g\qϞ8\~S&S\II(\\rnws\ZQ3q\$L\%r\ZS4\j\0*8}\e92(w\f\r\\\GAW\#\0xؘxxZ2*.86\\d@f[a\\}sm}Bِn\6=\"+o^Vpo9\k;:\84\\*n8j\`d|\һi\.\\v l\rsn\2#җ!E\*2P\gS\!Z^뒠\\*\2p\\v#f\~\\֫\﷤B/j\U)^ĉ,y-`\0$|kkם:4\0\Mj8Pߨ)\\(Y\\0-yVQې&E\0@>,\>\\8\\c\K?\m a\nm3\;sR$\%Զ\*ͭL԰\\\M\q\ua= \^LrE\} ĚXRra]ޱMd~4\D\e-8\\eᲬ6[§q\t\\\TB:)M\aG`t\+w\0ٍ\XCoKˎp$QK\ vY 3\%\K㈲\\\p㌌ᅸ;KVP\\!M*y̫8\u-;99\r\rB}\^xt;^oVZR9\g\',5,1,'','','',''),(18,5,0,'9305bf00c9709025','12966450605bf00c9708ea1748285071','2018-11-17 12:41:59','2018-11-17 12:41:59','','','Profile Photos','person-300.jpg','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\(: +jo6;+\Ya>\U\aZ\\Hbqc1oz \\iق%a\-]\o)s\^\x[-4й\\\;\jT[P\lQ\\--\\v\$\ g3N]c\F|J^f<:Ī\u mYȬƼ1[\Z,En֨ESpRƆ\AKp\"K5\c\\k\{\=z\\eV<\}O^ԋʜ9E\\!\rf*9ʱ\'94nT\+NS?Ƈ!j5X\&\<1)d\-\\\u)_\]lQ91Y\¸K!\\`5R_\\\y\P\Ұ \h{G\\1ԏ P\\\ۦ%<\U0]y?gp)vI Ϭ\\\'\\uY\\"I۔…a\\aYXj[G\ؤ\r\0\B\<\\uoy\j:齟!5\6O\\\D|@+\\c|>ob_j\m\0&\Q\<\E\-AZv\ Ɍԏ.y\\M+z\Ga\\J\&\\Q\'۫\|0\&G(\p\<\s0\\zy|r\Sh]\\n\H|j=[P\`vm\PYESGMM\\\$\h\0S9\\J\LJL}XIQC\$kgD̛\:ռJt\\WUu}A\L\JsC\V\m؂Pb@ |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1>CO$yQ2Ka(#q\:\\O\\9\WPKg\\\ν\C(@l\n1|l߷\0=\x\?\Sg\{\KI˵\\=k\a&\\\S(#x\kY&N\\97m\ZY\ڨuE(@8b\{\٭\\\sݜ\v~\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\&\Z\΍o\\\9A/0\p4\xF|i\\C\\uXm.S4Uq\n\ʟ\rM\%rV\[\\<\o]\ضͮVe\\[\\'\dك\\2<8QF5Ѓ\Z4X1\G_\ss\4OKWv_xڍ3\\~9N\\\B2r\0!t\\D\Eu\+@\0E\0cFF872\\Zہ\e%\\>\ԁq\^cog/\o\+\ɘ\e\dwJwOŷGi{oq5\Ϗc\"rL:\!\ne\B91f\'Ө/O\gdcB{fDM&\\=؆k*e\\~v1\c\\i\\\\)\#[H<|nږVV\i{\}IL\Kf 9\#\򙿍KrԷ\86P\\1\\XW5\\#ᬙ\0\ـDx\\m#>\7.\eDDDDDDDDDDD\\d \2\`&<) cf2琏vp\1\\\8k\9\1M~C\\\ҵ|բ\\9Ug{\\v05\U,SH x&B|\"\\X\\t\l{\\\\_v\ [\;d卮$RH\z\?a!\8 \dmfD\<+n&|\2\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]\\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\dJ>\rms1/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\Z\\J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8R\7$ș>W n\I#\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\Wp\'\\\\\\"\"\"(\c̞94\m\i\"J\_0\\{\rkA\"\{\\jwƯX*\Z[\m3Ɋ\\\\l\ʋc W^V,g_Z\0c}ƒd\b\x\m&)\<# E.OF\:\0\'܇#v\Bp\\{\\5nq\<8g\\DDDDE/Q/jE\׺\l\\}~rY[g0@\\G#֊3O\n0\\8\E\\ϞXӕ\;k/*|lÍm{]\\ZuT;\˹x\h2C26dFfwԝ\\dz\|\?\Sk0$\C\y&\KlbX|\0I]f<|U\t]f\6ڹ{V\\\5,ҩ~\ME\\\dW\C\ J,.\\{;=}Iқ\Z\\ry\HJI0YNJAL\\\p/\UZt+](}\|@ȣB\S\:f{\\\Vtٻ$<>ql\09O\\gAz<V\-i\g\\\2K[-g8\G)3P[\6~+{\U\˪Eę;,\ pans!\\.\f\8\\a\HkaW\7\P\\\BRX\eM,I\C^\q\+\R3X\+X\hdF)}V\M\q(8\\cDW5\ 7\c\c\w|㳬\0e\\ZiY밚9[\\ckZs.u\G9\x\\'JaΦ=\v{Mʙd}v\\أ2L\\V.hjHh\|q#\}2 ^\rޞ\\3j:\\\5KG\0S\2&\.\'\hN \mSfǩ(\5\ljb1FƍL x\ƌ \0F֌A\шCkX\5cp\c \x\\|t{@\\\\xF\x;c\#\\(\\Z9116̋\*}TUc#8*i9w\"\6\1\\b\Kߴk\\\׫\\{:k\\2bs\Vg8i̊SLr\0rtho\\OcN~n\\ԇv\\i\Wg>E\Z4cQ\r`\#Ub\G:4\9t\"\"\"\1E \0\07\3\ \"n^B\X1s\\rcq;8\3Qw\v\\/;ku6\\\c\gZ\~2noŠLZ64Y)g\&.#\\\-c\ߩcJ\皭I\ \Z\\Ut\$\o̸x\(,cB\+\Ǫj֋\i\u%n\u(\"m]t1\q\Ŏ,acw;?\"y{\"\"\"\"\"\"\"\"\"\"\"(J瞵\\"Y\\Ys[\\zs\X\7*\\QHi OiG=n^7{\ɾ8{}\}\dԬG޵܋\\\7\\"w\?̯\\έ fSC6\]Q*s+.َ\'\cKa\)k5\%;-c*.!UsZW\=]XVQ 6\\(\wՌDDZ\Vg\\\gnըu\H.3\\Nk\Zm\\Z\8#_\\7\\D\^lٖS%\\˓>\|͝:i\*dْʗ.Q\ȓ \ys=\1^\{\02\]ӊ>\S2\w\Ճ&ma*qAZKȦMZ\0`Ћ\L\cߚ\"\"\"\"\"\"\"\"\"\"\"\"\0C(&$\"\Z@\aB`BaaF9\ \ܱ\\Z\g\UnpwM{ˣ\'Z\Fr\\T\cck;\\r\L{\\\\n\\b\b\0i&ۘml$+k~N\s\N\r\Pϴ\\ \+Z)3!꼉\\M=t\\'\P͎aͭc\IV\:\\\DE?S\r5\\\&ns=\\t㲽a~9ǴsUF\ϻp\\.\p^\U\G)qv.@\h! fW[\ubx\\"H aAn~7\ñ|\'UWQOQ5Օ\n  X\\b ah5kq\\=\0]9\{\"\"\"\"\"\"\"\"\"\",h\q\*\\8q\ku]ebr\o\$\nw6s%\@\H\"0\Z>@\g\m\n2zη.vl䭐h[XNՁf\e\Y3v.\X\Z얺+s2T\]t\\\K}\Z\c]\nª%v\ݎ=@\nh4䷱\\5X\G9{`t\ I\\'\Ɗs\ܧ\\\\\%r\\jH3ܭFڷ*,\\J\7Q*\<F]\6@\'dcJ(C\ZOXL\1\3\0\>i\g/\\}\h}dF\e}xN\\\K\R>\0r\'X\$bO9Y\\N<`v\\d\[\WH\=dș\\Xg\\o^4!m\7 Gދv\}\\\\y\Z(5gضK9q)`̰85ǛcȈ\r|p\\\tO\\ ,ڲ{\oi\6fXL>@l\\;G79v(\\h.uK\[cOkWT\3̫j\J\L\]I:1\s҉\s{\\\$7\o~E[X!84{m\!aJ\\H\ B!|Xǻ8no\\h{\0Ñ\P֭H\%4)79ÆPc\\v3\]*\[/(\q\\r\nK\UB $Kca-\#Ǝ\"\sD&q jx\{xΉ\Zjó\pM8;#4\jb\b?G\\Ŵ :\y+\lZ:\k;Ȳ$f;lm\\ Ξioc\\&T\ X[8qy)lA!bTh\GW\\"ֹ_\O\~\p\I\\\\Z\-\im\\c1 UTVP&J>Zw½y\\\pU \\yR\\\8\2$g\\J\$\˟l{{\Z\"\"\"\"*}Q-l?݋~\ޱ*[{o}o\\.5f\\2{B\\8;m \ϗ>Ćي\Ɓ_y\W\#\\.\\\\JMP\\hȸy\\E줟8m\\R\5$\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃ\q\n;\\\M^(ѽ\$0eK^m<\󟕎U֨\n\:\A\ZkJ\Z٤}~\ȟCCk\\n5$\մ /O_Kŗ\[\AqO \f\8K/\%\\x昙\~\X\\0\\Y\O1\\r.\W\\\\\0\F\\0ya?%5|\a~q\gITV:5D\0+\F]\"\"\"\"\"ž\t{\׾@\o?j\m},7kuqe)Z)Xp\@ [(\H$2b@\ Um\\0˝S`H\x[m\$\\Zxq\\\$\xo\\@YF3+hq\Ӹy_GּDI\]\=6n=٢F\y8\z.g2W\8qWh֏X\1lʶ\\Z\%}9\_\0w5.\zݴ}s[ko3_\\\n\N\\Ÿ\Z,ʋ&9\QFT\"\"\"\"\"\"\"\".(\Z0\"D`@\0!c\!\!汌k\a\UR\2=\\[gf\lvZ\cF!C\.J\y`_:\:М\$&in\"W\;y\ɧfu>ptC|\nӐ9\n\)ka\[ih\\4-(5p\\\-~ΗWT {ʛz/Ѿ\ם/}ա\\z\l&^ߺ$q\[\\8\eݵM2Iֲ\"[Q3 \\\\\DDDDDDD^a\+Ľ\\Ú\R\?\\\U_j{4K\ZfF %\r\d \Ex\"a(,RJ \0O+\\\\EA\ \}_f\\rv\;TH\y/3S\j\\,ӨgÃ!ulK[\X^|\\0nr\ =jS.m\\H\"Xj܉\mRKk,66E,c`3\d 1d?\\Geb\\6#<\(\j \c~\/Q+ \I\\M20\\Sw_O`\8\?$rg\\\o1\"\!u\s-\\Ytⰻi\Z\\:7\Z\\0̬,\ZQqpo^wY\~4\r{F䚢\r\\Dœ#J\.,X3eM|Ղ%\\;3ƃ1aR#0 6& \ADn2 ˘AØ\-ss79\qȈ)@w絞W9w\\l\B\m_\q[IQ\'9.˵lQ\rE\ ɝ\'\Z\ (A\Ozfv)d\\^f\J\\k\6>h\\"\\t\\Q6f\iZf q\\~\}r\0vOG\5{&^q\r.:)cD\uv\*mk\\l{\J\Z|x\\ݩظ\O\,\mGH\Z.N\\Ipc\W\` G\*6ڑ\2\\ BkWM#ҵ\b\\~;;Y\n)2\0\;4>\u:}4\lb\o\㽭w>\ha\\\@خeN\m;yKnwF\ﱂK:9;1\q8O\5\\2ܷ\Ƿ7\\#r\o[<\r\mu$bs\\VJs\Z7/~qͶ~\kKQ\\%i&\\Qd7\ā܇\o\EJֽ\i\v\\\\\\sK\\\k\4\Ȝ\,D~G)\\vʊ610\\;`/\-w\\\ٽ\C\(\I\-jh\ZvQ\ĉQ\0(UA9lp\07\e\#\\s\\QS\/\\7L:\\\ǒd\f\ c\\X0,5#2Zn&7\;o\9\\V}\\r\\"ޠ\+\W\\K \Aߜ\\\^\0\"\"\"\"\O=2^\S:\lSM\'\\\c\\Dz߾Xǃ9Ǿ}x\\icoqPn\N쎭\'\\p% \xV ԭeNr\g$v.?\0m\5\@!k\y\\\\i\\\8\0# HLyBY\q2\ai\\0ߟv\7\~Y@\уx$;yF\/\'c\!\h\\\L\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\V\0  RDDDD_\Al(\ #ZonZ\Z=\\\\c-ssg\s\L]z\2~q9(# \kc\1ٍ[\lc9SDDDDDDDDDDDDQ`\\e\\]ό\r\p\#|\\0XjB\1fWVx\\\Zܘ֔ 8n2Be2{\\3\X\m\"\"\"\"\"GmC\\\\Zia<\\w.X坲\\!=\c(\\|\01\0\l\"\"\"\"\"\"\"\"\"\"\"\"~\\0\gi\V2\0/<9\\\\\\\E*V[\?\\r#\\ĉ\K\Ӿ\0\hT\v3DDDDZ\\qO.\*\s\6RZCl (\zq`\n>\n\^\dNsƈ\c\\c?PfFh\\\ [(p%\ \\Gtı$30\3?ɇ\_\=8\<,?ڄA\,\r^Q0\?710`xȱ\k\\\*|*fuՕMdx3)w$?g\c1&\Zs\ۂ\\\_\}\DDDDDDDDDDDE\7=F|\\u\re55\\\Cѕ\|ax\\\\ 3\\=ѡQ\kՍ{+hjk\Ga\d\Za{Z\9\\0; n\g8kqc\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/\',4,1,'','','',''),(20,6,0,'9305bf00cb1412b6','19504305895bf00cb14111f611783866','2018-11-17 12:42:25','2018-11-17 12:42:25','','','Profile Photos','person-300.jpg','image/jpeg',80,80,2354,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0+\0\0\0\0\0\0\0\0 \n!#\"AB\\0\0\0?\0\\8\8\8㚑\1V\\7M\PCm;\n\f˲\A\"&\n(\\\z\r+\\*N\1dVq+:}\wN\\z/۝ \\I̮֩\uJ7\ZIA\y\d2m`\^N|HKFow\nRɭc5mö0\~Sr\\\t<Ŏ8\Mi\-2\Cz*\}\\`C\_\qț\\\'0\g(督v\\pQaYV\[=AXFR\\Y\Zp|6RPoSz\\B9d\\\V؛jl.e6Nڔ\"S\\pσ\U{\r<є\\*F@VԘ:WV`b6]hX\\F\G8a\n\\\hNcsѿ7?\Z*GLn.{^\m}kY(IF\s(\#\#Y+{|2&%hZ\OL~\0\:\vڗ-\\E\;=!\0\DZ\vW\^Q\\"\5y\0\Z\G\لJ ]\٥\CT\\ZiR.!-:eZ_>(q԰J\dHqya\0\\T9\\\/ZŹ\潩DO\\"f4D\rR\wDPm5-\\\]P>A\r+\sњK^_\z\A\t\n3V\)\ٚ@.gE0{H\0.`FZP\qi7k\\\=4ąםL\c\\+c\\\\ΩN h\HH\\&\O\r^m\\K1R\\\0z\?W\'m\\r`\ƵD\*\Z.4.\D\)&\Z\"\1yȐ_\2\eǧXv:r\^*d!!ܭ;@\3\=?\'kR\ \Fq\u*\\"ˆ0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬OcaYr\]C\(%g\F6X͟+\\\a0Qԫ$]#cJ4 \HZ&f:\dפ3b\\rs`*\%IZp\nJ%I\2c8\q|\8\?g\qϞ8\~S&S\II(\\rnws\ZQ3q\$L\%r\ZS4\j\0*8}\e92(w\f\r\\\GAW\#\0xؘxxZ2*.86\\d@f[a\\}sm}Bِn\6=\"+o^Vpo9\k;:\84\\*n8j\`d|\һi\.\\v l\rsn\2#җ!E\*2P\gS\!Z^뒠\\*\2p\\v#f\~\\֫\﷤B/j\U)^ĉ,y-`\0$|kkם:4\0\Mj8Pߨ)\\(Y\\0-yVQې&E\0@>,\>\\8\\c\K?\m a\nm3\;sR$\%Զ\*ͭL԰\\\M\q\ua= \^LrE\} ĚXRra]ޱMd~4\D\e-8\\eᲬ6[§q\t\\\TB:)M\aG`t\+w\0ٍ\XCoKˎp$QK\ vY 3\%\K㈲\\\p㌌ᅸ;KVP\\!M*y̫8\u-;99\r\rB}\^xt;^oVZR9\g\',5,1,'','','',''),(21,6,0,'9305bf00cb1412b6','19504305895bf00cb14111f611783866','2018-11-17 12:42:25','2018-11-17 12:42:25','','','Profile Photos','person-300.jpg','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\(: +jo6;+\Ya>\U\aZ\\Hbqc1oz \\iق%a\-]\o)s\^\x[-4й\\\;\jT[P\lQ\\--\\v\$\ g3N]c\F|J^f<:Ī\u mYȬƼ1[\Z,En֨ESpRƆ\AKp\"K5\c\\k\{\=z\\eV<\}O^ԋʜ9E\\!\rf*9ʱ\'94nT\+NS?Ƈ!j5X\&\<1)d\-\\\u)_\]lQ91Y\¸K!\\`5R_\\\y\P\Ұ \h{G\\1ԏ P\\\ۦ%<\U0]y?gp)vI Ϭ\\\'\\uY\\"I۔…a\\aYXj[G\ؤ\r\0\B\<\\uoy\j:齟!5\6O\\\D|@e{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(23,0,9,'9305bf00e7f8a224','53312796025bf00e7f82e82896954942','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(24,0,9,'9305bf00e7f8a224','53312796025bf00e7f82e82896954942','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(25,0,10,'9305bf00e7f924b7','95550517915bf00e7f84cd7979805449','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(26,0,10,'9305bf00e7f924b7','95550517915bf00e7f84cd7979805449','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(27,0,10,'9305bf00e7f924b7','95550517915bf00e7f84cd7979805449','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(28,0,12,'9305bf00e7fb7439','73148550885bf00e7fb1447228602212','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(29,0,12,'9305bf00e7fb7439','73148550885bf00e7fb1447228602212','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(30,0,12,'9305bf00e7fb7439','73148550885bf00e7fb1447228602212','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(31,0,11,'9305bf00e7fb6ff3','11822615065bf00e7fab8ba088168210','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(32,0,11,'9305bf00e7fb6ff3','11822615065bf00e7fab8ba088168210','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(33,0,11,'9305bf00e7fb6ff3','11822615065bf00e7fab8ba088168210','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(34,3,5,'9305bf00e7fdf2d2','15820583805bf00e7fd7909940374120','2018-11-17 12:50:07','2018-11-17 12:50:07','','','Contact Photos','3.jpg','image/jpeg',300,300,11005,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0\0 \n!\"1A#3BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`>V\<|\\r\\Rľ\\vM/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\Ή6\u\xL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@lD<>U\\ \03EW\0\\W%oſm\]\\;\\͋l\\\f]ռ|\&A=1\r\#Å`]1\\E㛑\ؙ|~N\*Yc++*a j\Z?ЋK}$L\Z\\Q\β\0P4`4h#\\0\G\0Y!\шBZk\\nas\",=\|z\\#s/kyqM R]\S\-Hkiu\66\rf\\fGx$\7z\[\vF ^\l{\\\\'$àH\y \^D#\bz\:XdvF4/dD۸\l3\͈f]\\c\>6~\\2ϱ8ke\ɇ\\6\kenqƗˠn\g/1كs\F|\ \\d\ƪ\\ IK\[@\QK\Z\m_)X<}M!>.,bb:=\\\䯻|G\jO[Z;d卮$W\\z\?a!\8 \dmfL\<++n&|\6\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]S\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\J>\rms>)/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\ZX\܍J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8 9R\7\ș>Wn\I#\\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\W\'\\\\\\"\"\"(\c̞94\m\i!ʾ\_0\\{\rkA\"\y\\jwƯX*\Z[\l3Ɋ\\\Il\ʋc W^\Y;\\8j\!`IuMP\8\hh1\y,G-n\Vͤmr(7`\vjYyeS\:9 ȯ&!Y ]\vz7\ZđyL>^\~M\\b\aWa`o V}z`6%\@_֫\`n\V>Q7;\eF\c\t\z\vHn%By)Bh`ϯ~\0: \\)\M\gk?J\\rŒZ\\kSv\!}\\]\`ٶ.6\m<8w=<\\\\w\ap{xT\N[F¦\"E,\d\\wV}=%\|w;4\us]U#\ua#XBΑ;#\\1uhB`;xMSZ\uM;N\}n5EDaî<ᕷuhO\\Myy῵|Ʌ[\\\||;9\-\X_q\\QG6s\\0˱*|נq=v\\|i\\E$ꝋ4\+HB)Y\m|أ0^3#H(Ae\v0\_ \U\|}\\B\ruefƒ`4zZ&c\rfZ\61\\0,9vsDDDDDDDDDDDX\\>\u:U\0\p\$\\7^\[\\\H\>l\K+\Zؑ*D`5|aс\Ϭ۠\e(o\\\\\\[ ж;ͬ\cIJf\D]\y<5\-tV\9 d;n\\\\=B5ƺTKZ\O߻z^\i\oc[[\j\\\ݛ`bO)?4S6\8>^g~+v~P\A\\l5&տ\Qd\>\ZViŔyVq\\\"4b\<#\ZQB\|\\e\0iY\HOk9z\\\rͅGǴ\\"6S+\\\ṷv2_(5:\#|\\\x>\q\SV<\%RޢF1\;&D\\\\"\?\{-\\DhB\\nu\Z;\\gٷ\40Pjϱmvr\RsO&=n!WGai(qk)6Ǒ\Z\\n+Y\\)iekp\\SmA̰|\2)#5\\vnr\Qհ\\\\\]&`֮,/f3WsO4\֕ҙ \nlc\;0\a\]H:o)\fU\\r7d+FC8ph:\9oB”`\"\B?8vp\\\\\/-\"\"֡[B\'a\\hj5MR\'IR\!2%F\x\uq-k\/\请_D \\v\n.&3\ZH0\UCedz\\g\\+מ \~]p\iQ\0ǚ.^(\0c#*F|\\IO9\\\l\q숈Gx\\0v-ǚĨm\\S` ՚\3L\\ Ps \\bm>df+G\Z|Q\/\_L{h\#u)6yCp_\"\\i\\\_\Z^|\~e\4\eK\1䝵\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃq\n;\\\M(\$\ \nVT\\\9X\[j\\:\\lJJ8\5\O&.JǨ\\ȍo\'Ģ#0\o\A\nK:v\Ym7[\n\C@\ͪ,\57\ȉY\\5~(\?\\\6]\([\r\\\\\\\\+h\r>K`NJܖDlF5\\C\aշ]7x\=\'m\w\n\9}}UAcߌ\tr\\\]$\s8\}:\"\"\"\"\"\";\~z3\\\݅\\\0\V\qJ\lڎ>\*\ð\\d\rM)Hz}\\\\T%EEe\r\re\\݄*jjRlm\lueet1\Zd E dʒQ\0y^\\\?O*],\c\525\pkyڢE$s\|ɚW6Id\m&C> bZ޲\\Us\HwȈÖO\Rsoh3H\c8\r\|gʇ\B޶P\Kۤf\\{ͽl7R\mԑ\+3\XI+\\kܽ\\6\ciu/\G l#n\DX}\\7\m(jA L\j\\28\0Dy$E\r\\4Qx\\;\\\Yrwl\6O׭\NMH\?Y\\Zo\Z\q,$Fq+\s\EPr_&tS\\g\nf\T;\0\UΨ\u E-s\r[d\\E&\G _.\4ќ\Wst?\\\]=J\\g1qV\\`\\"`k\i\SG: R6>2ƌFr\\"\"\"\"\\.C\/\ȕn\:\\6Uro鿍93Z\r9\? ~[g?\\/|9\S$>7\"r\m\*( \\\H\\ vXL\{{f L\\'SеqPi\\FS$aD\0TA \\\V\/\/)\s\ڈQO)a\{nqT\\0Cfd \\\u<\[8 a\\ףw1x\x1\7Gb{\>[\X\\"__ &\\vZtQ|\\r\\kbm:\ϧ>\=lMq\.`ݙM \"\\\\/\X\4\0\o-\\ŝ˘eL\u}Of\\\#~\3u$i\+X瀱\a\\6p>4֨;9ҾR\06H\P׶[Xem9\\iI\Zc#@kI[.n6I\iqҾ\-\\0\\RoV>7\<\=\\\\kH{v\wdui?ۄ(Nkǂk,bp\81#Yw\So<\O ț_+ϒ\\r\dp\\W{Ng7\0ZBe\\9!\\\H\\k\\\ \~{PW2k?~p\+\\\\|>M$\\'\\0Pc#˳>]n\rO\4 ǗvGwc$bh\`j\]zF1\0\"1\˜\\\1keˆ\w\ns e\l\-\s}XčJ\9\}0\0|{c\\\\URO4mevX6H\a\|q5R \Z\\\\\rvKs\9ضBc\\[_`Wj\W\}/돷\\;\>\0\\\H~!iФ` \s\4\j_\85z\D\p<\\]\\\"\~5kDDDDEu\Z9\VU5\\ئ6\\H~ϲ\c0L4\\7\'7\\\"\"\"\"\"\"\"\"\"\"\".\4\\sl)b\ #\ {\V?`Y|\\q\hi\t\c^\\\Z\j\y`\k\ֱ~\0\\\[;\~\Ȉ\',4,0,'','','',''),(35,2,4,'9305bf00e7fdc853','12287146285bf00e7fd3b7e500413187','2018-11-17 12:50:07','2018-11-17 12:50:07','','','Contact Photos','2.jpg','image/jpeg',300,300,11005,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0\0 \n!\"1A#3BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`>V\<|\\r\\Rľ\\vM/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\Ή6\u\xL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@lD<>U\\ \03EW\0\\W%oſm\]\\;\\͋l\\\f]ռ|\&A=1\r\#Å`]1\\E㛑\ؙ|~N\*Yc++*a j\Z?ЋK}$L\Z\\Q\β\0P4`4h#\\0\G\0Y!\шBZk\\nas\",=\|z\\#s/kyqM R]\S\-Hkiu\66\rf\\fGx$\7z\[\vF ^\l{\\\\'$àH\y \^D#\bz\:XdvF4/dD۸\l3\͈f]\\c\>6~\\2ϱ8ke\ɇ\\6\kenqƗˠn\g/1كs\F|\ \\d\ƪ\\ IK\[@\QK\Z\m_)X<}M!>.,bb:=\\\䯻|G\jO[Z;d卮$W\\z\?a!\8 \dmfL\<++n&|\6\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]S\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\J>\rms>)/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\ZX\܍J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8 9R\7\ș>Wn\I#\\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\W\'\\\\\\"\"\"(\c̞94\m\i!ʾ\_0\\{\rkA\"\y\\jwƯX*\Z[\l3Ɋ\\\Il\ʋc W^\Y;\\8j\!`IuMP\8\hh1\y,G-n\Vͤmr(7`\vjYyeS\:9 ȯ&!Y ]\vz7\ZđyL>^\~M\\b\aWa`o V}z`6%\@_֫\`n\V>Q7;\eF\c\t\z\vHn%By)Bh`ϯ~\0: \\)\M\gk?J\\rŒZ\\kSv\!}\\]\`ٶ.6\m<8w=<\\\\w\ap{xT\N[F¦\"E,\d\\wV}=%\|w;4\us]U#\ua#XBΑ;#\\1uhB`;xMSZ\uM;N\}n5EDaî<ᕷuhO\\Myy῵|Ʌ[\\\||;9\-\X_q\\QG6s\\0˱*|נq=v\\|i\\E$ꝋ4\+HB)Y\m|أ0^3#H(Ae\v0\_ \U\|}\\B\ruefƒ`4zZ&c\rfZ\61\\0,9vsDDDDDDDDDDDX\\>\u:U\0\p\$\\7^\[\\\H\>l\K+\Zؑ*D`5|aс\Ϭ۠\e(o\\\\\\[ ж;ͬ\cIJf\D]\y<5\-tV\9 d;n\\\\=B5ƺTKZ\O߻z^\i\oc[[\j\\\ݛ`bO)?4S6\8>^g~+v~P\A\\l5&տ\Qd\>\ZViŔyVq\\\"4b\<#\ZQB\|\\e\0iY\HOk9z\\\rͅGǴ\\"6S+\\\ṷv2_(5:\#|\\\x>\q\SV<\%RޢF1\;&D\\\\"\?\{-\\DhB\\nu\Z;\\gٷ\40Pjϱmvr\RsO&=n!WGai(qk)6Ǒ\Z\\n+Y\\)iekp\\SmA̰|\2)#5\\vnr\Qհ\\\\\]&`֮,/f3WsO4\֕ҙ \nlc\;0\a\]H:o)\fU\\r7d+FC8ph:\9oB”`\"\B?8vp\\\\\/-\"\"֡[B\'a\\hj5MR\'IR\!2%F\x\uq-k\/\请_D \\v\n.&3\ZH0\UCedz\\g\\+מ \~]p\iQ\0ǚ.^(\0c#*F|\\IO9\\\l\q숈Gx\\0v-ǚĨm\\S` ՚\3L\\ Ps \\bm>df+G\Z|Q\/\_L{h\#u)6yCp_\"\\i\\\_\Z^|\~e\4\eK\1䝵\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃq\n;\\\M(\$\ \nVT\\\9X\[j\\:\\lJJ8\5\O&.JǨ\\ȍo\'Ģ#0\o\A\nK:v\Ym7[\n\C@\ͪ,\57\ȉY\\5~(\?\\\6]\([\r\\\\\\\\+h\r>K`NJܖDlF5\\C\aշ]7x\=\'m\w\n\9}}UAcߌ\tr\\\]$\s8\}:\"\"\"\"\"\";\~z3\\\݅\\\0\V\qJ\lڎ>\*\ð\\d\rM)Hz}\\\\T%EEe\r\re\\݄*jjRlm\lueet1\Zd E dʒQ\0y^\\\?O*],\c\525\pkyڢE$s\|ɚW6Id\m&C> bZ޲\\Us\HwȈÖO\Rsoh3H\c8\r\|gʇ\B޶P\Kۤf\\{ͽl7R\mԑ\+3\XI+\\kܽ\\6\ciu/\G l#n\DX}\\7\m(jA L\j\\28\0Dy$E\r\\4Qx\\;\\\Yrwl\6O׭\NMH\?Y\\Zo\Z\q,$Fq+\s\EPr_&tS\\g\nf\T;\0\UΨ\u E-s\r[d\\E&\G _.\4ќ\Wst?\\\]=J\\g1qV\\`\\"`k\i\SG: R6>2ƌFr\\"\"\"\"\\.C\/\ȕn\:\\6Uro鿍93Z\r9\? ~[g?\\/|9\S$>7\"r\m\*( \\\H\\ vXL\{{f L\\'SеqPi\\FS$aD\0TA \\\V\/\/)\s\ڈQO)a\{nqT\\0Cfd \\\u<\[8 a\\ףw1x\x1\7Gb{\>[\X\\"__ &\\vZtQ|\\r\\kbm:\ϧ>\=lMq\.`ݙM \"\\\\/\X\4\0\o-\\ŝ˘eL\u}Of\\\#~\3u$i\+X瀱\a\\6p>4֨;9ҾR\06H\P׶[Xem9\\iI\Zc#@kI[.n6I\iqҾ\-\\0\\RoV>7\<\=\\\\kH{v\wdui?ۄ(Nkǂk,bp\81#Yw\So<\O ț_+ϒ\\r\dp\\W{Ng7\0ZBe\\9!\\\H\\k\\\ \~{PW2k?~p\+\\\\|>M$\\'\\0Pc#˳>]n\rO\4 ǗvGwc$bh\`j\]zF1\0\"1\˜\\\1keˆ\w\ns e\l\-\s}XčJ\9\}0\0|{c\\\\URO4mevX6H\a\|q5R \Z\\\\\rvKs\9ضBc\\[_`Wj\W\}/돷\\;\>\0\\\H~!iФ` \s\4\j_\85z\D\p<\\]\\\"\~5kDDDDEu\Z9\VU5\\ئ6\\H~ϲ\c0L4\\7\'7\\\"\"\"\"\"\"\"\"\"\"\".\4\\sl)b\ #\ {\V?`Y|\\q\hi\t\c^\\\Z\j\y`\k\ֱ~\0\\\[;\~\Ȉ\',4,0,'','','',''),(36,3,5,'9305bf00e7fdf2d2','15820583805bf00e7fd7909940374120','2018-11-17 12:50:07','2018-11-17 12:50:07','','','Contact Photos','3.jpg','image/jpeg',80,80,2355,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0+\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\9ZM\*x\\.d% \r1!hu\GS6J\\9jdžBÏ-_e9+\r 0p\m<\\Ƌ\?Jt>\l\\ϭu1y[U:^R\0rT Ƕ>,G6\\9\ \\\oCN0!..-ӭ/\nC)!i\V\*\q㕈w{\C\Jg]U%,}$\\p\\I׉\\5Wț$DEWI.f\"\\A\պ\0t\KDu\Y\\Z%ލjR\ \n\ 24\\J 0f0L<\#N\Ҏv@F 37\\WKLE҂C2IH\5)]i\8>*ƌޖ~\c\B\\m]cȢ~ڔ\rvb?N\Z \\\\S 6a\0\0p|;8\s:S7`3x\52\,\y\)\\r \\ɻ$\DW\tiR\ fԷ=\\0B\\mg/Ň\{\Z\\P^\Z\|\!v\Z&I\^\n\X<\0f\\,,zq+u\c.\\A2\\;\\ZjI\r#;Sؓz\,`ͤgR2/\QeN-鵍u\U [R\ڽb:XبFm C2;- B1xo:\V\\0Ww\W?[\ڪ\N\\\XbA:e]X3㭶1\4\\՞i\\\V[\>$\.kI\ LɻyܪaP\\8R\\0t %WY/O\ZO\\0\Do- \a\\ ^-\,R\\\u\!Y \tu\\U5t\\\B[Q\\\\'SY\"P\\n\>VQ\ | #x˥\4\qm\Z\n\5\#g-ӞW]ۦZjybN-\r53c-{{o\+\D\\\P*#T-L\\ZCM\,fjY@\g\\LnF\#N\W\\-8SHY\\t\I\N,\ϳ\\S\^\+w~Ak>\m\l}\h\ (MN\4,^ J1(\2\b>\\\{(U՚ۭ)jdvvӢ\\ڞT\Rw\\ƌת1\\\3Z^F\¶\r\$㦣c\a\R&X$\\\\h$ca8\"\Z\$B\u!q6!X\gO\rv\\]ZA/3{^PQ[ˎ_\\4%5\\\0%_rLe\rC\^2\ 8c 0\\\\#\\,\16\M(m \\' N1c{xyՠ/5[-&\\Z\_\!dGlx\\"\"\䭒><%R\eն\*\et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\9ZM\*x\\.d% \r1!hu\GS6J\\9jdžBÏ-_e9+\r 0p\m<\\Ƌ\?Jt>\l\\ϭu1y[U:^R\0rT Ƕ>,G6\\9\ \\\oCN0!..-ӭ/\nC)!i\V\*\q㕈w{\C\Jg]U%,}$\\p\\I׉\\5Wț$DEWI.f\"\\A\պ\0t\KDu\Y\\Z%ލjR\ \n\ 24\\J 0f0L<\#N\Ҏv@F 37\\WKLE҂C2IH\5)]i\8>*ƌޖ~\c\B\\m]cȢ~ڔ\rvb?N\Z \\\\S 6a\0\0p|;8\s:S7`3x\52\,\y\)\\r \\ɻ$\DW\tiR\ fԷ=\\0B\\mg/Ň\{\Z\\P^\Z\|\!v\Z&I\^\n\X<\0f\\,,zq+u\c.\\A2\\;\\ZjI\r#;Sؓz\,`ͤgR2/\QeN-鵍u\U [R\ڽb:XبFm C2;- B1xo:\V\\0Ww\W?[\ڪ\N\\\XbA:e]X3㭶1\4\\՞i\\\V[\>$\.kI\ LɻyܪaP\\8R\\0t %WY/O\ZO\\0\Do- \a\\ ^-\,R\\\u\!Y \tu\\U5t\\\B[Q\\\\'SY\"P\\n\>VQ\ | #x˥\4\qm\Z\n\5\#g-ӞW]ۦZjybN-\r53c-{{o\+\D\\\P*#T-L\\ZCM\,fjY@\g\\LnF\#N\W\\-8SHY\\t\I\N,\ϳ\\S\^\+w~Ak>\m\l}\h\ (MN\4,^ J1(\2\b>\\\{(U՚ۭ)jdvvӢ\\ڞT\Rw\\ƌת1\\\3Z^F\¶\r\$㦣c\a\R&X$\\\\h$ca8\"\Z\$B\u!q6!X\gO\rv\\]ZA/3{^PQ[ˎ_\\4%5\\\0%_rLe\rC\^2\ 8c 0\\\\#\\,\16\M(m \\' N1c{xyՠ/5[-&\\Z\_\!dGlx\\"\"\䭒><%R\eն\*\V\<|\\r\\Rľ\\vM/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\Ή6\u\xL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@lD<>U\\ \03EW\0\\W%oſm\]\\;\\͋l\\\f]ռ|\&A=1\r\#Å`]1\\E㛑\ؙ|~N\*Yc++*a j\Z?ЋK}$L\Z\\Q\β\0P4`4h#\\0\G\0Y!\шBZk\\nas\",=\|z\\#s/kyqM R]\S\-Hkiu\66\rf\\fGx$\7z\[\vF ^\l{\\\\'$àH\y \^D#\bz\:XdvF4/dD۸\l3\͈f]\\c\>6~\\2ϱ8ke\ɇ\\6\kenqƗˠn\g/1كs\F|\ \\d\ƪ\\ IK\[@\QK\Z\m_)X<}M!>.,bb:=\\\䯻|G\jO[Z;d卮$W\\z\?a!\8 \dmfL\<++n&|\6\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]S\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\J>\rms>)/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\ZX\܍J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8 9R\7\ș>Wn\I#\\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\W\'\\\\\\"\"\"(\c̞94\m\i!ʾ\_0\\{\rkA\"\y\\jwƯX*\Z[\l3Ɋ\\\Il\ʋc W^\Y;\\8j\!`IuMP\8\hh1\y,G-n\Vͤmr(7`\vjYyeS\:9 ȯ&!Y ]\vz7\ZđyL>^\~M\\b\aWa`o V}z`6%\@_֫\`n\V>Q7;\eF\c\t\z\vHn%By)Bh`ϯ~\0: \\)\M\gk?J\\rŒZ\\kSv\!}\\]\`ٶ.6\m<8w=<\\\\w\ap{xT\N[F¦\"E,\d\\wV}=%\|w;4\us]U#\ua#XBΑ;#\\1uhB`;xMSZ\uM;N\}n5EDaî<ᕷuhO\\Myy῵|Ʌ[\\\||;9\-\X_q\\QG6s\\0˱*|נq=v\\|i\\E$ꝋ4\+HB)Y\m|أ0^3#H(Ae\v0\_ \U\|}\\B\ruefƒ`4zZ&c\rfZ\61\\0,9vsDDDDDDDDDDDX\\>\u:U\0\p\$\\7^\[\\\H\>l\K+\Zؑ*D`5|aс\Ϭ۠\e(o\\\\\\[ ж;ͬ\cIJf\D]\y<5\-tV\9 d;n\\\\=B5ƺTKZ\O߻z^\i\oc[[\j\\\ݛ`bO)?4S6\8>^g~+v~P\A\\l5&տ\Qd\>\ZViŔyVq\\\"4b\<#\ZQB\|\\e\0iY\HOk9z\\\rͅGǴ\\"6S+\\\ṷv2_(5:\#|\\\x>\q\SV<\%RޢF1\;&D\\\\"\?\{-\\DhB\\nu\Z;\\gٷ\40Pjϱmvr\RsO&=n!WGai(qk)6Ǒ\Z\\n+Y\\)iekp\\SmA̰|\2)#5\\vnr\Qհ\\\\\]&`֮,/f3WsO4\֕ҙ \nlc\;0\a\]H:o)\fU\\r7d+FC8ph:\9oB”`\"\B?8vp\\\\\/-\"\"֡[B\'a\\hj5MR\'IR\!2%F\x\uq-k\/\请_D \\v\n.&3\ZH0\UCedz\\g\\+מ \~]p\iQ\0ǚ.^(\0c#*F|\\IO9\\\l\q숈Gx\\0v-ǚĨm\\S` ՚\3L\\ Ps \\bm>df+G\Z|Q\/\_L{h\#u)6yCp_\"\\i\\\_\Z^|\~e\4\eK\1䝵\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃq\n;\\\M(\$\ \nVT\\\9X\[j\\:\\lJJ8\5\O&.JǨ\\ȍo\'Ģ#0\o\A\nK:v\Ym7[\n\C@\ͪ,\57\ȉY\\5~(\?\\\6]\([\r\\\\\\\\+h\r>K`NJܖDlF5\\C\aշ]7x\=\'m\w\n\9}}UAcߌ\tr\\\]$\s8\}:\"\"\"\"\"\";\~z3\\\݅\\\0\V\qJ\lڎ>\*\ð\\d\rM)Hz}\\\\T%EEe\r\re\\݄*jjRlm\lueet1\Zd E dʒQ\0y^\\\?O*],\c\525\pkyڢE$s\|ɚW6Id\m&C> bZ޲\\Us\HwȈÖO\Rsoh3H\c8\r\|gʇ\B޶P\Kۤf\\{ͽl7R\mԑ\+3\XI+\\kܽ\\6\ciu/\G l#n\DX}\\7\m(jA L\j\\28\0Dy$E\r\\4Qx\\;\\\Yrwl\6O׭\NMH\?Y\\Zo\Z\q,$Fq+\s\EPr_&tS\\g\nf\T;\0\UΨ\u E-s\r[d\\E&\G _.\4ќ\Wst?\\\]=J\\g1qV\\`\\"`k\i\SG: R6>2ƌFr\\"\"\"\"\\.C\/\ȕn\:\\6Uro鿍93Z\r9\? ~[g?\\/|9\S$>7\"r\m\*( \\\H\\ vXL\{{f L\\'SеqPi\\FS$aD\0TA \\\V\/\/)\s\ڈQO)a\{nqT\\0Cfd \\\u<\[8 a\\ףw1x\x1\7Gb{\>[\X\\"__ &\\vZtQ|\\r\\kbm:\ϧ>\=lMq\.`ݙM \"\\\\/\X\4\0\o-\\ŝ˘eL\u}Of\\\#~\3u$i\+X瀱\a\\6p>4֨;9ҾR\06H\P׶[Xem9\\iI\Zc#@kI[.n6I\iqҾ\-\\0\\RoV>7\<\=\\\\kH{v\wdui?ۄ(Nkǂk,bp\81#Yw\So<\O ț_+ϒ\\r\dp\\W{Ng7\0ZBe\\9!\\\H\\k\\\ \~{PW2k?~p\+\\\\|>M$\\'\\0Pc#˳>]n\rO\4 ǗvGwc$bh\`j\]zF1\0\"1\˜\\\1keˆ\w\ns e\l\-\s}XčJ\9\}0\0|{c\\\\URO4mevX6H\a\|q5R \Z\\\\\rvKs\9ضBc\\[_`Wj\W\}/돷\\;\>\0\\\H~!iФ` \s\4\j_\85z\D\p<\\]\\\"\~5kDDDDEu\Z9\VU5\\ئ6\\H~ϲ\c0L4\\7\'7\\\"\"\"\"\"\"\"\"\"\"\".\4\\sl)b\ #\ {\V?`Y|\\q\hi\t\c^\\\Z\j\y`\k\ֱ~\0\\\[;\~\Ȉ\',4,0,'','','',''),(41,4,6,'9305bf00e8022c7a','12242958235bf00e8006f72987476580','2018-11-17 12:50:08','2018-11-17 12:50:08','','','Contact Photos','4.jpg','image/jpeg',80,80,2355,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0+\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\9ZM\*x\\.d% \r1!hu\GS6J\\9jdžBÏ-_e9+\r 0p\m<\\Ƌ\?Jt>\l\\ϭu1y[U:^R\0rT Ƕ>,G6\\9\ \\\oCN0!..-ӭ/\nC)!i\V\*\q㕈w{\C\Jg]U%,}$\\p\\I׉\\5Wț$DEWI.f\"\\A\պ\0t\KDu\Y\\Z%ލjR\ \n\ 24\\J 0f0L<\#N\Ҏv@F 37\\WKLE҂C2IH\5)]i\8>*ƌޖ~\c\B\\m]cȢ~ڔ\rvb?N\Z \\\\S 6a\0\0p|;8\s:S7`3x\52\,\y\)\\r \\ɻ$\DW\tiR\ fԷ=\\0B\\mg/Ň\{\Z\\P^\Z\|\!v\Z&I\^\n\X<\0f\\,,zq+u\c.\\A2\\;\\ZjI\r#;Sؓz\,`ͤgR2/\QeN-鵍u\U [R\ڽb:XبFm C2;- B1xo:\V\\0Ww\W?[\ڪ\N\\\XbA:e]X3㭶1\4\\՞i\\\V[\>$\.kI\ LɻyܪaP\\8R\\0t %WY/O\ZO\\0\Do- \a\\ ^-\,R\\\u\!Y \tu\\U5t\\\B[Q\\\\'SY\"P\\n\>VQ\ | #x˥\4\qm\Z\n\5\#g-ӞW]ۦZjybN-\r53c-{{o\+\D\\\P*#T-L\\ZCM\,fjY@\g\\LnF\#N\W\\-8SHY\\t\I\N,\ϳ\\S\^\+w~Ak>\m\l}\h\ (MN\4,^ J1(\2\b>\\\{(U՚ۭ)jdvvӢ\\ڞT\Rw\\ƌת1\\\3Z^F\¶\r\$㦣c\a\R&X$\\\\h$ca8\"\Z\$B\u!q6!X\gO\rv\\]ZA/3{^PQ[ˎ_\\4%5\\\0%_rLe\rC\^2\ 8c 0\\\\#\\,\16\M(m \\' N1c{xyՠ/5[-&\\Z\_\!dGlx\\"\"\䭒><%R\eն\*\V\<|\\r\\Rľ\\vM/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\Ή6\u\xL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@lD<>U\\ \03EW\0\\W%oſm\]\\;\\͋l\\\f]ռ|\&A=1\r\#Å`]1\\E㛑\ؙ|~N\*Yc++*a j\Z?ЋK}$L\Z\\Q\β\0P4`4h#\\0\G\0Y!\шBZk\\nas\",=\|z\\#s/kyqM R]\S\-Hkiu\66\rf\\fGx$\7z\[\vF ^\l{\\\\'$àH\y \^D#\bz\:XdvF4/dD۸\l3\͈f]\\c\>6~\\2ϱ8ke\ɇ\\6\kenqƗˠn\g/1كs\F|\ \\d\ƪ\\ IK\[@\QK\Z\m_)X<}M!>.,bb:=\\\䯻|G\jO[Z;d卮$W\\z\?a!\8 \dmfL\<++n&|\6\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]S\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\J>\rms>)/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\ZX\܍J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8 9R\7\ș>Wn\I#\\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\W\'\\\\\\"\"\"(\c̞94\m\i!ʾ\_0\\{\rkA\"\y\\jwƯX*\Z[\l3Ɋ\\\Il\ʋc W^\Y;\\8j\!`IuMP\8\hh1\y,G-n\Vͤmr(7`\vjYyeS\:9 ȯ&!Y ]\vz7\ZđyL>^\~M\\b\aWa`o V}z`6%\@_֫\`n\V>Q7;\eF\c\t\z\vHn%By)Bh`ϯ~\0: \\)\M\gk?J\\rŒZ\\kSv\!}\\]\`ٶ.6\m<8w=<\\\\w\ap{xT\N[F¦\"E,\d\\wV}=%\|w;4\us]U#\ua#XBΑ;#\\1uhB`;xMSZ\uM;N\}n5EDaî<ᕷuhO\\Myy῵|Ʌ[\\\||;9\-\X_q\\QG6s\\0˱*|נq=v\\|i\\E$ꝋ4\+HB)Y\m|أ0^3#H(Ae\v0\_ \U\|}\\B\ruefƒ`4zZ&c\rfZ\61\\0,9vsDDDDDDDDDDDX\\>\u:U\0\p\$\\7^\[\\\H\>l\K+\Zؑ*D`5|aс\Ϭ۠\e(o\\\\\\[ ж;ͬ\cIJf\D]\y<5\-tV\9 d;n\\\\=B5ƺTKZ\O߻z^\i\oc[[\j\\\ݛ`bO)?4S6\8>^g~+v~P\A\\l5&տ\Qd\>\ZViŔyVq\\\"4b\<#\ZQB\|\\e\0iY\HOk9z\\\rͅGǴ\\"6S+\\\ṷv2_(5:\#|\\\x>\q\SV<\%RޢF1\;&D\\\\"\?\{-\\DhB\\nu\Z;\\gٷ\40Pjϱmvr\RsO&=n!WGai(qk)6Ǒ\Z\\n+Y\\)iekp\\SmA̰|\2)#5\\vnr\Qհ\\\\\]&`֮,/f3WsO4\֕ҙ \nlc\;0\a\]H:o)\fU\\r7d+FC8ph:\9oB”`\"\B?8vp\\\\\/-\"\"֡[B\'a\\hj5MR\'IR\!2%F\x\uq-k\/\请_D \\v\n.&3\ZH0\UCedz\\g\\+מ \~]p\iQ\0ǚ.^(\0c#*F|\\IO9\\\l\q숈Gx\\0v-ǚĨm\\S` ՚\3L\\ Ps \\bm>df+G\Z|Q\/\_L{h\#u)6yCp_\"\\i\\\_\Z^|\~e\4\eK\1䝵\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃq\n;\\\M(\$\ \nVT\\\9X\[j\\:\\lJJ8\5\O&.JǨ\\ȍo\'Ģ#0\o\A\nK:v\Ym7[\n\C@\ͪ,\57\ȉY\\5~(\?\\\6]\([\r\\\\\\\\+h\r>K`NJܖDlF5\\C\aշ]7x\=\'m\w\n\9}}UAcߌ\tr\\\]$\s8\}:\"\"\"\"\"\";\~z3\\\݅\\\0\V\qJ\lڎ>\*\ð\\d\rM)Hz}\\\\T%EEe\r\re\\݄*jjRlm\lueet1\Zd E dʒQ\0y^\\\?O*],\c\525\pkyڢE$s\|ɚW6Id\m&C> bZ޲\\Us\HwȈÖO\Rsoh3H\c8\r\|gʇ\B޶P\Kۤf\\{ͽl7R\mԑ\+3\XI+\\kܽ\\6\ciu/\G l#n\DX}\\7\m(jA L\j\\28\0Dy$E\r\\4Qx\\;\\\Yrwl\6O׭\NMH\?Y\\Zo\Z\q,$Fq+\s\EPr_&tS\\g\nf\T;\0\UΨ\u E-s\r[d\\E&\G _.\4ќ\Wst?\\\]=J\\g1qV\\`\\"`k\i\SG: R6>2ƌFr\\"\"\"\"\\.C\/\ȕn\:\\6Uro鿍93Z\r9\? ~[g?\\/|9\S$>7\"r\m\*( \\\H\\ vXL\{{f L\\'SеqPi\\FS$aD\0TA \\\V\/\/)\s\ڈQO)a\{nqT\\0Cfd \\\u<\[8 a\\ףw1x\x1\7Gb{\>[\X\\"__ &\\vZtQ|\\r\\kbm:\ϧ>\=lMq\.`ݙM \"\\\\/\X\4\0\o-\\ŝ˘eL\u}Of\\\#~\3u$i\+X瀱\a\\6p>4֨;9ҾR\06H\P׶[Xem9\\iI\Zc#@kI[.n6I\iqҾ\-\\0\\RoV>7\<\=\\\\kH{v\wdui?ۄ(Nkǂk,bp\81#Yw\So<\O ț_+ϒ\\r\dp\\W{Ng7\0ZBe\\9!\\\H\\k\\\ \~{PW2k?~p\+\\\\|>M$\\'\\0Pc#˳>]n\rO\4 ǗvGwc$bh\`j\]zF1\0\"1\˜\\\1keˆ\w\ns e\l\-\s}XčJ\9\}0\0|{c\\\\URO4mevX6H\a\|q5R \Z\\\\\rvKs\9ضBc\\[_`Wj\W\}/돷\\;\>\0\\\H~!iФ` \s\4\j_\85z\D\p<\\]\\\"\~5kDDDDEu\Z9\VU5\\ئ6\\H~ϲ\c0L4\\7\'7\\\"\"\"\"\"\"\"\"\"\"\".\4\\sl)b\ #\ {\V?`Y|\\q\hi\t\c^\\\Z\j\y`\k\ֱ~\0\\\[;\~\Ȉ\',4,0,'','','',''),(44,5,7,'9305bf00e803570d','97971161215bf00e801fdd7464746900','2018-11-17 12:50:08','2018-11-17 12:50:08','','','Contact Photos','5.jpg','image/jpeg',80,80,2355,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0+\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\9ZM\*x\\.d% \r1!hu\GS6J\\9jdžBÏ-_e9+\r 0p\m<\\Ƌ\?Jt>\l\\ϭu1y[U:^R\0rT Ƕ>,G6\\9\ \\\oCN0!..-ӭ/\nC)!i\V\*\q㕈w{\C\Jg]U%,}$\\p\\I׉\\5Wț$DEWI.f\"\\A\պ\0t\KDu\Y\\Z%ލjR\ \n\ 24\\J 0f0L<\#N\Ҏv@F 37\\WKLE҂C2IH\5)]i\8>*ƌޖ~\c\B\\m]cȢ~ڔ\rvb?N\Z \\\\S 6a\0\0p|;8\s:S7`3x\52\,\y\)\\r \\ɻ$\DW\tiR\ fԷ=\\0B\\mg/Ň\{\Z\\P^\Z\|\!v\Z&I\^\n\X<\0f\\,,zq+u\c.\\A2\\;\\ZjI\r#;Sؓz\,`ͤgR2/\QeN-鵍u\U [R\ڽb:XبFm C2;- B1xo:\V\\0Ww\W?[\ڪ\N\\\XbA:e]X3㭶1\4\\՞i\\\V[\>$\.kI\ LɻyܪaP\\8R\\0t %WY/O\ZO\\0\Do- \a\\ ^-\,R\\\u\!Y \tu\\U5t\\\B[Q\\\\'SY\"P\\n\>VQ\ | #x˥\4\qm\Z\n\5\#g-ӞW]ۦZjybN-\r53c-{{o\+\D\\\P*#T-L\\ZCM\,fjY@\g\\LnF\#N\W\\-8SHY\\t\I\N,\ϳ\\S\^\+w~Ak>\m\l}\h\ (MN\4,^ J1(\2\b>\\\{(U՚ۭ)jdvvӢ\\ڞT\Rw\\ƌת1\\\3Z^F\¶\r\$㦣c\a\R&X$\\\\h$ca8\"\Z\$B\u!q6!X\gO\rv\\]ZA/3{^PQ[ˎ_\\4%5\\\0%_rLe\rC\^2\ 8c 0\\\\#\\,\16\M(m \\' N1c{xyՠ/5[-&\\Z\_\!dGlx\\"\"\䭒><%R\eն\*\e{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(47,0,13,'9305bf00e80b8132','14369303895bf00e80b1e28207310424','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(48,0,13,'9305bf00e80b8132','14369303895bf00e80b1e28207310424','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(49,6,8,'9305bf00e80e68a1','63436871415bf00e80dfe9c308856934','2018-11-17 12:50:08','2018-11-17 12:50:08','','','Contact Photos','6.jpg','image/jpeg',300,300,11005,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0\0 \n!\"1A#3BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`>V\<|\\r\\Rľ\\vM/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\Ή6\u\xL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)AT\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@lD<>U\\ \03EW\0\\W%oſm\]\\;\\͋l\\\f]ռ|\&A=1\r\#Å`]1\\E㛑\ؙ|~N\*Yc++*a j\Z?ЋK}$L\Z\\Q\β\0P4`4h#\\0\G\0Y!\шBZk\\nas\",=\|z\\#s/kyqM R]\S\-Hkiu\66\rf\\fGx$\7z\[\vF ^\l{\\\\'$àH\y \^D#\bz\:XdvF4/dD۸\l3\͈f]\\c\>6~\\2ϱ8ke\ɇ\\6\kenqƗˠn\g/1كs\F|\ \\d\ƪ\\ IK\[@\QK\Z\m_)X<}M!>.,bb:=\\\䯻|G\jO[Z;d卮$W\\z\?a!\8 \dmfL\<++n&|\6\>!\m8\D\*\\9\]fP\u\_OEOÍ\Z0\,k[C=\0PDϋ1\舼Gz\=\$qcx@ 3 &]S\dr \\<1\8A&<\*\0\\Kv\\\s\r?o\"g]\"~ \d3N$aL#\6[\J>\rms>)/DDE\nSx\6!]M\\\:f^^Z\\86\UI|\\W\\5\@3NֿE\n\Q{\Y{+\B\>\/\2u.I\{\wtӆ`8\&P\D&!,0\`Lc<\J`s\\\\k䣧]\\<>#\\*Q\ZX\܍J6Aۨ\pV\\r\ffcA6P\Ur\'@pf`\">r\~6x\"\\Nv\\, ^8 9R\7\ș>Wn\I#\\m^s8W:\ \|\W\ isW!nTڌy֓1Kn\\\\Z\\W\'\\\\\\"\"\"(\c̞94\m\i!ʾ\_0\\{\rkA\"\y\\jwƯX*\Z[\l3Ɋ\\\Il\ʋc W^\Y;\\8j\!`IuMP\8\hh1\y,G-n\Vͤmr(7`\vjYyeS\:9 ȯ&!Y ]\vz7\ZđyL>^\~M\\b\aWa`o V}z`6%\@_֫\`n\V>Q7;\eF\c\t\z\vHn%By)Bh`ϯ~\0: \\)\M\gk?J\\rŒZ\\kSv\!}\\]\`ٶ.6\m<8w=<\\\\w\ap{xT\N[F¦\"E,\d\\wV}=%\|w;4\us]U#\ua#XBΑ;#\\1uhB`;xMSZ\uM;N\}n5EDaî<ᕷuhO\\Myy῵|Ʌ[\\\||;9\-\X_q\\QG6s\\0˱*|נq=v\\|i\\E$ꝋ4\+HB)Y\m|أ0^3#H(Ae\v0\_ \U\|}\\B\ruefƒ`4zZ&c\rfZ\61\\0,9vsDDDDDDDDDDDX\\>\u:U\0\p\$\\7^\[\\\H\>l\K+\Zؑ*D`5|aс\Ϭ۠\e(o\\\\\\[ ж;ͬ\cIJf\D]\y<5\-tV\9 d;n\\\\=B5ƺTKZ\O߻z^\i\oc[[\j\\\ݛ`bO)?4S6\8>^g~+v~P\A\\l5&տ\Qd\>\ZViŔyVq\\\"4b\<#\ZQB\|\\e\0iY\HOk9z\\\rͅGǴ\\"6S+\\\ṷv2_(5:\#|\\\x>\q\SV<\%RޢF1\;&D\\\\"\?\{-\\DhB\\nu\Z;\\gٷ\40Pjϱmvr\RsO&=n!WGai(qk)6Ǒ\Z\\n+Y\\)iekp\\SmA̰|\2)#5\\vnr\Qհ\\\\\]&`֮,/f3WsO4\֕ҙ \nlc\;0\a\]H:o)\fU\\r7d+FC8ph:\9oB”`\"\B?8vp\\\\\/-\"\"֡[B\'a\\hj5MR\'IR\!2%F\x\uq-k\/\请_D \\v\n.&3\ZH0\UCedz\\g\\+מ \~]p\iQ\0ǚ.^(\0c#*F|\\IO9\\\l\q숈Gx\\0v-ǚĨm\\S` ՚\3L\\ Ps \\bm>df+G\Z|Q\/\_L{h\#u)6yCp_\"\\i\\\_\Z^|\~e\4\eK\1䝵\*\<\Dh\{\$\\o<\\;^Ɯ{G:b3ǃq\n;\\\M(\$\ \nVT\\\9X\[j\\:\\lJJ8\5\O&.JǨ\\ȍo\'Ģ#0\o\A\nK:v\Ym7[\n\C@\ͪ,\57\ȉY\\5~(\?\\\6]\([\r\\\\\\\\+h\r>K`NJܖDlF5\\C\aշ]7x\=\'m\w\n\9}}UAcߌ\tr\\\]$\s8\}:\"\"\"\"\"\";\~z3\\\݅\\\0\V\qJ\lڎ>\*\ð\\d\rM)Hz}\\\\T%EEe\r\re\\݄*jjRlm\lueet1\Zd E dʒQ\0y^\\\?O*],\c\525\pkyڢE$s\|ɚW6Id\m&C> bZ޲\\Us\HwȈÖO\Rsoh3H\c8\r\|gʇ\B޶P\Kۤf\\{ͽl7R\mԑ\+3\XI+\\kܽ\\6\ciu/\G l#n\DX}\\7\m(jA L\j\\28\0Dy$E\r\\4Qx\\;\\\Yrwl\6O׭\NMH\?Y\\Zo\Z\q,$Fq+\s\EPr_&tS\\g\nf\T;\0\UΨ\u E-s\r[d\\E&\G _.\4ќ\Wst?\\\]=J\\g1qV\\`\\"`k\i\SG: R6>2ƌFr\\"\"\"\"\\.C\/\ȕn\:\\6Uro鿍93Z\r9\? ~[g?\\/|9\S$>7\"r\m\*( \\\H\\ vXL\{{f L\\'SеqPi\\FS$aD\0TA \\\V\/\/)\s\ڈQO)a\{nqT\\0Cfd \\\u<\[8 a\\ףw1x\x1\7Gb{\>[\X\\"__ &\\vZtQ|\\r\\kbm:\ϧ>\=lMq\.`ݙM \"\\\\/\X\4\0\o-\\ŝ˘eL\u}Of\\\#~\3u$i\+X瀱\a\\6p>4֨;9ҾR\06H\P׶[Xem9\\iI\Zc#@kI[.n6I\iqҾ\-\\0\\RoV>7\<\=\\\\kH{v\wdui?ۄ(Nkǂk,bp\81#Yw\So<\O ț_+ϒ\\r\dp\\W{Ng7\0ZBe\\9!\\\H\\k\\\ \~{PW2k?~p\+\\\\|>M$\\'\\0Pc#˳>]n\rO\4 ǗvGwc$bh\`j\]zF1\0\"1\˜\\\1keˆ\w\ns e\l\-\s}XčJ\9\}0\0|{c\\\\URO4mevX6H\a\|q5R \Z\\\\\rvKs\9ضBc\\[_`Wj\W\}/돷\\;\>\0\\\H~!iФ` \s\4\j_\85z\D\p<\\]\\\"\~5kDDDDEu\Z9\VU5\\ئ6\\H~ϲ\c0L4\\7\'7\\\"\"\"\"\"\"\"\"\"\"\".\4\\sl)b\ #\ {\V?`Y|\\q\hi\t\c^\\\Z\j\y`\k\ֱ~\0\\\[;\~\Ȉ\',4,0,'','','',''),(50,6,8,'9305bf00e80e68a1','63436871415bf00e80dfe9c308856934','2018-11-17 12:50:08','2018-11-17 12:50:08','','','Contact Photos','6.jpg','image/jpeg',80,80,2355,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0+\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\9ZM\*x\\.d% \r1!hu\GS6J\\9jdžBÏ-_e9+\r 0p\m<\\Ƌ\?Jt>\l\\ϭu1y[U:^R\0rT Ƕ>,G6\\9\ \\\oCN0!..-ӭ/\nC)!i\V\*\q㕈w{\C\Jg]U%,}$\\p\\I׉\\5Wț$DEWI.f\"\\A\պ\0t\KDu\Y\\Z%ލjR\ \n\ 24\\J 0f0L<\#N\Ҏv@F 37\\WKLE҂C2IH\5)]i\8>*ƌޖ~\c\B\\m]cȢ~ڔ\rvb?N\Z \\\\S 6a\0\0p|;8\s:S7`3x\52\,\y\)\\r \\ɻ$\DW\tiR\ fԷ=\\0B\\mg/Ň\{\Z\\P^\Z\|\!v\Z&I\^\n\X<\0f\\,,zq+u\c.\\A2\\;\\ZjI\r#;Sؓz\,`ͤgR2/\QeN-鵍u\U [R\ڽb:XبFm C2;- B1xo:\V\\0Ww\W?[\ڪ\N\\\XbA:e]X3㭶1\4\\՞i\\\V[\>$\.kI\ LɻyܪaP\\8R\\0t %WY/O\ZO\\0\Do- \a\\ ^-\,R\\\u\!Y \tu\\U5t\\\B[Q\\\\'SY\"P\\n\>VQ\ | #x˥\4\qm\Z\n\5\#g-ӞW]ۦZjybN-\r53c-{{o\+\D\\\P*#T-L\\ZCM\,fjY@\g\\LnF\#N\W\\-8SHY\\t\I\N,\ϳ\\S\^\+w~Ak>\m\l}\h\ (MN\4,^ J1(\2\b>\\\{(U՚ۭ)jdvvӢ\\ڞT\Rw\\ƌת1\\\3Z^F\¶\r\$㦣c\a\R&X$\\\\h$ca8\"\Z\$B\u!q6!X\gO\rv\\]ZA/3{^PQ[ˎ_\\4%5\\\0%_rLe\rC\^2\ 8c 0\\\\#\\,\16\M(m \\' N1c{xyՠ/5[-&\\Z\_\!dGlx\\"\"\䭒><%R\eն\*\e{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(53,3,14,'9305bf01a84c7ec6','21202845355bf01a84c5d33470288998','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(54,3,14,'9305bf01a84c7ec6','21202845355bf01a84c5d33470288998','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(55,2,15,'9305bf01a87c0b81','43713557095bf01a87be90d132625601','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(56,2,15,'9305bf01a87c0b81','43713557095bf01a87be90d132625601','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(57,2,15,'9305bf01a87c0b81','43713557095bf01a87be90d132625601','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(58,4,16,'9305bf01b2f3b2a5','17505033475bf01b2f38ff6177578340','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(59,4,16,'9305bf01b2f3b2a5','17505033475bf01b2f38ff6177578340','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(60,4,16,'9305bf01b2f3b2a5','17505033475bf01b2f38ff6177578340','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(61,2,17,'9305bf01b31f0b98','10942403815bf01b31eec59540800317','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(62,2,17,'9305bf01b31f0b98','10942403815bf01b31eec59540800317','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(63,2,17,'9305bf01b31f0b98','10942403815bf01b31eec59540800317','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(64,5,18,'9305bf01b7c2bcd7','18858997325bf01b7c29d79163158635','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(65,5,18,'9305bf01b7c2bcd7','18858997325bf01b7c29d79163158635','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(66,5,18,'9305bf01b7c2bcd7','18858997325bf01b7c29d79163158635','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(67,2,19,'9305bf01b7eb4ee9','34785303845bf01b7eb2f18737560971','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(68,2,19,'9305bf01b7eb4ee9','34785303845bf01b7eb2f18737560971','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(69,2,19,'9305bf01b7eb4ee9','34785303845bf01b7eb2f18737560971','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(70,6,20,'9305bf01b9732a1c','56404448515bf01b9730801345691622','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(71,6,20,'9305bf01b9732a1c','56404448515bf01b9730801345691622','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(72,6,20,'9305bf01b9732a1c','56404448515bf01b9730801345691622','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(73,2,21,'9305bf01b999ea44','14034074815bf01b999cc19093001643','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(74,2,21,'9305bf01b999ea44','14034074815bf01b999cc19093001643','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(75,2,21,'9305bf01b999ea44','14034074815bf01b999cc19093001643','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(76,4,22,'9305bf01bd73e275','11253258925bf01bd73be2b220663529','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(77,4,22,'9305bf01bd73e275','11253258925bf01bd73be2b220663529','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(78,4,22,'9305bf01bd73e275','11253258925bf01bd73be2b220663529','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(79,3,23,'9305bf01bd94ffec','13931663105bf01bd94d90e243464200','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(80,3,23,'9305bf01bd94ffec','13931663105bf01bd94d90e243464200','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(81,3,23,'9305bf01bd94ffec','13931663105bf01bd94d90e243464200','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(82,6,24,'9305bf01c3d40ea6','10196270915bf01c3d3dcff106620826','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(83,6,24,'9305bf01c3d40ea6','10196270915bf01c3d3dcff106620826','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(84,6,24,'9305bf01c3d40ea6','10196270915bf01c3d3dcff106620826','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''),(85,5,25,'9305bf01c3fdb20a','11789807665bf01c3fd8ccd829485384','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 ,,\0\\0\0\0\0\0\0\0\0\0\0\0\n  \\08\0 \0\0\0 \n!\"13A#BQ$%2Saqs\\0\0\0?\0\\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\^b\ \m S\m&mpKY!\\\\K V\~\4 +\/r9f3D뺞g\]L\\ϵ[d_\\"W\ĶHx\x.+\'=Yf0H\F}`V\<|\\r\\Rľ\\v=/\y\Zd\{\.%F29#1\\?\CVގ\\r\MƕM\G\9OWag\,\"Q\\x7\\a\\6\-Nл\\\q\z\.\\-̡nic!I)\\0sp1ؕ׏9Jh\^I\\\\΋6\uxL\8x\m\V\m؂PbH |u\J\\=\-KS\NS\ܛW\y=^-\ϬM)T\2\1CO$yQ2Ka(#q\:\\O\\y\WPKg\\\ν\C(@le{R\A b;\'N Yen\eEŖc\\N\m|˪䝀\l\QZo[ &U\t(J,\\,\>1l\ƒDp\\0\+6v\\0\/\\l\\|\Zf\>9lb\ \ΓUY ÍL9ƅ-f6h#\At6=:\m|\\$\0\-}w\6A\9\ \+P\\\0o\+\{6_n{Ѷmo6-i\\\3lgIy&T\)H\9϶=c\?\\\0{\\8\0\ O\\\\0M\-\'.\g=\0 e`\7\/r\L\-dm:G\Dx\\\r\'\\rj\\Z:*mb_\jRQ\E\ZʚȢ@\\0E0Bykqv\\g.s\\Ɇ\[\3PK\;\\\\=).&\\Zy]VKl/\\\Br\\F\^\38\Bp:6,ا\Z.> \\\\lm\\}mb%|93̸̎IC\n\\\7Uo`\6&\p\s\NIA\ρ^3G1&,\u\,\h_/lȉq\Xg?\e^,\\?\\=|\0~\w:s|\j;>\\᭖\+&/h۶U\?\Z^.IL\Kf 9\!򙿍KrԷ\86P\\\\X75\\#ᬙ\0\فDx\\m#>ln].ʈ\Ad1Ly\nR? \\e\!\\c\vp\7\sc9Uz\w\\0k#,\'E\|s\Z\3h.:%/!n\E,k9\~X`6LD\\"\/ۇ\Y>mh퓖6_ :\|\,s[呵\1\0$𬮣ٺUpۤ\\r[\8\uCM֪%}=,ǣ\"}\_\0D\=\?\L\&Ⱥ\v\O\3G\\\Ɨ\c8G񤈌<`pH\nM\=͵mh\rwyխі\0\\rśb\J{jbs͑\\\VV\ \kr\\^\j謧S^Q\WqMqW(l\k\'k\A\0ʉ(% C(\\|ձރy]\Ͳ\ZyQu{\rv96+\\H)cYj־YfD\\\\'H)3\lWZ#\·\\hƩ n&\֧S es\(b\a\\\#$\Zinp)\g3\ 2Th;\DDDDDDDDDDE,\\^\:Xbʰ\x\֧U2fkY\5\\a{o\n$S4Dzi{DߐI$rʓ:tgMis&K1$ʗ*Iid\3c\9\{\R=\{\g9tV|?~?-\;Uρ\tS\ȉ|\'ɐ\8I2bL\ʸ\AopI*\hĿ\(}O\\D\u7M~r\\\0qqwg8/`G!j\\cr5(\n\[7!ɘ \B\%Uȝm\"\"\"\"\"\"\"\"\"(\\wx\\\ ߲7a9\_n>81x\ \QJ˜߳\"dq^1q&kez\\0\\\\\\'\0ffo1\\\; 1\ \\Sj1\`n\ZL\-;8kώse^Cp\\*~$\;(\UW\t0iB\Q\y \k֌m\\[DDD\\Ɲ\ZD).\Z,9F7\\0x \V8eǷ-\qG9\\Ȧ\Nm4\\\\0\\5\r\O,;\67b\\0\j`A٫*cf5Sc곈;ȝ@\.Lgn4h\'e3+\[\]\\[ݣS\\(FLfQ%}a\\\DDDDDDDDDE[\\qCu,1~y#v,圌f\5o\ٯ^a\~r\\[\_]\)Z8R\\0\\,\Y t\<#^äS\\N\-kʖ>$r\"[C\\\2x\\yY*|\O\6!q\\Oq\W2n)`ioU\\&(EK#\; %W*-\\]xq\nU\sΥBM\%:OCl\n An!e\ \\kv\\"\"\"\"\"\"\"\"\"\"/^ :\0\=y5\\\韥RYE\F\e l5\\\.ۆWBZ~mnl\0*[\Ə5\V9*S\m\Z\\|r܍ѩ->\\湡kX\R^xj\Z{)aq۽ws.Q\\0\񟬸Ȝ?2\\ b;\e\\6nD\N5=Bַ;)\v\"mv\lېLp\\j, 6\(aQiIyj\"\"\"\"\"\"\"\"\"\"G+r\'d:\<}ֹ;\\4 \3\ \\l1N\U)rz46\>\=I\\_y\0\7k.\'9\~xq<\[\\\s|DDDDZB\^{PI\\q)\\%oVpjk a\nlr9h4 ݜӈKޔ^\\\勍9^ Jꦿ\k\\8\׺ޑU\Y}[\'S(D9s#fDaKia\I\^vGW̓\;V#M;גm\\+@\EG\\vc\g\9h\\u]Eڶm#mGiΫR\ȳ*a׬d\]U\\`fE}91\r\쌏gה=)լט~$\dAo\6\\ \Z\r#xb\Ԥ\\͑(\r}^\\\wB\\\؎G\ ,4=;k׵\Ȉ\gM@q*\JG\0}}譓\^OZm?c;WW n,\\\Y\\d6\\L,ìj魝\vr\*\"\"\"\"\"\"\"\"\"\"*U=g\e\:\3ÝD(\N\Q3\Tc\ClX (v\Z\ϑoq&N\9lh\\0۫\\~G9\\8{x\%Ǣ>#\\U\mM\\DGT!9\5PT4@0Ki1\WqG#\\\\y\5Iҵ\DYB\m4\\a7 \f4EsZ\b\s湘DDE\\_\\>\0\~w\}Zfk\&Vǽm\\־D\\˝ifx\w<:\\|\ңs\\m#\\rYk坲m(\L6U˾=Z\Z,b-p/<ɹd\\O@3}2 ^\rޞ\\3j:\\\5KG\0\ک\W`FIJ\^C4\')c\\}\\\Ď@\#Q\F&N:=\\\ j\r#o<\\}g\\p\\n\^ZfE>D_4\;\o_fm\\5\\sk\\\=5\qr1Nƹ\+34\fE)\OPN&L9\09:JC\7\j\\'?Mwm|\f\C\[N;ll\+\\\"\Z)\rh\*\1k#X\ X\ZuqŽ\"ȐQ\0\s\B7/!JG\\ƹ\{݆˝cʨԃ\7l\Ï5ˉѺ\\\rb\n1\\Çy\\-N\]\|F7aEM$\l*h$R\ \rL\\Guo\\\\^\\q\3SN\W8(\\5Z>[5/\x \\Ic<ߙp(-s_PXƄ+VT\5[Ӵ\J\oW\\TQQTF:\\c\ŋX\X\7\v}\R9\+\R=\\+\oz\oyh\Vux\\o&\\MRz\r/;\H\r\T \02:-LRM X\\ruD̮ \\f8\\\}\->\\\\\lr\\\\?͎2ȨVmU\i_vQaYD&0\p\V2k\\\=Xu\syUV׮P> ϳ:j8k\\0߯9oޑyfYLcc.L O6t\,fJ+\*\\G{\\"L\9\\{\G\\s\\_MwN(⟁LʷA\; VrӮ\'jẋ&i4ӨfdC6:\6\a&S\~[x\K\'QL4חX\n\U\\óQ\ێ\\\\U\ZTsg>\\ §\z\Wmƚ\\RNعM\"%]o\\͊3\0\"4Qx\\c\qU]G\\==D \VV\:\(0@&\0QcG1f0\e\c\g\\=\4vO\_\kG8\5\.ײ\9d7\0;9\\n\F Ev$J\r~ Xt`{3\6\7J=g[;6rV\4-\'\k2\,Qh^|\,\rvK]\H[wwgfоj1aUֻ_\\\W4\Zr[\\\\uzyrr>f\X\Oō獹N߮J\]f\6g[.IoTY1nqeUy8lO$\ƔP4<\cg\0?\"|\\\^saA\\ȍ\8es+]̗\\rf}~l\N\Hğ8r3Ĝx\c$8\e男|\re1\\\"\"\_\"#]\\\0B~Ga\=|\-6n\\jvM0Y&E$fc\\\\]:\Z RV\\\\\Z\ŕL\`s*\i暺ҺS1aaMx\f\4~1\\6\M\>4\\ߑV꼁\h\g\r\[g-XR,R;G\1\\\x\%^0\@\5hR\8GcM\nds\r\p\&c\\g8s]\]*\[/(\q\\r\nK\UB $˝ca-\#Ǝ\"\sD&qc jx\{xΉ\Zjg*\NaqYw\\GLi\n;\&\\\~ghu\\W\شu\r\\v?dH\v\\Kٶ<\\\\\M\rF@p\)5JR؂&BĨ\\:/^1\Er\\\ȓQ\\5\[\\\m\\$\cIlL_oY#-S;\^pO[\{Lu\<ԩujGoU3\\%v_Jy\\{e\϶3cDDDDETO;ŵGm\\<\%D\omﭶ\0eƬ\yfOu\Z\gGpCm\\'ؐ\1Z8\+\1}V\d{\Eܟ[I\]SO>\к\\-[*\\氡$\<\VQ\e#DDDDDDDDD^3\q\'W8y\^qܪ^4\\9\\\6<U\\\n\0@\y%\`\R6s\\\*\kTv7|q\R\\~ 5\\0k\_r\'\\\\\\"f*95m!\ \\W\V\\\S\i4NR\\st\9&q@#\d?ntS\{K\\ym⿩08z:C>,\0\5\\\FO\l7B\;L\2~\F2|Hձ Ë4DDDDDXܮpz\m\\Z͡\%\-un,E>\E+4vAe\\Cƒ1DPHq@\\\\'y:$\a%I\4d\Y\\\d]6@Q\ G,T\8\\i\L\^\"|.\\s 7\\#j<\\CH\w3+\O8ݴkGɬuV\SXW6e[mi\>/;\`z\U\\>\9\؇\-\7?\uhT\\WIa\˅>4Y!Ls2J DDDDDDDD\\2$(\r*Q\Z4aD0\0\C\#1CkB=\c\9\\q}Ke.\;p\\i=|\\\FCD\\\I/5\Eu\r)u92HMv\\EgM\8\V\ \\r\:]Lq\\a\[c.\]c>\]FS\\=\m\\\-\P%M\jmGBG\n\+!GS}JRit2>\\QQkkYCCYawywa\n+[{[)\"]YY] F>\|\,(QBY2@\0W\\\_\\v>\[S/\\\{睪$_rG<̙\5sdL\\i\3\%\,/MEW>d7|\9k{)6v\ZUy,5nD\\)Q\\›\"1 R\\\xۘytowϑ\cG\fN\\p\rӱ[?\ryƗk\Į&}ER\ S\\]T\Oa)˯˰vg3wk\F\:󂹖,qX]\@KlpYO\TEikfVW\\Vt\-z۸7Z\_?]\ZrMQeZ\\V\\\ɑm,\nڙ\O>j\u\A)Q \ \"7Fe\ \\a\{sÛ\8\\DDDDDU;s\\+6cut8ѭΨeڶ(\\cQcml\'2gJƹA<\ad\0\`Sޙ\\ZY2xҸ-2\1]&,3\L\V.j9\"8|\7DDDDDDDDDDE=\\\\pF\\\^ɇ\dfˢ쎌X\6Fݸlʛh&ZE8,\6}\+^2y\wjv.\0\څp(~ E\\|i. {(X&\}\󊍶Y &DZ\\Hyع\\n\\mBLο\//\Nl\/`#\\kkdϻ\*v6+SDDDDE\<N\^RۣѤju쀒\ej=f\\Fke\4v3-\q\}\-\a\\r\0x佺H\ml\\\0Cp/a\n\\Ic=n!ƿ\r\ߜ|m;\RTp\ZI6\\E\\r O\O_p\\MV#GH\$P\LCHUi;\G\o\_CM:]e\'pf\h\d\z\d\dԍcѨ\mUG\@g.\'=U\nqW%oYyI\=,z_+p VlC\r\\ꍧPadR\\0նLY$Rl\r \\cO9\5w7CAK\߳ԭvsjf ( :x8%4xsY@\ #c\,h\adg.[\"\"\"\"-n\9O\V\k-S|e_&\0\C5p\ss\\&sw\(9C%2C\r\')q!\x6ݲ͌L1d\ \e\]woyд\>:\u= Z\ku1\"F@\nDB[?\\\?q}켤{\\{j\"\"\"\"\"\"\"\"\"\"\"*t=E<釕\\\\Od\\n\rGT\\\[]֘^옌ۡlr\\%aÂk^\d\\\m\\8\\\n[$[\\Z~Ec|Jb~a|H09\k\\\\DDDDZ\\|Jw\]i\\>,|Uq4njve4$?[p|9c\2\0wtcGt[.bŗ\0_lf\\=k(8\ԑ c\aڮDDDDDDDDDDDDU\\Z\\JJ\0\9#yC^\n0!6M:ٴ\kJv$j\V&yn$\\'A\J78EJѿ\ⷉlϷ\,c\\>\<~\\\"\"\"\"\\\jݺ\ZO\!\.J\&AZ\<\\ H\]|~\M\0\n\'\0dM\\s\28b+3\C-!2\\񜐌k$DDDDDDDDDDDDPM\5\\\\?Oy+58n~u}F\\c&b|Ɍ\\0Y\1\\\F\w\Zc\ڻ#α14d5m.H\#[\0\\hcXvƵ\DDDD^[\Qq;9 \g W9\0,bFn\߿0\0|{c\\\\id\\p\dmv\<ú9\H\k .\\n;5\/\k\b\\DDDDDDDDDDDE [n \ޗ\}~\]\\0/^/>\\p\o}\0\\\H~!iФ` \s\Y\q2\a\\\0ߟv\7\~Y@\уx$;yE\/\'c\!\h\\\̗\s\\\osp\\a\3\u\ghz\oaLނ\\A\\\l+\0\0}nT\ \aE\n\"\(ּdۖdcnr\5\\\\\\\q\A7\A̟lG\*G\\o\Z\\\?#qc\\҉϶1\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\xJ\i2\g\\s\r\p\#|\0 H\}\0\\\Vsx7\rWo\,\\\Z#c\Z҄R\\r\HL\g\\\1ò\7\k\\[C\\i\0x+XG$1]˖9kl3Owe,e\Z@\π\"\rDDDDDDDDDDDDQ\O\\\L\ϱ6;I\dk\r\@_a\C(h\O|W\Z;6%!G\8\L<\\1\\Xx\d|i\\\"q`j‰\y1ǶEnk^\7P\T\44sk#Ll!>\0ӟ\7\\\^\\w\\oΡv\2801\Z\f}7c\>\r4r^k\[CS]M^\;# \\ {\\5\\0<9\kp\c9\q\>\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\',4,0,'','','',''),(86,5,25,'9305bf01c3fdb20a','11789807665bf01c3fd8ccd829485384','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \0P\0P\0\\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0*\0\0\0\0\0\0\0 \n!#\"BC\\0\0\0?\0\\8\8\8㚑\1VwZf\J\\!me\Mgܑ|\\qbtٙ$[Q\\\|\'J+\_Οyd\]~\\N\M>et-eMs2T9\4ZO\4\Z] W~?;J&\&u\約f}v\\+\3V\\;j\ \\*stn].\\ \;E\\V\u0\EW}o H{ ^8\9{\\\Evػ\n4\+\"J\`kgR+\U\l\ƽ)͔ь|Poީ\kڐH\$Ͳ&a\DK.P\\\Vn\\r\\k\0Vq\\m%I^SD\\\#;q4)\\p\\\S\\hկJ\-&\Mzg\2TV:󣩛 x\lbXc\!aǍѯ\B28G6X\\\E\z\㟥:b6H\g\Y^ǘ[^*\`)Y\ҹ*aPL\\#N?rzq\\}Ie\i\!}YyQ\i֗!\\B\+Nrc8\q\\{ս\A{>^v}vDXz\$N\\Ă\s\\Z\I\$M\ky\"X\"\$ en\Z\}j\u%:\N\FE)jraÚC\l\X\%SL Hx3`i\ȧi\GFIڠ#\zv\o\\%\\\\\\&\"\AJ}Q$\$a\.8>*ƌޖ~\c\B\\m]cȢ~ڔ\rS\\\cO\n4\E\ܦ@ \\\)d\0\vcGq\\\ftO4n\gan\\0TkHe\Y\'\:S\Z69vI\'AG\ҥͩn{\\0]_\\04\v\k9~,\WX%c\\r-}\5EJMV\ __\2\dcӍ[W;Lw/2\r\V\\@\RLi\\ğ\5cm#8:\\a~B0\-m*tQh_MkoRШ4X\BO\\\,l, H\\\F3hdP\m8Jsǻy\\no:t1\\\W-jtȬ\\\ e1\\( \\wmɏi֬ON\³r\]!\J,} j\Q\=g\\Lu*\H\ҍ)$-~3Hdk\\fx\\8RU%X’\J{\8\=g\3\8\\sQ?-ٯ*~j:M\JP\DuO@kwۘ҈ \D\z\^W(q8ʃMva\\Y!\"pma!!\P\5\\Tt~0X8Hpc\"\m\0 D1ZhqFem\r)\\\?!v\\-\cc\*\\\j\5\r\0ۍa\bgB<u\ʾG\j\`d|h\]\Xi\.\\v l\rsn\2#җ!E\*2P\gS\!Z\뒱\\4\nuL4A!\\\lݯ\\0\j\0;zHd)\Z\%[:ʑ?K\0P82E#\0\\0mv\F\4>u\ɭG\7\e:ۥ\K:@\0\Elv\m{\0 FG\q\0|s>q\s\Z\x\RceE\'.\\\-,!M`\njQq\|peA):?\z\պ0H;SQy\C#&]WaSF[\0RP\l2\ul\VK\Sǎ8\@\z:W\\\Ss\o?~G`t\+w\\l\\WU\S~\0=\H @\$g\KM\d+?\\\ pvBJTW?q\\d1T[g\vrsC:e\\\n\w\\]xvqg⵲ҕ\ \;<\',5,0,'','','',''),(87,5,25,'9305bf01c3fdb20a','11789807665bf01c3fd8ccd829485384','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '\\\0JFIF\0\0H\0H\0\0\\0C\0\0 \00\00\0\\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n\\0(\0\0\0\0\0\0\0\0\0 \n\"1$%\\0\0\0?\08\\{\0݇\I\\]Un\_Pf\-5a.-\\5ۮ6B]eW\]\~:\'jjy3 6e5-MG>\u3vk\x\ZxJ§=!&1y֙\z\\>\\<\\]e\h\rWQkUbeb\\#\:ӳJ\\r\'3[d\0 \'\R\ҽRZisӵw?9\Ԩgt\أ\[@Z\Ih3Az\&f \r 9!@Ew\~\x\'JuU\\XU}Yy\\b5\ZXݭP\'\्\r3Ђ\* Dk\\\g\\ \{\\\UX\y.&C#gk-8r\\B\Z\TsbNs\nh&&\)䩦VJC\Z\jMCxbR\\[WM\5FRmP8آsbp\ZCY\Xk\0rɟ-\`9\>\.kI\ LɻyܪaHgp)vI Ϭ\\\'\\uY\\"Iۖ…a\دVFZ\Q)q :\\::\[\\*\{κog\}-M{\Mo)\ő(ba+(\>e\\|qN\\u8̶\Y\\\ \\q\\Z\\\|+\\Z5<\1\'G\9\Z\flpV \(GJ~N\rɉk!35^k&7#oT\+v\)֤,Qtͺi$\b \'J\\r\\\0\m\[O]ൟY>\\\\p6S_NቴkN?|MN\4,^ J1 \\b>\l\{hU՚ۭ)jdvvӢ56)BT55\w\\\&kFk\\\\BMXV\}\\dt\l|\9\JD\$\\y\rq\6PG[ [Xδ@\2.!\Ԥ+\\\\\| H%\ow>p\\n+yq\\=Ƹc?d\Ir\}k\Pc4xa vVZa\BZdq\BZe[F0\i\r8\P\)\1c\o3ڴ\e\\Ü\\+5{$,\/?DT\Y¼@gǖ@\T۬֜YLjǪj\\/\)R\fH)M\m\ZW.\\k\ppA?Է`k\nV~\n\SpMGصrjKue+R\[N]5k4=?\\SQp\0FU\0o6~b\\Z/[S!)\\ E\{- \\\8ZМ\0BS\\>\',6,0,'','','',''); +INSERT INTO `photo` VALUES (1,1,0,'9305bf00a1f6a976','59483894715bf00a1f6a24a891914929','2018-11-17 12:31:27','2018-11-17 12:31:27','','','Profilbilder','person-300.jpg','image/jpeg',300,300,11007,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0\0 \n!\"1A#3BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/�\�3\�\"u\�OX\�G�X����q\�\�ڭ�/\�+�xߌ\�[VJ$�+\\�c|>o���b_j�\�m\0�&\�Q\�<\�E\�-�A��Zv�\��� ��Ɍ�ԏ.y��\�\�M+z\�Ga�\���\�J\�&�\�\�Q�\'�۫\�|0�\�&G�(\�p\�<\�s0\�\�z�y|�r\�S�h]\�\�n�\�H|j=[P\�`vm�\�P�YE�SGM�M���\�\�\�$��\�h\0S9�\�\�J\�LJL}X�IQ�C\�$�k��gD̛�\�:�ռJ�t\\WU�u}A�\�L\�JsC\�V\�m�؂P�b@�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1>CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�9\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�7\��`\�\rf�\��a\�\�#�\�w^\�s\�\���c�\�j^2\�\�|\�����AaB\�vN�\�g\�\�1�ʋ�,\�3Ŝ E!_��\��=�U\�;\0\'u��\�,)E\����%�\�\"6@M:��\� QH�Yy���Y\�}b\�\�υf���\�\�_\��\0\�OE�m\�1):\�\�t\�^�\�y{l�c\��d|��4ͮ|r\�ņǝ&���\Z�s�\n[\�\�l\�DGԃ\�\'B\�lzu\�\�J���\�(I�]���u\��\�l\�@~d�ǐ�BR7[��Vռ�6`,z\�\�X�׵\�\�\�\�}�\�wֻFٴZͼض+ɧ�����;\�α��%\�<�RNG��#�\�>\��n1�|l�߷����\0�=�\��x\�?�\��S����g���\�{�\�KI˵\�\�=k\�a�&���\�\�\��S(#x�\�kY&�N�\��\�97�m\��Z�����Y��\��ڨ�uE���(�@�8��b���\�{\�٭\�\�\�sݜ�\�v~�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\���&\Z\�΍o\\\�9A/0\�p��4��\�xF|i\�\�C\�\�uXm.���S4Uq\n�\�ʟ��\rM\�%rV�\�[�\�\�<��\�o��]\�ضͮ�Ve\�\�[\�\'\��dك\�\�2<8QF5Ѓ\Z��4X1�\�G_�\�s�s���\�4OKW��v_x��ڍ3\�\�~9��N���\�\�\�B��2�r���\0���!���t�\�\�D\�E�u�\�+@\0E\0cF�F��8�72����\�\Zہ\���e%\�\�>\�ԁq\���^�cog/\�o\�+\�ɘ\�e\�dw�J��w�OŷGi����{oq��5\�Ϗ����c�\"rL:\�!�\n�����e\�B9��1f\'�Ө/��O\�gdcB�{fDM��&\�\�=��؆k*�e\�\���~v1\�c\���\�i\�\��\�\��)�\��#��[H�<�|��nږ�VV\��i{\�}��I��L\�Kf 9\�#\��򙿍KrԷ\�8�6��P\�\���1�\�\�XW5�\�\�#ᬙ\0\�ـ�Dx\\�m#>\�7.\�eDDDDDDDDDDD\\d \�2\�`�&<�)� cf2琏vp\�1�\�\\��8k�\�9\�1����M~C\�\�\�ҵ���|բ\�\�9�Ug��{\\����v�0��5�\��U,SH� x��&B|\"\\X\�\�t\�l{\��\�\�\�_v���\� ���[\�;d卮$RH�\�z\�?a�!\�8 \��dmf�D\�<+��n�&|\�2\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�\�\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�dJ�>\rm�s�1�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���\�\���J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf�`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8�R�\�7$ș>�W n\�I�#�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W�p\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i\"J�\�_0\�\�{\r�kA\"\�{�\�\�jwƯ��X*\�Z[\�m3�Ɋ\�\���\�\�l�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�_`F5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\"/\�u�w��\�1�p%\�\n<�9~3��G��-~XB�#��]f_��#f\�N�t\�S\�+Mk{3��7b(�pZF\�k�\�&\�{;�r �]\rE���\�8\� 7�0�!\� /-DDDDDDDDDDQH��p�D�V>V�,�g_����Z\�0�c��}��ƒd��\�b\�x\���m�&)\�<�# ��E.OF�\�:�\0\'܇�#v��\�Bp��\�\�{\�\�5�nq\�<�8g\�\�DDDDE�/Q�/j�E\�׺\�l��\�\�}�~rY[�g��0@\���\��G#�֊3O�\n0�\�\�8���\�E\�\�ϞX�ӕ\��;���k�/*�|lÍm{�]\�\ZuT�;��\�˹���x\�h2�C�26dF�f�w��ԝ\�\�dz\�|\�?�\�S�k��0$\�C�\�y&\�K�l�b��X��|��\0I]�f<�|�����U\�t]�f\�6ڹ{V��\\\�5,��ҩ�~\�ME\�\\���dW\�C�\� J,�.\�\��{;=}Iқ\�Z\�\ry�\�H�<�D/q�&�\�1 �`]}���\�\�0\�07�+>�JI0YNJA�L\��\�\�p�����/\�UZ�t+]�(��}���\�|@�ȣB\��S�\�:f�{\\\���Vtٻ$��<��>ql\09����O\��\��gAz<V\�-i���\�g\�\\��2K[�-g���8\�G)3�P��[\��6~+�{�\�U\�˪E�ę;,\� pa����n�s!�\�\�.\�f\�8\�\�a\�H����kaW�\�7�\�P��\\\�B�RX\�e�M,I�\�C�^�\����q\�+\�R�3X\�+X\�hdF�)�}V\�M\�q(8�\�\�cDW5�\� 7�\�c�\�c\�w�|㳬��\0e�\���\Z�iY�밚9[��\�\�ck���Z�s.u��\�G9\�x�\��\'J�aΦ�=�\��v��{Mʙd}��v\�\�أ�2L\�\�V�.�h�j�Hh���\�|�q#�\��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0���S\�2����&�\�.�\'\�hN \�mSfǩ�(\�5��\�ljb�1�F�ƍL x�\�ƌ \0F֌A\�шCkX\�5�cp\�c ���\�x\�\�|�t{��@\�\�\�\�xF\�x;c���\�#\�\�(\�\Z91�1��6̋\�*}T�Uc#8�*i9�w�\"\�6\�1\�\�b\�Kߴk\�\�\�׫\�\��{:k�\�\�2b��s\�Vg8i�̊S����L�r\0rt�h��o\�\�Oc�N~�n\���\�ԇ���v\�\�i\�Wg��>E\Z4cQ\r`\�#�U�b\�G���:4\�9t\"\"\"\�1�E� �\0\07�\�3\� �\"n^B��\�X1��s\���\rcq�;8\�3�Qw�\�v\�\�/�;�k��u��6\�\�\�c��\��g��Z�\�~����2noŠ�L�Z64Y)g�\�&.#��\�\�\��-c\����ߩ�c��J\�皭I�\� \Z\�\�Ut�\�$�\�o̸x����\�(,cB\�+\�Ǫj�֋�\�i\�u%n��\�u��(��\"�m]t1\�q\�Ŏ,a�c�w;?\"�y����{�\"\"\"\"\"\"\"\"\"\"\"(J���瞵\�\"�Y\�\�Ys��[�\�\�z�s\�X��\�7*�\����\�QHi O���i�G��=n����^7{\�ɾ8{}\�}�\�d�Ԭ�G޵܋\�\�\�7\�\"�w\�?̯\�\�έ fS�C6\�]Q*�s+�.����َ\'\��c�Ka���\�)k5\�%�;-��c��*.!��UsZW\�=�]�XVQ �6\\(\�wՌ�DDZ��\�Vg�\�\�\�g��nըu\��H.3\�\�N�k\Z�m\�\Z\��8�#_\�\�7\�\�D�\���^lٖS%\�\�˓>\�|�͝:i\�*dْ��ʗ.Q\��ȓ \�y�s=\�1^�\�{���\02�\�]ӊ>���\�S2�\�w�\�Ճ��&ma*�q�A�Z�KȦM�Z�\0`Ћ�\�L���\���cߚ\"\"\"\"\"\"\"\"\"\"\"\"\�0C(&�$\"�\Z@��\�aB`��BaaF9\� \�ܱ\�\�Z\�g\�U��n�pwM{ˣ�\'��Z�\�F��r\�\�T\�cck;\�\r\�L{\��\��\�\�n\�\�b\�b\0�i�&ۘ��ml$�+�k~�N\�s\�N\�r\�P��ϴ\�\� \���+Z�)3!꼉�\�\�M=t\��\'\�P͎�a�ͭc��\�I���V\�:\�\�\�DE�?S\r5\�\��\���&n�s=��\�\�t�㲽a~9ǴsUF�\�ϻp\�\�.\�p��^�\��U\�G)�q��v.@\�h�!� fW[\�u��b��x\�\"H� �aA�n~7�\�ñ|\'UWQ���OQ5Օ���\n  �X\�\�b ah��5�kq�\�\�=��\0�]��9\�{�\"\"\"\"\"\"\"\"\"\",h\�q��\�*\�\�8q\�ku�]�e��br\�o�\�$\nw6s%\�@�\�H�\"0\Z�>@�\���g\�m\�n2�zη�.vl䭐h[XNՁf\�e�\�Y3v�.\�����X\Z얺+s��2��T\��]�t\�\�\�K�}\Z\�c]\nª%�v��\�ݎ=@�\nh4䷱��\�\�5X�\�G9{�`t\� I\�\'\�Ɗs\�ܧ\�\�\�\�\�%r�\�\���jH3�ܭ�F�ڷ�*,�\�\�J\�7Q���*\�<�F�]\�6@\'�dcJ(C\ZO�X�L�\�1\�3��\0\��>i\�g/\\}���\�h���}dF\�e}xN����\�\�\�K\��R>�\0�r\'X\�$bO�9Y\�\�N<`�v�\�\��d��\�[\�WH\�=����dș\���\�Xg�����\��\�o�^4!m�\�7 �G�ދ�v\�}�\�\�\�\�y\Z�(5gض�K9q)`�������̰��8�5�ǛcȈ�\r|�p\\�\�tO�\\ ��,��ڲ��{\�oi�\�6��fXL>@l���\�\�;G��79v(\�\�h.uK�\�[c��O�kWT\�3�̫���j\�J\�L\�]�I�:1\��s�҉�\�s�{�\�\�\�$7��\�o�~E[����X�!�84{m��\�!aJ\\�H\� B!�|Xǻ8no�\�\�h���{\0Ñ\�P֭�H�\�%�4)��7�9ÆP��c��\�\�v3�\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $K�ca-\�#Ǝ\"��\�s�D&q�� �j�x\�{�xΉ\Z����jó\�p�M��8��;�#�4���\�j��b��\�b�?�G�\�\�Ŵ :��\�y+\�lZ:��\�k;Ȳ$f;lm�\�\� �Ξioc\�\�&��T\� X[8qy���)lA!bTh\���GW\�\"ֹ_\����O\�~�\�p���\�I�\�\�\�\Z�\��-\�i�m�\�\�c1�� UTVP&J���>Z�w½y\���\��\��p��U� \�\�y�R\�\�\��8\�2�$g\�\�J\�$��\��˟�l{{\Z\"\"\"\"*�}Q�-l�?݋~\��ޱ*[{o}o�\�\�.5f�\�\�2{�B\�\�8�;��m \�ϗ>Ćي\�Ɓ_y�\�W\�#\�\�.\��\�\�\�JM���P\�\�h�ȸ��y���\�\�E��줟8��m�\��\�R\�5�$��\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ\�q\n�;�\�\�\�M�^(ѽ\�$��0���eK^m<\�󟕎U�֨\�n�\�:��\��A\Zk��J\Z٤}~\�ȟCC�k\�\n5��$\�մ� /�O�_�Kŗ�\��[\�AqO \�f��\�8�K�/\�%\�\�x昙\�~\��X����\��\0\�\�Y\�O1\�\r.\�W\�\�����\�\�\�0\���F\����\0�y�a��?%�5��|\�a�~q\�gI��TV:5D\0�+\�F��]��\"\"\"\"\"ž\�t��{\�׾@\�o?j\��m}�,7��k�u�qe�)�Z)Xp\�@��� [(\�H�$2b���@\� U�m��\��\0�˝S�`H\�x[m��\�$\�\Zxq�\�\�\�$\��xo���\�\�@�YF�3�+hq�\�Ӹ��y_GּD�I\�]��\�=6n=٢F\�y���8��\�z.g2W\�8�qW�h֏�X\��1�����lʶ\�\Z\�%�}9\�_\0w��5.��\�z�ݴ}�s���[k�o3_\�\�\n\�N����\�\�Ÿ\Z,�ʋ&9\�Q�FT\"\"\"\"\"\"\"\".�(\Z0�\"D�`@\0�!�c\��!�\�!汌k�\�a�\�UR��\�2�=�\�\�[�gf��\��lvZ\�cF!�C\�.J����\��y`_:��\��:М\���$&�in\"�W���\�;y\�ɧfu>�ptC|�\nӐ9\n\�)�ka��\��[i�h�\�\�4-(5�p\�\�\�-~�ΗWT {ʛ�z/Ѿ�\�ם/��}ա\�\�z\�l&^ߺ$q\�[\�\�8\�e�ݵM2I�ֲ�\"[��Q3 �\\\�\�\�DDDDDDD^a\��+Ľ�\�\�Ú�\�R\��?\�\�\�U_j{�4K��\Z���fF %\r\�d \�Ex\"a�(�,�RJ \0O+\�\�\�\���EA\� ��\�}���_f�\�\rv�\�;TH�\�y/�3S\�j\�\�,���ӨgÃ!�ulK[\�X^���|\�\0n��r\� ��=jS�.m\�\����H�\"Xj܉�\�m�R�Kk,���66E,c`�3\�d �����1d�?\�\�Geb\�\��6�#�<\�(��\�j� �\��c��~��\�/Q+ \�I�\\M2���������0�\�\�Sw�_O�`\�8\�?$rg\\\�o1\"\�!���u\�s-\\Ytⰻ��i\Z�\�\�:�����7\Z�\�\�0̬���,\��Z�Qq�po�^�w��Y\��~�4\r{F䚢\�r�\�\�D���œ#J\�.,X�3eM��|Ղ%\�\�;3ƃ1aR#��0 6& \�ADn2 �˘A��Ø�\�-ss�79\�q�Ȉ�����)�@w絞W9w\\l\�B\�m_\�q�[I�Q\'9.˵lQ��\rE������\� ɝ\'\Z\� (��A������\�Ozfv��)d\�\�^��f\�J\�\�k\�6>h�\�\"\�\�t��\�\��Q6f\�iZf �q�\�\�~�\����}r��\0vOG�\�5{&^�q�\r�.��:)cD\�uv\�*m���k\�\�l�{�\���J\Z|�x\�\�ݩظ�\�O�\�,\�m���G�H\Z.N\�\�Ipc\�W\�`� ��G\�*6ڑ\�2\�\� B�kW�M#ҵ\�b\�\��~;;Y���\n)2��\0\�;4�>\�u:�}�4��\�l�b\�o\�㽭��w>\�h��a\�\\@خeN��\�m;�yKn�wF���\�ﱂK�:9���;1�\�q�8O�\�5\�\�2ܷ\�Ƿ��������7�\��\�#��r\�o[<�\r\���m��u$bs\�\��VJ���s\Z�7/~q�Ͷ~�\�kK�Q\�\�%i&\�\��Qd7�\�ā܇\��o\�EJֽ\�i\�v\�\��\��\�\�\��sK\�\�\�k\��4�\��Ȝ�\��,�D~G)\�\�vʊ610\�\�;`/\���-w\�\�\�ٽ\�C\�(�\�I\��-jh\Zv�Q�\�ĉQ\0(UA9lp�\0�7���\�e\�#\���\�s\�\�QS�\�/\�\�7L<�vM\�7ꜻ&cu\�l̒>��:\�\�\�ǒ�d\�f\� c�\�\�X0�,5�#2��Z�n�&7\�;o\�9\��\�V��}\�\�r\�\"ޠ\��+\�W\�\�K� \�A�ߜ\�\�\�^\0���\"\"\"\"\�O�=2^�\�S�:\�lSM�\'\\��\��c\�\�Dz�����߾Xǃ9Ǿ}�x��\��\�icoq�Pn\�N쎭\'\�\�p�% \�x�V� ԭe�Nr\�g$v�.��?���\0��m�\��5����\�@!�k\�y�\\�����\�\�i\�\�\�8�\0# HL��yBY�\�q2\���ai\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���F�\�/�\'c\�!\�h�\���\�\�L�\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\���\�V\0 �� �RDDDD_\���Al(�\� �#Z�onZ�\�Z�=�\�\\\�c-ss�g\�s�\�L]z\�2~q��9�(# �\�kc�����\�1�ٍ�[\�lc9���S�DDDDDDDDDDDDQ��`�\�\�e�\�\��]ό\��r\�p\�#|��\������\0�����Xj�B�\��1fWVx�\�\�\Zܘ֔ ���8n2Be�2�{�\�\�3\�X\�m�\"\"\"\"\"�G�m�C�\���\���\�\Zia�<\�\�w.X坲\�\�!=\��c(\�\�|\01�\0\�l��\"\"\"\"\"\"\"\"\"\"\"\"��~�\��\0\�gi\�V2�\0�/<9����\�\�\�\��\�\�\��E��*V[\�?\��\r�#�\�\���ĉ\�K\��Ӿ���\0�\�hT�\��v3����DDDDZ�\�\�qO��.�\�*��\�s\�6RZC��l �(�\�z�q`\��n>�\n\�^\�dNs�ƈ������������\�c�\��\�c��?PfF��h\�\�\� [(p%�\� \�\�Gt�ı$3�0\��3?�ɇ�\�_\�=8\�<,��?��ڄA\���,\r^�Q0\�?710`x�ȱ��\�k\�\�\�*|*�f��uՕMdx3�)��w$?g\�c1�&\Zs�\���ۂ�\�\��\��_\�}\�DDDDDDDDDDDE\�7=F�|\�\�u\r�e55\�\\C�ѕ\���|a�x\�\�\�\� 3\�\�=�ѡ���Q\�kՍ{+hjk��\�Ga\�d\Z�a��{Z\�9\�\0�; n\�g8kq�c\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/�\�',4,1,'','','',''),(2,1,0,'9305bf00a1f6a976','59483894715bf00a1f6a24a891914929','2018-11-17 12:31:27','2018-11-17 12:31:27','','','Profilbilder','person-300.jpg','image/jpeg',80,80,2354,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0+\0\0\0\0\0\0\0\0 \n!#\"AB�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�\�\��7��M�\�PCm;\n\�f˲�\�A�\"&\n(�\�\�\�z\�r+\�\�*�������N\��1dVq��+:}\�w�N\�\�z/۝ \�\�I�̮���֩�\�uJ�7\Z��I��A�\�y�\��d�2m`\�^�N|HKFow\�n�R�ɭc5mö�0�\�~�S��r\�\�\�t<Ŏ8\�M�i\�-2\��C���z*�\�}\�\�`�C\�_��\�qț\�\�\'�0\�g�(督�v\�\�pQ�aYV\�[=��AXFR�\�\�Y�\Z�p�|6R�����Po��S�z�\��\��B�9d\�\�\�V�؛�����jl.e6Nڔ\"S\�\���pσ��\�U�{\r�<���є\�\�*�F�@�V�Ԙ��:�W�V`�b6�]���hX��\�\�F\�G�8a\n\�\��\�hN�c�sѿ7�?�\Z*GLn�.���{^\�m}k��Y(IF\�s(���\���#\�#Y�+{|2���&%hZ�\�OL��~�\0\�:\�vڗ-���\�\����E�\�;=����!\0\�DZ�\�vW\�^���Q\�\"\��5y\0\Z\�G\�ل��J �]�\�٥\�C�����T��\�\Z���iR.!-:eZ�_���>�(q԰J\�d��H�qya�\0\�\�T��9\��\�\�/�Z�Ź��\�潩D��O�\�\"f4D�\�r�R\�wDPm5�-\�\�\�]P���>A\r���+\�s��њ�K^�_\�z\�A\�t\�n3�V\�)\�ٚ@��.g��E0{�H�\0.`F�Z��P\�qi7�k\��\\=����4ą��םL\�c\��\�+c\�\�\�\�ΩN h\�HH\�\�&\��O\r^��m\�\�K1�R\��\�\0z�\�?W��\'�m\�\�r���`�\�Ƶ�D\�*��\��Z��.�4��.\�D\�)&�\��Z\"�\�1yȐ_\�2\���e��ǧ�X�v:�r\�^*d!!ܭ��;����@\�3�\�=�?\'�kR\� \�Fq\�u*�\�\"�ˆ0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O��caY�r\�]��C\�(��%�g\�F6�X�͟+�\�\�\�a0Qԫ$]#cJ4 �\�HZ�&f�:\�dפ3b�\�\rs`*�\�%IZp��\nJ��%I\�2�c8�\�q�|\�8\�?�g\�qϞ8\�~�S&�S���\�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o��^�V��po9�\�k;:\�8�4\��\�*�n8���j\�`�d|\�һ���i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��^뒠\�\��*\�2p\�\��v#f\�~\���\�֫\�﷤�B�/�j��\�U�)����^��ĉ�,y-`\0$|kk�ם:4�����\�0\�Mj8�Pߨ)\�\�(��Y\�\0-yVQ�ې&E���\0�@�>,�\�>�����\��\�8\��\�c\��K�����?��\�m a\nm3\�;sR��$\�%Զ�\�*ͭL�԰\�\��\�M\��q\��u�a= \�^�LrE\�} �ĚXRra�]�ޱMd~4�\�D\�e-�8\�\�eᲬ6�[§�q\����t�\�\�\�TB��:�)M\�a����G`t�\�+w\��0ٍ��\�XC�o�K�ˎp$Q�K\� vY �3\�%�\�K㈲\\��\�p㌌ᅸ;KVP\�\�!M�*y̫��8\�u��-��;99��\r��\�rB}\�^xt;����^�o�V�ZR���9\�g��\�',5,1,'','','',''),(3,1,0,'9305bf00a1f6a976','59483894715bf00a1f6a24a891914929','2018-11-17 12:31:27','2018-11-17 12:31:27','','','Profilbilder','person-300.jpg','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\���(: �+��j�o��6;+\�Ya�>\�U\�aZ\�\�H�bqc1oz ���\�\�iق%a��\��-��]�\�o)s\�^\�x[�-4й\�\�\�;��\�jT[���P\�lQ\�\�-�-\\v\�$�\� �g3N�]c\�F�|J���^���f<�:Ī\�u m�Y��ȬƼ��1[\Z�,En֨E��SpRƆ�\�AKp����\"K5�\�c��\�\�k\�{�\�=�z\�\�e��V�<\�}O�^�����ԋʜ9E\�\�!\rf*9ʱ\'9�4n�T\�+NS?Ƈ�!���j5X\�&�\�<1)d\�-���\�\\u��)_�\�]�lQ9���1Y\�¸K�!�\�\�`5��R�_\�\�\��y\�P�\�Ұ �\�h{G\�\�1ԏ P�\�\�\�ۦ%�<\�U0��]y?�gp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�۔…�a�\�\�a�YXj[�G\�ؤ\r\�0�\�B�\�<\�\�uo���y\�j:齟����!5\�6�O\�\\�D���|���@�V�\��<|\�\r\�\�Rľ\��\�vM��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\Ή�6�\�u\�x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7�\�+�\�,;:\�\�<Լe����\�KM�…�\�81f\�-��cM�Y�g�8�B�\"=;}��{.��v\0N\��U�XR�\��EFKi�Dl��uW\���(�*�\�l���(����\n\��ò�\��\0����\�\�bRuۆ\���q���\�\n\�\�;,\��q�i�\\�屋 3�:MUd350\�\Z�/Ř٢\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"���\�N�\�\��멶���c�P���|k\�\�Y�\�΀�\��!Z��n�M8��x%\\l�D<>�U�\�\� \�03EW����\0�\�\��W%o�ſm��\�]\��\�;\�\�͋l\�\�\�f]�ռ�|\�&A=�1\r�\�#Å`�]1�\�\�E��㛑��\�ؙ|~N\�*Yc+�+*�a� �j�\Z?Ћ�K}��$L\�Z\�\�Q\�β�\0P4`�4h�#\�\0\�G\0Y��!\�шBZ���k\�\�n�as\",=\�|z�\�\�#s/ky�q�M� R]\�S\�-H�kiu\�66\�r�������f\\fGx$��\�7z\��[�\�v������F� ^\�l���{\�\�\�\'$à�H\���y \�^�D#�\�bz\�:��Xd��vF4/��dD۸\�l�3\�͈f��]\�\�c�\�>��6��~�\��\�2��ϱ�8ke��\�ɇ\�\�6\�k�enq�Ɨ�ˠn�\�g/1�كs\�F|�\�<�o\�Rܵ-��&ͤl\�;v�=�|;�r\�\r\�a����k&@1��`\'?Hϛ�c ��\"\"\"\"\"\"\"\"\"\".2a b0BB��\�1�s\�G�8k\�\�.{ݜ5�\�s�\�\�UFަ�!�]\��?�\�Z\�\�\�> \�\�d�\�ƪ�\�\� ���IK\�[�@\�QK\Z\�m�_��)�X<}M�!>.,bb:��=\�\��\�䯻|G\�jO��[Z;d卮$W\��\�z\�?a�!\�8 \��dmf�L\�<++��n�&|\�6\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�S\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�J�>\rm�s>�)�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���X\�܍J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8 9R�\�7\�ș>�W�n\�I�#\�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W��\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i!ʾ\�_0\�\�{\r�kA\"\�y�\�\�jwƯ��X*\�Z[\�l�3�Ɋ\�\���\�Il�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�YxF5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\".\"u�w��\�1�p%\�\n<�9~3����fZ���3\�Gc4�\"̿zF\�ȝ�\�Ƨ�V�\��ge8n\�Q\0഍�\�w�M��w�\�:�\Z�+C\r�q�oaC\�^Z������������\�\n\�(�\�|�$Y,οsOl��a\�G.�w�$\�#\�3\�\�=��\�)9p\�\"LS�y0FAU|�\\��\r�u�O�Rx��\�c\�m�f|F\�ceԄ\�\�9\�\�0���kr\�\�;�ynpϕ������H^�^\���ït_�\�\n 9�3\�:�^�d��\r\�\�\rMt`��!M����G5�f�$a��qI{ҋ\�}��<�q�+��w\�]T\��^U\�mp�ه\Z\��^�\�4깂v 3/��ska��\�\ne�.dlȌ)v\�\"\"\"\"\"\"\"\"\"\",>\��Y�;�\�\������8�j\�!`I��u��M��P\�8\�hh�1����\�y,�G-n��\�Vͤm�r(��7`�\�vjYyeS\�:������9 ȯ��&!��Y ]��\��vz���7���\Z�đ�yL�>^\�~M\�\�b\����a�W��a�`o V}z��`����6��%��\�@_֫\�`�n\�V�>Q7�;\���e�F��\�c\�t\�z�����\�vHn%By)Bh�`ϯ��~��\0�: \�\��)\�M�\�gk?J\�\rŒZ\�\�kSv\�!}\�\���]���\�`ٶ.6\�m�<8w�=<\�\�\��\�w\�a�p{x�T\�N[F¦�\"E,��\�d\�\�wV�}=�%\�|�w�;�4\�us��]��U�#\�u�a#XB���Α���;#\����\�1u���hB��`;x�MSZ\�u�M;N��\��}n�5EDaî���<ᕷ���uhO\�\�Myy῵��|�Ʌ[�\\\�||;9\�-�\�X_�q\�\�Q�G6s\�\�0�˱�*|נq=v\�\�|i�\\E$ꝋ�4\�+HB)Y�\��m|أ0^3�#H(�Ae���\�v0\�_ \�U\�|}�\�\�B\ruef��ƒ`4z��Z&c\rfZ\�61�\��\0,�9vs��DDDDDDDDDDDX\�\�>\�u�:U��\0\�p\�$\�\�7�^\�[\�\�\���H\�>l\�K�+�\Z�ؑ*D`5�|�aс\�Ϭ۠\�e(��o\�\\\�\�\�[ ж��;�ͬ\�cIJf\�D]�\�y�<�5\�-tV\�9 d;n�\�\��\�\�=���B�5�ƺ�TKZ\�O߻z�^\�i\�oc[[�\�j�\�\�\��ݛ�`bO)?4S�6\�8>�^g~�+�v~���P\�A�\�\�l�5&տ\�Qd\�>\ZVi��ŔyVq\�\�\"4b\��<�#\ZQB\�|�\�\�e�\0i�Y�\��H���Ok9z\�\�\rͅ�GǴ\�\"6S+\�\�\�u�̭v��2_(5������:\�#|\�\�\�x>�\�q\�S��V��<\�%��Rޢ�F1\�;&D\�\�\�\"\�?�\�{-��\�\�D�hB\�\�n�u�\Z;�\�\��gٷ�\��40�Pjϱmv�r\�R�sO&=n!WG�ai(q�k)�6Ǒ\Z�\��\�n��+Y\�\�)i�ekp��\�\�S�mA�̰�|�\�2)#5\�\�v��nr\�Qհ\�\\\����\�]&�`֮,�/�f3�WsO4\�֕ҙ�� �\nlc\�;0\�a��\��]���H:o)�\�f���U\�\r7d�+FC8ph�:\�9o�B”�`\"�\��B?8���vp\�\�\�\�\�/�-\"��\"֡�[B�\'a\�\��hj5MR���\'�I�R�\�!2%F��\�x\��uq��-k���\�/\���请_��D����� �\�\���v\�n.�&3\ZH0\�UCed��z\�\�g\\+מ \�~�]�p�\�i�Q�\0�ǚ�.�^(�\0c��#*�F|�\�\��IO9\\\�l���\�q숈������Gx��\��\0v-�ǚĨ�m\���\�S` �՚\�3L\�\� Ps� \�\�bm���>d�f+G\Z|Q\�/�\�_L�{h��\�#�u)6yC���p_\"\�\�i\�\�\�_�\Z^���|\�~e�\�4\�eK�\�1䝵\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ�q\n�;�\�\�\�M�(�\�$�\� \nVT�\�\�\�9�X\�[�j�\�\�:��\\lJJ���8�\�5\�O�&.J�Ǩ\�\�ȍ�o�\'Ģ�#0�\�o\�A\�n�K:v\��Ym7�[\n\�C@\�ͪ,\�5�7\��ȉ��Y����\�\�5~(\�?�\�\�\�6��]\�([\r\\\�\�\�\\\�+h\�r�>�K`NJܖD�lF5�\�\�C��\�aշ]7x�\�=\'m\�w\n\�9�}��}U�Acߌ\�t��r\�\�\�]��$\�s��8\�}��:\"\"\"\"\"\"���;\�~z3\�\�\�݅�\\�\0\�V\�q��J��\�lڎ����>\�*\�ð���\�\�d\rM�)Hz}�\�\�\�\��T%EE���e\r\re�\�\�݄*�jj�Rl�m\�l��ueet1\Zd� � ��E dʒQ\0y^\�\�\�?O��*],\�c\�5�2�5\�pk��yڢE�$s\�|ɚ��W6Id\�m&�C> �bZ޲\��\�Us\�HwȈ���������Ö�O��\�R��soh<��ZG�\�V\�MN�o��\Z[X\�`-m\�)��)c\�)� M����ݹ�\'��\0H\�J;+�\��A\�9D|\�kT\�e\�\0\�;���\�ǟ\\iz�Y�LJ\�\�\�U,��5\�\�M���\Z��|��|�a\�q��:\�{y��\r�l��8+�j\�˧�\�D H�Զ\� Ք�,%O���\�V���eeu\�igL\�\�ת��������\��\���Ѡk\�7$\�Xx���\��%l�N,�\ZV\�q`\�����*mt�\�.�Yٞ4���!�a��0^\��#p�df\\\� �vǷ9k��9�\�3��DDDDDEQO��w=��˺\�f8�Y+j�C�\Z\�L\�8�\�v]�b�^f056\�\�z�P�&t�k�$�\�YO�J�v �=\�\�A��\'�z��-+���\���\� ��S\�b˃8Z\�Dɻ�i�2\�ƣ�#��g\�\�tDDDDDDDDDDXs\�N��\�\�gl\�=�l\�\�yz�\�@6l�.\�\�ōi\�mۆ̩��\�e�tS���\�g\�(i��\�\'��Wv�b\�O�am�Y�\�G �\��4\\�\�ƒ\�Dz�5�d\'\�Q8�\�jG�˫�`dH�\n�_\�4�Jל\r��k\��\�\�f\�K\�V\�(�\�\��\0 \�\����A\�\��\�\�6 ��1�}�[����A\�����\�9�iqb��8DDDD^s\�!�\�%\�-�1\�\ZF�\�[�\� ,vX\�\�\�l\�\�f�\�X\�>3H\�c8\�r\�|gʇ\�B޶P\���Kۤ�f\�\�{�ͽl�7R��\�m�ԑ�\�+�3\�XI+\�\�k�ܽ�\�\�6\��c�i�u/\�G l���#n\�DX}�\�\�7��\�m���(j�A �L�\�j\�\�28�\0Dy$��E\r�\�\�4�Q���x\�\����;��\�\���\�Yrwl\�6O׭\�N�MH\�?Y\�\Z�o��\�Z\�q,$Fq+�\�s\�EP�r_&���t�S\�\�g��\n�f\�T;\0\�UΨ\�u �E-�s\r[d\�\�E&\�G �_.�\�4�ќ\�Wst?��\�\�\�]�=J\�\�g1qV��\�\�`�\�\"�`���k\�i\�SG�:�� �R6>2ƌ�Fr\�\"\"\"\"\�\�.C�\��/�\�ȕn��\�:\�\�6U��r�o鿍93Z�\r�9\�? ~[�g?\�\�/|���9���\�S$>7\"r�\����m\�*( \�\�\��H\�\��� vXL�\�{{f�� L�\�\'Sе�q�Pi\�\�F�S$aD\0�TA \�\��\�V�\�/�\�/)��\�s�\�ڈ������������QO)�a\�{��n�q�T\�\�0��Cfd�� \��\�\�u�<��\�[��8��� a���\��\�ףw�1�x�\�x�1\�7�Gb��{\�>[�\����X\���\"_�_ &�\�\�vZ�t�Q�|\�\�r�\���\�kb�m�:\�ϧ�>\�=�lMq\�.`ݙM \"\�\�\�\�/\�X\�4���\0���\�o-�\�\�ŝ˘�e�L\��u}Of\�\�\�#~\�3u$i\�+X瀱\�a\�\�6���p>��4֨;9ҾR��\06�H\�P׶[�X�e��m9\�\���iI\Z����c�#@kI�[�.n6I\�iqҾ\�-\�\0\�\�R�o���V�>��7���\�<\�=�\��\�\�\�kH����{���v\�wdui?�ۄ�(Nkǂ���k,bp�\�81#�Yw\�������So���<\��O ț_+ϒ\�\r\�dp\�\�W{Ng7���\0ZBe��\�\�9!\�\\H��������������\�\�k\�\�\� \�~�{P�W2k?~p\��+\�\����\�\�|>M�$\���\'\��\0P��c#˳�>�]n\rO��\�4 Ǘ�vGw�c$bh\�`j\�]z��F�1\0\"1\�˜\�\��\�1�keˆ�������\�w\ns e\�l\�-\��s}�XčJ\�9\�}��0�\0|{c\�\�\�\��UR�O4�m�e�vX�6�H\�a\��|��q5�R \�Z\�\����\�\�\rvK�s�\�9ض�Bc\�\�[�����_�`�Wj����\��W�\�}/돷�\\;\���>�\0\�\�\�H�~!�iФ` \�s\�4�\�j_\�8�5z\�D\�p<�\�\��]�\�\�\"\�~�5�k���DDDDE�u�\Z9\�VU5�\�\�ئ6\�\�H~ϲ\�c0L4\���\��7\'��7�\���\�\"\"\"\"\"\"\"\"\"\"\".��\�4\�\�s�l)��b\������ #\� {\�V�?`Y�|�\��\�q\����hi�\�t\��c^\�\�\Z�\�j�\�y�`\�k\�ֱ�~\0\�\�\�[�;\��~\��Ȉ�����������������������������������������������������������\�',4,0,'','','',''),(5,0,2,'9305bf00acda37f7','52226322615bf00acd9acbf086558812','2018-11-17 12:50:03','2018-11-17 12:50:03','','','Contact Photos','1.jpg','image/jpeg',80,80,2355,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0+\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���9�ZM\�*��x\�\�.d% �\r1!h�u\�GS6���J\�\�9j�džBÏ-��_��e9+\r 0p�\�m<�\�\�Ƌ���\�?Jt>\�l�\�\�ϭ���u�1y[�U�:�^R���\0�rT �Ƕ>,G6�\��\�9\�� �\�\�\�o�CN0�!.��.�-�ӭ/\nC���)!i\�V�\�*\�q�㕈w�{\�C\�J�g]U%,}$�\��\�p����\�\�I��׉�\�\�5��W�ț�$�D�E�W�I.f\"\�\�A��\��պ�\0t\�KDu\�Y\�\Z�%ލj�R\� \�n\� 2�4��\���\�J ��0��f0�L<\�#��N\�Ҏ�v��@F 37��\�\�W��K�����LE҂�C2��I�H\�5)�]i\����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��vb�?���N\Z \�\��\�\�S 6a\0��\0p��|;���8\��s�:S��7`�3��x\��5�2\�,�\���y\�)\�\r \�\�ɻ$��\�DW��\�t�iR\� fԷ=���\��\0�B\�\�mg/Ň��\�{\Z\�\�P�^��\�Z\��|\�!���v\Z&��I�\�^\n\�X<���\0��f\\�,��,zq�+u�\�c��.\�\�A�2\�\�;�\�\ZjI�\r#;Sؓ�z\�,`ͤg�R��2/\�Qe��N�-��鵍u�\�U�� [�R\���ڽb�:�����Xب�Fm �C2;-� B1�x�o:\�V\��\0Ww\�W��?[\�ڪ\�N��\\\�XbA���:�e]�X3�㭶�1\�4\�\�՞i�\�\�\�V[\�>$<�E��-{?j1��Ƕl�\\V�ޓ ���Y\"\�\ZQ�`e?d��\�fi��MzC6*�\�\��6���T��\nJ���RT�\�)V3�|gǾ3�\��\�q�g�\�j\'岙5\�O\�?I�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o�\�~�v�CZ\�\�s\r�\�v&t#�qxi\�[��|�\�q\�{�V�f vGƎ�\�Տ���\�^�n(v�\�;���\�\�?+S\"=)r[\r�%��u\��.���k��I��\� �Lɻ�yܪa���P\�\�8R\��\0t %W�Y�/O��\�ZO���\��\0\�D�o�-� �\�a\�\� �^���-��\�,R\�\\u\�!Y� \�tu��\�\�U��5�t\�\�\��B[��Q\�\�\�\'�S�Y�\"P\�\n\�>VQ\� | #x˥��\�4��\�q�m\�Z����\n���\�5\�#g�-Ӟ�W]ۦ��Zjy�bN�-\�r53c-{{o\�+\�D\�\�\��P*���#T��-L\�\ZCM\��,fj�Y@\�g\\LnF\�#N�\�W\�\�-8S�HY\�\��t\�I�\�N,�\�ϳ\�\�S�\���^\�+�w~��Ak>�\�m����\�l���}\�h\� (��MN\�4,�^ J��1�(\�2\�b>\�\�\�{�(U��՚�ۭ)jdvvӢ�\�\�ڞT�\�Rw\�\�ƌת1\�\�\��3Z�^�F\���¶�\�r\�$㦣c\�a\�R&X$\�\�\�\�h�$c�a��8\"\�Z\�$B\�u�!����q6�!X\�gO�\rv�\�\�]��ZA/3{��^PQ[ˎ_�\�\�4%�5\�\��\0�%_rL��e\r�C\�^2���\� 8c� ��0��\�\�\�\�#�\�\�,�\�1�6\�M�(m \��\' N1�c{xy��ՠ/5[-&\�\�Z\�_��\�!dGl��x�\�\"�\"\�䭒><�%�R�\�eն�\�*\��+\\�c|>o���b_j�\�m\0�&\�Q\�<\�E\�-�A��Zv�\��� ��Ɍ�ԏ.y��\�\�M+z\�Ga�\���\�J\�&�\�\�Q�\'�۫\�|0�\�&G�(\�p\�<\�s0\�\�z�y|�r\�S�h]\�\�n�\�H|j=[P\�`vm�\�P�YE�SGM�M���\�\�\�$��\�h\0S9�\�\�J\�LJL}X�IQ�C\�$�k��gD̛�\�:�ռJ�t\\WU�u}A�\�L\�JsC\�V\�m�؂P�b@�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1>CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�9\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�7\��`\�\rf�\��a\�\�#�\�w^\�s\�\���c�\�j^2\�\�|\�����AaB\�vN�\�g\�\�1�ʋ�,\�3Ŝ E!_��\��=�U\�;\0\'u��\�,)E\����%�\�\"6@M:��\� QH�Yy���Y\�}b\�\�υf���\�\�_\��\0\�OE�m\�1):\�\�t\�^�\�y{l�c\��d|��4ͮ|r\�ņǝ&���\Z�s�\n[\�\�l\�DGԃ\�\'B\�lzu\�\�J���\�(I�]���u\��\�l\�@~d�ǐ�BR7[��Vռ�6`,z\�\�X�׵\�\�\�\�}�\�wֻFٴZͼض+ɧ�����;\�α��%\�<�RNG��#�\�>\��n1�|l�߷����\0�=�\��x\�?�\��S����g���\�{�\�KI˵\�\�=k\�a�&���\�\�\��S(#x�\�kY&�N�\��\�97�m\��Z�����Y��\��ڨ�uE���(�@�8��b���\�{\�٭\�\�\�sݜ�\�v~�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\���&\Z\�΍o\\\�9A/0\�p��4��\�xF|i\�\�C\�\�uXm.���S4Uq\n�\�ʟ��\rM\�%rV�\�[�\�\�<��\�o��]\�ضͮ�Ve\�\�[\�\'\��dك\�\�2<8QF5Ѓ\Z��4X1�\�G_�\�s�s���\�4OKW��v_x��ڍ3\�\�~9��N���\�\�\�B��2�r���\0���!���t�\�\�D\�E�u�\�+@\0E\0cF�F��8�72����\�\Zہ\���e%\�\�>\�ԁq\���^�cog/\�o\�+\�ɘ\�e\�dw�J��w�OŷGi����{oq��5\�Ϗ����c�\"rL:\�!�\n�����e\�B9��1f\'�Ө/��O\�gdcB�{fDM��&\�\�=��؆k*�e\�\���~v1\�c\���\�i\�\��\�\��)�\��#��[H�<�|��nږ�VV\��i{\�}��I��L\�Kf 9\�#\��򙿍KrԷ\�8�6��P\�\���1�\�\�XW5�\�\�#ᬙ\0\�ـ�Dx\\�m#>\�7.\�eDDDDDDDDDDD\\d \�2\�`�&<�)� cf2琏vp\�1�\�\\��8k�\�9\�1����M~C\�\�\�ҵ���|բ\�\�9�Ug��{\\����v�0��5�\��U,SH� x��&B|\"\\X\�\�t\�l{\��\�\�\�_v���\� ���[\�;d卮$RH�\�z\�?a�!\�8 \��dmf�D\�<+��n�&|\�2\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�\�\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�dJ�>\rm�s�1�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���\�\���J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf�`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8�R�\�7$ș>�W n\�I�#�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W�p\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i\"J�\�_0\�\�{\r�kA\"\�{�\�\�jwƯ��X*\�Z[\�m3�Ɋ\�\���\�\�l�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�_`F5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\"/\�u�w��\�1�p%\�\n<�9~3��G��-~XB�#��]f_��#f\�N�t\�S\�+Mk{3��7b(�pZF\�k�\�&\�{;�r �]\rE���\�8\� 7�0�!\� /-DDDDDDDDDDQH��p�D�V>V�,�g_����Z\�0�c��}��ƒd��\�b\�x\���m�&)\�<�# ��E.OF�\�:�\0\'܇�#v��\�Bp��\�\�{\�\�5�nq\�<�8g\�\�DDDDE�/Q�/j�E\�׺\�l��\�\�}�~rY[�g��0@\���\��G#�֊3O�\n0�\�\�8���\�E\�\�ϞX�ӕ\��;���k�/*�|lÍm{�]\�\ZuT�;��\�˹���x\�h2�C�26dF�f�w��ԝ\�\�dz\�|\�?�\�S�k��0$\�C�\�y&\�K�l�b��X��|��\0I]�f<�|�����U\�t]�f\�6ڹ{V��\\\�5,��ҩ�~\�ME\�\\���dW\�C�\� J,�.\�\��{;=}Iқ\�Z\�\ry�\�H�<�D/q�&�\�1 �`]}���\�\�0\�07�+>�JI0YNJA�L\��\�\�p�����/\�UZ�t+]�(��}���\�|@�ȣB\��S�\�:f�{\\\���Vtٻ$��<��>ql\09����O\��\��gAz<V\�-i���\�g\�\\��2K[�-g���8\�G)3�P��[\��6~+�{�\�U\�˪E�ę;,\� pa����n�s!�\�\�.\�f\�8\�\�a\�H����kaW�\�7�\�P��\\\�B�RX\�e�M,I�\�C�^�\����q\�+\�R�3X\�+X\�hdF�)�}V\�M\�q(8�\�\�cDW5�\� 7�\�c�\�c\�w�|㳬��\0e�\���\Z�iY�밚9[��\�\�ck���Z�s.u��\�G9\�x�\��\'J�aΦ�=�\��v��{Mʙd}��v\�\�أ�2L\�\�V�.�h�j�Hh���\�|�q#�\��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0���S\�2����&�\�.�\'\�hN \�mSfǩ�(\�5��\�ljb�1�F�ƍL x�\�ƌ \0F֌A\�шCkX\�5�cp\�c ���\�x\�\�|�t{��@\�\�\�\�xF\�x;c���\�#\�\�(\�\Z91�1��6̋\�*}T�Uc#8�*i9�w�\"\�6\�1\�\�b\�Kߴk\�\�\�׫\�\��{:k�\�\�2b��s\�Vg8i�̊S����L�r\0rt�h��o\�\�Oc�N~�n\���\�ԇ���v\�\�i\�Wg��>E\Z4cQ\r`\�#�U�b\�G���:4\�9t\"\"\"\�1�E� �\0\07�\�3\� �\"n^B��\�X1��s\���\rcq�;8\�3�Qw�\�v\�\�/�;�k��u��6\�\�\�c��\��g��Z�\�~����2noŠ�L�Z64Y)g�\�&.#��\�\�\��-c\����ߩ�c��J\�皭I�\� \Z\�\�Ut�\�$�\�o̸x����\�(,cB\�+\�Ǫj�֋�\�i\�u%n��\�u��(��\"�m]t1\�q\�Ŏ,a�c�w;?\"�y����{�\"\"\"\"\"\"\"\"\"\"\"(J���瞵\�\"�Y\�\�Ys��[�\�\�z�s\�X��\�7*�\����\�QHi O���i�G��=n����^7{\�ɾ8{}\�}�\�d�Ԭ�G޵܋\�\�\�7\�\"�w\�?̯\�\�έ fS�C6\�]Q*�s+�.����َ\'\��c�Ka���\�)k5\�%�;-��c��*.!��UsZW\�=�]�XVQ �6\\(\�wՌ�DDZ��\�Vg�\�\�\�g��nըu\��H.3\�\�N�k\Z�m\�\Z\��8�#_\�\�7\�\�D�\���^lٖS%\�\�˓>\�|�͝:i\�*dْ��ʗ.Q\��ȓ \�y�s=\�1^�\�{���\02�\�]ӊ>���\�S2�\�w�\�Ճ��&ma*�q�A�Z�KȦM�Z�\0`Ћ�\�L���\���cߚ\"\"\"\"\"\"\"\"\"\"\"\"\�0C(&�$\"�\Z@��\�aB`��BaaF9\� \�ܱ\�\�Z\�g\�U��n�pwM{ˣ�\'��Z�\�F��r\�\�T\�cck;\�\r\�L{\��\��\�\�n\�\�b\�b\0�i�&ۘ��ml$�+�k~�N\�s\�N\�r\�P��ϴ\�\� \���+Z�)3!꼉�\�\�M=t\��\'\�P͎�a�ͭc��\�I���V\�:\�\�\�DE�?S\r5\�\��\���&n�s=��\�\�t�㲽a~9ǴsUF�\�ϻp\�\�.\�p��^�\��U\�G)�q��v.@\�h�!� fW[\�u��b��x\�\"H� �aA�n~7�\�ñ|\'UWQ���OQ5Օ���\n  �X\�\�b ah��5�kq�\�\�=��\0�]��9\�{�\"\"\"\"\"\"\"\"\"\",h\�q��\�*\�\�8q\�ku�]�e��br\�o�\�$\nw6s%\�@�\�H�\"0\Z�>@�\���g\�m\�n2�zη�.vl䭐h[XNՁf\�e�\�Y3v�.\�����X\Z얺+s��2��T\��]�t\�\�\�K�}\Z\�c]\nª%�v��\�ݎ=@�\nh4䷱��\�\�5X�\�G9{�`t\� I\�\'\�Ɗs\�ܧ\�\�\�\�\�%r�\�\���jH3�ܭ�F�ڷ�*,�\�\�J\�7Q���*\�<�F�]\�6@\'�dcJ(C\ZO�X�L�\�1\�3��\0\��>i\�g/\\}���\�h���}dF\�e}xN����\�\�\�K\��R>�\0�r\'X\�$bO�9Y\�\�N<`�v�\�\��d��\�[\�WH\�=����dș\���\�Xg�����\��\�o�^4!m�\�7 �G�ދ�v\�}�\�\�\�\�y\Z�(5gض�K9q)`�������̰��8�5�ǛcȈ�\r|�p\\�\�tO�\\ ��,��ڲ��{\�oi�\�6��fXL>@l���\�\�;G��79v(\�\�h.uK�\�[c��O�kWT\�3�̫���j\�J\�L\�]�I�:1\��s�҉�\�s�{�\�\�\�$7��\�o�~E[����X�!�84{m��\�!aJ\\�H\� B!�|Xǻ8no�\�\�h���{\0Ñ\�P֭�H�\�%�4)��7�9ÆP��c��\�\�v3�\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $K�ca-\�#Ǝ\"��\�s�D&q�� �j�x\�{�xΉ\Z����jó\�p�M��8��;�#�4���\�j��b��\�b�?�G�\�\�Ŵ :��\�y+\�lZ:��\�k;Ȳ$f;lm�\�\� �Ξioc\�\�&��T\� X[8qy���)lA!bTh\���GW\�\"ֹ_\����O\�~�\�p���\�I�\�\�\�\Z�\��-\�i�m�\�\�c1�� UTVP&J���>Z�w½y\���\��\��p��U� \�\�y�R\�\�\��8\�2�$g\�\�J\�$��\��˟�l{{\Z\"\"\"\"*�}Q�-l�?݋~\��ޱ*[{o}o�\�\�.5f�\�\�2{�B\�\�8�;��m \�ϗ>Ćي\�Ɓ_y�\�W\�#\�\�.\��\�\�\�JM���P\�\�h�ȸ��y���\�\�E��줟8��m�\��\�R\�5�$��\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ\�q\n�;�\�\�\�M�^(ѽ\�$��0���eK^m<\�󟕎U�֨\�n�\�:��\��A\Zk��J\Z٤}~\�ȟCC�k\�\n5��$\�մ� /�O�_�Kŗ�\��[\�AqO \�f��\�8�K�/\�%\�\�x昙\�~\��X����\��\0\�\�Y\�O1\�\r.\�W\�\�����\�\�\�0\���F\����\0�y�a��?%�5��|\�a�~q\�gI��TV:5D\0�+\�F��]��\"\"\"\"\"ž\�t��{\�׾@\�o?j\��m}�,7��k�u�qe�)�Z)Xp\�@��� [(\�H�$2b���@\� U�m��\��\0�˝S�`H\�x[m��\�$\�\Zxq�\�\�\�$\��xo���\�\�@�YF�3�+hq�\�Ӹ��y_GּD�I\�]��\�=6n=٢F\�y���8��\�z.g2W\�8�qW�h֏�X\��1�����lʶ\�\Z\�%�}9\�_\0w��5.��\�z�ݴ}�s���[k�o3_\�\�\n\�N����\�\�Ÿ\Z,�ʋ&9\�Q�FT\"\"\"\"\"\"\"\".�(\Z0�\"D�`@\0�!�c\��!�\�!汌k�\�a�\�UR��\�2�=�\�\�[�gf��\��lvZ\�cF!�C\�.J����\��y`_:��\��:М\���$&�in\"�W���\�;y\�ɧfu>�ptC|�\nӐ9\n\�)�ka��\��[i�h�\�\�4-(5�p\�\�\�-~�ΗWT {ʛ�z/Ѿ�\�ם/��}ա\�\�z\�l&^ߺ$q\�[\�\�8\�e�ݵM2I�ֲ�\"[��Q3 �\\\�\�\�DDDDDDD^a\��+Ľ�\�\�Ú�\�R\��?\�\�\�U_j{�4K��\Z���fF %\r\�d \�Ex\"a�(�,�RJ \0O+\�\�\�\���EA\� ��\�}���_f�\�\rv�\�;TH�\�y/�3S\�j\�\�,���ӨgÃ!�ulK[\�X^���|\�\0n��r\� ��=jS�.m\�\����H�\"Xj܉�\�m�R�Kk,���66E,c`�3\�d �����1d�?\�\�Geb\�\��6�#�<\�(��\�j� �\��c��~��\�/Q+ \�I�\\M2���������0�\�\�Sw�_O�`\�8\�?$rg\\\�o1\"\�!���u\�s-\\Ytⰻ��i\Z�\�\�:�����7\Z�\�\�0̬���,\��Z�Qq�po�^�w��Y\��~�4\r{F䚢\�r�\�\�D���œ#J\�.,X�3eM��|Ղ%\�\�;3ƃ1aR#��0 6& \�ADn2 �˘A��Ø�\�-ss�79\�q�Ȉ�����)�@w絞W9w\\l\�B\�m_\�q�[I�Q\'9.˵lQ��\rE������\� ɝ\'\Z\� (��A������\�Ozfv��)d\�\�^��f\�J\�\�k\�6>h�\�\"\�\�t��\�\��Q6f\�iZf �q�\�\�~�\����}r��\0vOG�\�5{&^�q�\r�.��:)cD\�uv\�*m���k\�\�l�{�\���J\Z|�x\�\�ݩظ�\�O�\�,\�m���G�H\Z.N\�\�Ipc\�W\�`� ��G\�*6ڑ\�2\�\� B�kW�M#ҵ\�b\�\��~;;Y���\n)2��\0\�;4�>\�u:�}�4��\�l�b\�o\�㽭��w>\�h��a\�\\@خeN��\�m;�yKn�wF���\�ﱂK�:9���;1�\�q�8O�\�5\�\�2ܷ\�Ƿ��������7�\��\�#��r\�o[<�\r\���m��u$bs\�\��VJ���s\Z�7/~q�Ͷ~�\�kK�Q\�\�%i&\�\��Qd7�\�ā܇\��o\�EJֽ\�i\�v\�\��\��\�\�\��sK\�\�\�k\��4�\��Ȝ�\��,�D~G)\�\�vʊ610\�\�;`/\���-w\�\�\�ٽ\�C\�(�\�I\��-jh\Zv�Q�\�ĉQ\0(UA9lp�\0�7���\�e\�#\���\�s\�\�QS�\�/\�\�7L<�vM\�7ꜻ&cu\�l̒>��:\�\�\�ǒ�d\�f\� c�\�\�X0�,5�#2��Z�n�&7\�;o\�9\��\�V��}\�\�r\�\"ޠ\��+\�W\�\�K� \�A�ߜ\�\�\�^\0���\"\"\"\"\�O�=2^�\�S�:\�lSM�\'\\��\��c\�\�Dz�����߾Xǃ9Ǿ}�x��\��\�icoq�Pn\�N쎭\'\�\�p�% \�x�V� ԭe�Nr\�g$v�.��?���\0��m�\��5����\�@!�k\�y�\\�����\�\�i\�\�\�8�\0# HL��yBY�\�q2\���ai\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���F�\�/�\'c\�!\�h�\���\�\�L�\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\���\�V\0 �� �RDDDD_\���Al(�\� �#Z�onZ�\�Z�=�\�\\\�c-ss�g\�s�\�L]z\�2~q��9�(# �\�kc�����\�1�ٍ�[\�lc9���S�DDDDDDDDDDDDQ��`�\�\�e�\�\��]ό\��r\�p\�#|��\������\0�����Xj�B�\��1fWVx�\�\�\Zܘ֔ ���8n2Be�2�{�\�\�3\�X\�m�\"\"\"\"\"�G�m�C�\���\���\�\Zia�<\�\�w.X坲\�\�!=\��c(\�\�|\01�\0\�l��\"\"\"\"\"\"\"\"\"\"\"\"��~�\��\0\�gi\�V2�\0�/<9����\�\�\�\��\�\�\��E��*V[\�?\��\r�#�\�\���ĉ\�K\��Ӿ���\0�\�hT�\��v3����DDDDZ�\�\�qO��.�\�*��\�s\�6RZC��l �(�\�z�q`\��n>�\n\�^\�dNs�ƈ������������\�c�\��\�c��?PfF��h\�\�\� [(p%�\� \�\�Gt�ı$3�0\��3?�ɇ�\�_\�=8\�<,��?��ڄA\���,\r^�Q0\�?710`x�ȱ��\�k\�\�\�*|*�f��uՕMdx3�)��w$?g\�c1�&\Zs�\���ۂ�\�\��\��_\�}\�DDDDDDDDDDDE\�7=F�|\�\�u\r�e55\�\\C�ѕ\���|a�x\�\�\�\� 3\�\�=�ѡ���Q\�kՍ{+hjk��\�Ga\�d\Z�a��{Z\�9\�\0�; n\�g8kq�c\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/�\�',4,1,'','','',''),(8,2,0,'9305bf00c122b3bd','90913473615bf00c122ac78338492980','2018-11-17 12:39:46','2018-11-17 12:39:46','','','Profile Photos','person-300.jpg','image/jpeg',80,80,2354,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0+\0\0\0\0\0\0\0\0 \n!#\"AB�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�\�\��7��M�\�PCm;\n\�f˲�\�A�\"&\n(�\�\�\�z\�r+\�\�*�������N\��1dVq��+:}\�w�N\�\�z/۝ \�\�I�̮���֩�\�uJ�7\Z��I��A�\�y�\��d�2m`\�^�N|HKFow\�n�R�ɭc5mö�0�\�~�S��r\�\�\�t<Ŏ8\�M�i\�-2\��C���z*�\�}\�\�`�C\�_��\�qț\�\�\'�0\�g�(督�v\�\�pQ�aYV\�[=��AXFR�\�\�Y�\Z�p�|6R�����Po��S�z�\��\��B�9d\�\�\�V�؛�����jl.e6Nڔ\"S\�\���pσ��\�U�{\r�<���є\�\�*�F�@�V�Ԙ��:�W�V`�b6�]���hX��\�\�F\�G�8a\n\�\��\�hN�c�sѿ7�?�\Z*GLn�.���{^\�m}k��Y(IF\�s(���\���#\�#Y�+{|2���&%hZ�\�OL��~�\0\�:\�vڗ-���\�\����E�\�;=����!\0\�DZ�\�vW\�^���Q\�\"\��5y\0\Z\�G\�ل��J �]�\�٥\�C�����T��\�\Z���iR.!-:eZ�_���>�(q԰J\�d��H�qya�\0\�\�T��9\��\�\�/�Z�Ź��\�潩D��O�\�\"f4D�\�r�R\�wDPm5�-\�\�\�]P���>A\r���+\�s��њ�K^�_\�z\�A\�t\�n3�V\�)\�ٚ@��.g��E0{�H�\0.`F�Z��P\�qi7�k\��\\=����4ą��םL\�c\��\�+c\�\�\�\�ΩN h\�HH\�\�&\��O\r^��m\�\�K1�R\��\�\0z�\�?W��\'�m\�\�r���`�\�Ƶ�D\�*��\��Z��.�4��.\�D\�)&�\��Z\"�\�1yȐ_\�2\���e��ǧ�X�v:�r\�^*d!!ܭ��;����@\�3�\�=�?\'�kR\� \�Fq\�u*�\�\"�ˆ0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O��caY�r\�]��C\�(��%�g\�F6�X�͟+�\�\�\�a0Qԫ$]#cJ4 �\�HZ�&f�:\�dפ3b�\�\rs`*�\�%IZp��\nJ��%I\�2�c8�\�q�|\�8\�?�g\�qϞ8\�~�S&�S���\�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o��^�V��po9�\�k;:\�8�4\��\�*�n8���j\�`�d|\�һ���i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��^뒠\�\��*\�2p\�\��v#f\�~\���\�֫\�﷤�B�/�j��\�U�)����^��ĉ�,y-`\0$|kk�ם:4�����\�0\�Mj8�Pߨ)\�\�(��Y\�\0-yVQ�ې&E���\0�@�>,�\�>�����\��\�8\��\�c\��K�����?��\�m a\nm3\�;sR��$\�%Զ�\�*ͭL�԰\�\��\�M\��q\��u�a= \�^�LrE\�} �ĚXRra�]�ޱMd~4�\�D\�e-�8\�\�eᲬ6�[§�q\����t�\�\�\�TB��:�)M\�a����G`t�\�+w\��0ٍ��\�XC�o�K�ˎp$Q�K\� vY �3\�%�\�K㈲\\��\�p㌌ᅸ;KVP\�\�!M�*y̫��8\�u��-��;99��\r��\�rB}\�^xt;����^�o�V�ZR���9\�g��\�',5,1,'','','',''),(9,2,0,'9305bf00c122b3bd','90913473615bf00c122ac78338492980','2018-11-17 12:39:46','2018-11-17 12:39:46','','','Profile Photos','person-300.jpg','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\���(: �+��j�o��6;+\�Ya�>\�U\�aZ\�\�H�bqc1oz ���\�\�iق%a��\��-��]�\�o)s\�^\�x[�-4й\�\�\�;��\�jT[���P\�lQ\�\�-�-\\v\�$�\� �g3N�]c\�F�|J���^���f<�:Ī\�u m�Y��ȬƼ��1[\Z�,En֨E��SpRƆ�\�AKp����\"K5�\�c��\�\�k\�{�\�=�z\�\�e��V�<\�}O�^�����ԋʜ9E\�\�!\rf*9ʱ\'9�4n�T\�+NS?Ƈ�!���j5X\�&�\�<1)d\�-���\�\\u��)_�\�]�lQ9���1Y\�¸K�!�\�\�`5��R�_\�\�\��y\�P�\�Ұ �\�h{G\�\�1ԏ P�\�\�\�ۦ%�<\�U0��]y?�gp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�۔…�a�\�\�a�YXj[�G\�ؤ\r\�0�\�B�\�<\�\�uo���y\�j:齟����!5\�6�O\�\\�D���|���@��+\\�c|>o���b_j�\�m\0�&\�Q\�<\�E\�-�A��Zv�\��� ��Ɍ�ԏ.y��\�\�M+z\�Ga�\���\�J\�&�\�\�Q�\'�۫\�|0�\�&G�(\�p\�<\�s0\�\�z�y|�r\�S�h]\�\�n�\�H|j=[P\�`vm�\�P�YE�SGM�M���\�\�\�$��\�h\0S9�\�\�J\�LJL}X�IQ�C\�$�k��gD̛�\�:�ռJ�t\\WU�u}A�\�L\�JsC\�V\�m�؂P�b@�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1>CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�9\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�7\��`\�\rf�\��a\�\�#�\�w^\�s\�\���c�\�j^2\�\�|\�����AaB\�vN�\�g\�\�1�ʋ�,\�3Ŝ E!_��\��=�U\�;\0\'u��\�,)E\����%�\�\"6@M:��\� QH�Yy���Y\�}b\�\�υf���\�\�_\��\0\�OE�m\�1):\�\�t\�^�\�y{l�c\��d|��4ͮ|r\�ņǝ&���\Z�s�\n[\�\�l\�DGԃ\�\'B\�lzu\�\�J���\�(I�]���u\��\�l\�@~d�ǐ�BR7[��Vռ�6`,z\�\�X�׵\�\�\�\�}�\�wֻFٴZͼض+ɧ�����;\�α��%\�<�RNG��#�\�>\��n1�|l�߷����\0�=�\��x\�?�\��S����g���\�{�\�KI˵\�\�=k\�a�&���\�\�\��S(#x�\�kY&�N�\��\�97�m\��Z�����Y��\��ڨ�uE���(�@�8��b���\�{\�٭\�\�\�sݜ�\�v~�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\���&\Z\�΍o\\\�9A/0\�p��4��\�xF|i\�\�C\�\�uXm.���S4Uq\n�\�ʟ��\rM\�%rV�\�[�\�\�<��\�o��]\�ضͮ�Ve\�\�[\�\'\��dك\�\�2<8QF5Ѓ\Z��4X1�\�G_�\�s�s���\�4OKW��v_x��ڍ3\�\�~9��N���\�\�\�B��2�r���\0���!���t�\�\�D\�E�u�\�+@\0E\0cF�F��8�72����\�\Zہ\���e%\�\�>\�ԁq\���^�cog/\�o\�+\�ɘ\�e\�dw�J��w�OŷGi����{oq��5\�Ϗ����c�\"rL:\�!�\n�����e\�B9��1f\'�Ө/��O\�gdcB�{fDM��&\�\�=��؆k*�e\�\���~v1\�c\���\�i\�\��\�\��)�\��#��[H�<�|��nږ�VV\��i{\�}��I��L\�Kf 9\�#\��򙿍KrԷ\�8�6��P\�\���1�\�\�XW5�\�\�#ᬙ\0\�ـ�Dx\\�m#>\�7.\�eDDDDDDDDDDD\\d \�2\�`�&<�)� cf2琏vp\�1�\�\\��8k�\�9\�1����M~C\�\�\�ҵ���|բ\�\�9�Ug��{\\����v�0��5�\��U,SH� x��&B|\"\\X\�\�t\�l{\��\�\�\�_v���\� ���[\�;d卮$RH�\�z\�?a�!\�8 \��dmf�D\�<+��n�&|\�2\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�\�\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�dJ�>\rm�s�1�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���\�\���J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf�`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8�R�\�7$ș>�W n\�I�#�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W�p\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i\"J�\�_0\�\�{\r�kA\"\�{�\�\�jwƯ��X*\�Z[\�m3�Ɋ\�\���\�\�l�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�_`F5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\"/\�u�w��\�1�p%\�\n<�9~3��G��-~XB�#��]f_��#f\�N�t\�S\�+Mk{3��7b(�pZF\�k�\�&\�{;�r �]\rE���\�8\� 7�0�!\� /-DDDDDDDDDDQH��p�D�V>V�,�g_����Z\�0�c��}��ƒd��\�b\�x\���m�&)\�<�# ��E.OF�\�:�\0\'܇�#v��\�Bp��\�\�{\�\�5�nq\�<�8g\�\�DDDDE�/Q�/j�E\�׺\�l��\�\�}�~rY[�g��0@\���\��G#�֊3O�\n0�\�\�8���\�E\�\�ϞX�ӕ\��;���k�/*�|lÍm{�]\�\ZuT�;��\�˹���x\�h2�C�26dF�f�w��ԝ\�\�dz\�|\�?�\�S�k��0$\�C�\�y&\�K�l�b��X��|��\0I]�f<�|�����U\�t]�f\�6ڹ{V��\\\�5,��ҩ�~\�ME\�\\���dW\�C�\� J,�.\�\��{;=}Iқ\�Z\�\ry�\�H�<�D/q�&�\�1 �`]}���\�\�0\�07�+>�JI0YNJA�L\��\�\�p�����/\�UZ�t+]�(��}���\�|@�ȣB\��S�\�:f�{\\\���Vtٻ$��<��>ql\09����O\��\��gAz<V\�-i���\�g\�\\��2K[�-g���8\�G)3�P��[\��6~+�{�\�U\�˪E�ę;,\� pa����n�s!�\�\�.\�f\�8\�\�a\�H����kaW�\�7�\�P��\\\�B�RX\�e�M,I�\�C�^�\����q\�+\�R�3X\�+X\�hdF�)�}V\�M\�q(8�\�\�cDW5�\� 7�\�c�\�c\�w�|㳬��\0e�\���\Z�iY�밚9[��\�\�ck���Z�s.u��\�G9\�x�\��\'J�aΦ�=�\��v��{Mʙd}��v\�\�أ�2L\�\�V�.�h�j�Hh���\�|�q#�\��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0���S\�2����&�\�.�\'\�hN \�mSfǩ�(\�5��\�ljb�1�F�ƍL x�\�ƌ \0F֌A\�шCkX\�5�cp\�c ���\�x\�\�|�t{��@\�\�\�\�xF\�x;c���\�#\�\�(\�\Z91�1��6̋\�*}T�Uc#8�*i9�w�\"\�6\�1\�\�b\�Kߴk\�\�\�׫\�\��{:k�\�\�2b��s\�Vg8i�̊S����L�r\0rt�h��o\�\�Oc�N~�n\���\�ԇ���v\�\�i\�Wg��>E\Z4cQ\r`\�#�U�b\�G���:4\�9t\"\"\"\�1�E� �\0\07�\�3\� �\"n^B��\�X1��s\���\rcq�;8\�3�Qw�\�v\�\�/�;�k��u��6\�\�\�c��\��g��Z�\�~����2noŠ�L�Z64Y)g�\�&.#��\�\�\��-c\����ߩ�c��J\�皭I�\� \Z\�\�Ut�\�$�\�o̸x����\�(,cB\�+\�Ǫj�֋�\�i\�u%n��\�u��(��\"�m]t1\�q\�Ŏ,a�c�w;?\"�y����{�\"\"\"\"\"\"\"\"\"\"\"(J���瞵\�\"�Y\�\�Ys��[�\�\�z�s\�X��\�7*�\����\�QHi O���i�G��=n����^7{\�ɾ8{}\�}�\�d�Ԭ�G޵܋\�\�\�7\�\"�w\�?̯\�\�έ fS�C6\�]Q*�s+�.����َ\'\��c�Ka���\�)k5\�%�;-��c��*.!��UsZW\�=�]�XVQ �6\\(\�wՌ�DDZ��\�Vg�\�\�\�g��nըu\��H.3\�\�N�k\Z�m\�\Z\��8�#_\�\�7\�\�D�\���^lٖS%\�\�˓>\�|�͝:i\�*dْ��ʗ.Q\��ȓ \�y�s=\�1^�\�{���\02�\�]ӊ>���\�S2�\�w�\�Ճ��&ma*�q�A�Z�KȦM�Z�\0`Ћ�\�L���\���cߚ\"\"\"\"\"\"\"\"\"\"\"\"\�0C(&�$\"�\Z@��\�aB`��BaaF9\� \�ܱ\�\�Z\�g\�U��n�pwM{ˣ�\'��Z�\�F��r\�\�T\�cck;\�\r\�L{\��\��\�\�n\�\�b\�b\0�i�&ۘ��ml$�+�k~�N\�s\�N\�r\�P��ϴ\�\� \���+Z�)3!꼉�\�\�M=t\��\'\�P͎�a�ͭc��\�I���V\�:\�\�\�DE�?S\r5\�\��\���&n�s=��\�\�t�㲽a~9ǴsUF�\�ϻp\�\�.\�p��^�\��U\�G)�q��v.@\�h�!� fW[\�u��b��x\�\"H� �aA�n~7�\�ñ|\'UWQ���OQ5Օ���\n  �X\�\�b ah��5�kq�\�\�=��\0�]��9\�{�\"\"\"\"\"\"\"\"\"\",h\�q��\�*\�\�8q\�ku�]�e��br\�o�\�$\nw6s%\�@�\�H�\"0\Z�>@�\���g\�m\�n2�zη�.vl䭐h[XNՁf\�e�\�Y3v�.\�����X\Z얺+s��2��T\��]�t\�\�\�K�}\Z\�c]\nª%�v��\�ݎ=@�\nh4䷱��\�\�5X�\�G9{�`t\� I\�\'\�Ɗs\�ܧ\�\�\�\�\�%r�\�\���jH3�ܭ�F�ڷ�*,�\�\�J\�7Q���*\�<�F�]\�6@\'�dcJ(C\ZO�X�L�\�1\�3��\0\��>i\�g/\\}���\�h���}dF\�e}xN����\�\�\�K\��R>�\0�r\'X\�$bO�9Y\�\�N<`�v�\�\��d��\�[\�WH\�=����dș\���\�Xg�����\��\�o�^4!m�\�7 �G�ދ�v\�}�\�\�\�\�y\Z�(5gض�K9q)`�������̰��8�5�ǛcȈ�\r|�p\\�\�tO�\\ ��,��ڲ��{\�oi�\�6��fXL>@l���\�\�;G��79v(\�\�h.uK�\�[c��O�kWT\�3�̫���j\�J\�L\�]�I�:1\��s�҉�\�s�{�\�\�\�$7��\�o�~E[����X�!�84{m��\�!aJ\\�H\� B!�|Xǻ8no�\�\�h���{\0Ñ\�P֭�H�\�%�4)��7�9ÆP��c��\�\�v3�\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $K�ca-\�#Ǝ\"��\�s�D&q�� �j�x\�{�xΉ\Z����jó\�p�M��8��;�#�4���\�j��b��\�b�?�G�\�\�Ŵ :��\�y+\�lZ:��\�k;Ȳ$f;lm�\�\� �Ξioc\�\�&��T\� X[8qy���)lA!bTh\���GW\�\"ֹ_\����O\�~�\�p���\�I�\�\�\�\Z�\��-\�i�m�\�\�c1�� UTVP&J���>Z�w½y\���\��\��p��U� \�\�y�R\�\�\��8\�2�$g\�\�J\�$��\��˟�l{{\Z\"\"\"\"*�}Q�-l�?݋~\��ޱ*[{o}o�\�\�.5f�\�\�2{�B\�\�8�;��m \�ϗ>Ćي\�Ɓ_y�\�W\�#\�\�.\��\�\�\�JM���P\�\�h�ȸ��y���\�\�E��줟8��m�\��\�R\�5�$��\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ\�q\n�;�\�\�\�M�^(ѽ\�$��0���eK^m<\�󟕎U�֨\�n�\�:��\��A\Zk��J\Z٤}~\�ȟCC�k\�\n5��$\�մ� /�O�_�Kŗ�\��[\�AqO \�f��\�8�K�/\�%\�\�x昙\�~\��X����\��\0\�\�Y\�O1\�\r.\�W\�\�����\�\�\�0\���F\����\0�y�a��?%�5��|\�a�~q\�gI��TV:5D\0�+\�F��]��\"\"\"\"\"ž\�t��{\�׾@\�o?j\��m}�,7��k�u�qe�)�Z)Xp\�@��� [(\�H�$2b���@\� U�m��\��\0�˝S�`H\�x[m��\�$\�\Zxq�\�\�\�$\��xo���\�\�@�YF�3�+hq�\�Ӹ��y_GּD�I\�]��\�=6n=٢F\�y���8��\�z.g2W\�8�qW�h֏�X\��1�����lʶ\�\Z\�%�}9\�_\0w��5.��\�z�ݴ}�s���[k�o3_\�\�\n\�N����\�\�Ÿ\Z,�ʋ&9\�Q�FT\"\"\"\"\"\"\"\".�(\Z0�\"D�`@\0�!�c\��!�\�!汌k�\�a�\�UR��\�2�=�\�\�[�gf��\��lvZ\�cF!�C\�.J����\��y`_:��\��:М\���$&�in\"�W���\�;y\�ɧfu>�ptC|�\nӐ9\n\�)�ka��\��[i�h�\�\�4-(5�p\�\�\�-~�ΗWT {ʛ�z/Ѿ�\�ם/��}ա\�\�z\�l&^ߺ$q\�[\�\�8\�e�ݵM2I�ֲ�\"[��Q3 �\\\�\�\�DDDDDDD^a\��+Ľ�\�\�Ú�\�R\��?\�\�\�U_j{�4K��\Z���fF %\r\�d \�Ex\"a�(�,�RJ \0O+\�\�\�\���EA\� ��\�}���_f�\�\rv�\�;TH�\�y/�3S\�j\�\�,���ӨgÃ!�ulK[\�X^���|\�\0n��r\� ��=jS�.m\�\����H�\"Xj܉�\�m�R�Kk,���66E,c`�3\�d �����1d�?\�\�Geb\�\��6�#�<\�(��\�j� �\��c��~��\�/Q+ \�I�\\M2���������0�\�\�Sw�_O�`\�8\�?$rg\\\�o1\"\�!���u\�s-\\Ytⰻ��i\Z�\�\�:�����7\Z�\�\�0̬���,\��Z�Qq�po�^�w��Y\��~�4\r{F䚢\�r�\�\�D���œ#J\�.,X�3eM��|Ղ%\�\�;3ƃ1aR#��0 6& \�ADn2 �˘A��Ø�\�-ss�79\�q�Ȉ�����)�@w絞W9w\\l\�B\�m_\�q�[I�Q\'9.˵lQ��\rE������\� ɝ\'\Z\� (��A������\�Ozfv��)d\�\�^��f\�J\�\�k\�6>h�\�\"\�\�t��\�\��Q6f\�iZf �q�\�\�~�\����}r��\0vOG�\�5{&^�q�\r�.��:)cD\�uv\�*m���k\�\�l�{�\���J\Z|�x\�\�ݩظ�\�O�\�,\�m���G�H\Z.N\�\�Ipc\�W\�`� ��G\�*6ڑ\�2\�\� B�kW�M#ҵ\�b\�\��~;;Y���\n)2��\0\�;4�>\�u:�}�4��\�l�b\�o\�㽭��w>\�h��a\�\\@خeN��\�m;�yKn�wF���\�ﱂK�:9���;1�\�q�8O�\�5\�\�2ܷ\�Ƿ��������7�\��\�#��r\�o[<�\r\���m��u$bs\�\��VJ���s\Z�7/~q�Ͷ~�\�kK�Q\�\�%i&\�\��Qd7�\�ā܇\��o\�EJֽ\�i\�v\�\��\��\�\�\��sK\�\�\�k\��4�\��Ȝ�\��,�D~G)\�\�vʊ610\�\�;`/\���-w\�\�\�ٽ\�C\�(�\�I\��-jh\Zv�Q�\�ĉQ\0(UA9lp�\0�7���\�e\�#\���\�s\�\�QS�\�/\�\�7L<�vM\�7ꜻ&cu\�l̒>��:\�\�\�ǒ�d\�f\� c�\�\�X0�,5�#2��Z�n�&7\�;o\�9\��\�V��}\�\�r\�\"ޠ\��+\�W\�\�K� \�A�ߜ\�\�\�^\0���\"\"\"\"\�O�=2^�\�S�:\�lSM�\'\\��\��c\�\�Dz�����߾Xǃ9Ǿ}�x��\��\�icoq�Pn\�N쎭\'\�\�p�% \�x�V� ԭe�Nr\�g$v�.��?���\0��m�\��5����\�@!�k\�y�\\�����\�\�i\�\�\�8�\0# HL��yBY�\�q2\���ai\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���F�\�/�\'c\�!\�h�\���\�\�L�\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\���\�V\0 �� �RDDDD_\���Al(�\� �#Z�onZ�\�Z�=�\�\\\�c-ss�g\�s�\�L]z\�2~q��9�(# �\�kc�����\�1�ٍ�[\�lc9���S�DDDDDDDDDDDDQ��`�\�\�e�\�\��]ό\��r\�p\�#|��\������\0�����Xj�B�\��1fWVx�\�\�\Zܘ֔ ���8n2Be�2�{�\�\�3\�X\�m�\"\"\"\"\"�G�m�C�\���\���\�\Zia�<\�\�w.X坲\�\�!=\��c(\�\�|\01�\0\�l��\"\"\"\"\"\"\"\"\"\"\"\"��~�\��\0\�gi\�V2�\0�/<9����\�\�\�\��\�\�\��E��*V[\�?\��\r�#�\�\���ĉ\�K\��Ӿ���\0�\�hT�\��v3����DDDDZ�\�\�qO��.�\�*��\�s\�6RZC��l �(�\�z�q`\��n>�\n\�^\�dNs�ƈ������������\�c�\��\�c��?PfF��h\�\�\� [(p%�\� \�\�Gt�ı$3�0\��3?�ɇ�\�_\�=8\�<,��?��ڄA\���,\r^�Q0\�?710`x�ȱ��\�k\�\�\�*|*�f��uՕMdx3�)��w$?g\�c1�&\Zs�\���ۂ�\�\��\��_\�}\�DDDDDDDDDDDE\�7=F�|\�\�u\r�e55\�\\C�ѕ\���|a�x\�\�\�\� 3\�\�=�ѡ���Q\�kՍ{+hjk��\�Ga\�d\Z�a��{Z\�9\�\0�; n\�g8kq�c\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/�\�',4,1,'','','',''),(11,3,0,'9305bf00c27303da','17543934065bf00c273021a183499779','2018-11-17 12:40:07','2018-11-17 12:40:07','','','Profile Photos','person-300.jpg','image/jpeg',80,80,2354,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0+\0\0\0\0\0\0\0\0 \n!#\"AB�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�\�\��7��M�\�PCm;\n\�f˲�\�A�\"&\n(�\�\�\�z\�r+\�\�*�������N\��1dVq��+:}\�w�N\�\�z/۝ \�\�I�̮���֩�\�uJ�7\Z��I��A�\�y�\��d�2m`\�^�N|HKFow\�n�R�ɭc5mö�0�\�~�S��r\�\�\�t<Ŏ8\�M�i\�-2\��C���z*�\�}\�\�`�C\�_��\�qț\�\�\'�0\�g�(督�v\�\�pQ�aYV\�[=��AXFR�\�\�Y�\Z�p�|6R�����Po��S�z�\��\��B�9d\�\�\�V�؛�����jl.e6Nڔ\"S\�\���pσ��\�U�{\r�<���є\�\�*�F�@�V�Ԙ��:�W�V`�b6�]���hX��\�\�F\�G�8a\n\�\��\�hN�c�sѿ7�?�\Z*GLn�.���{^\�m}k��Y(IF\�s(���\���#\�#Y�+{|2���&%hZ�\�OL��~�\0\�:\�vڗ-���\�\����E�\�;=����!\0\�DZ�\�vW\�^���Q\�\"\��5y\0\Z\�G\�ل��J �]�\�٥\�C�����T��\�\Z���iR.!-:eZ�_���>�(q԰J\�d��H�qya�\0\�\�T��9\��\�\�/�Z�Ź��\�潩D��O�\�\"f4D�\�r�R\�wDPm5�-\�\�\�]P���>A\r���+\�s��њ�K^�_\�z\�A\�t\�n3�V\�)\�ٚ@��.g��E0{�H�\0.`F�Z��P\�qi7�k\��\\=����4ą��םL\�c\��\�+c\�\�\�\�ΩN h\�HH\�\�&\��O\r^��m\�\�K1�R\��\�\0z�\�?W��\'�m\�\�r���`�\�Ƶ�D\�*��\��Z��.�4��.\�D\�)&�\��Z\"�\�1yȐ_\�2\���e��ǧ�X�v:�r\�^*d!!ܭ��;����@\�3�\�=�?\'�kR\� \�Fq\�u*�\�\"�ˆ0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O��caY�r\�]��C\�(��%�g\�F6�X�͟+�\�\�\�a0Qԫ$]#cJ4 �\�HZ�&f�:\�dפ3b�\�\rs`*�\�%IZp��\nJ��%I\�2�c8�\�q�|\�8\�?�g\�qϞ8\�~�S&�S���\�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o��^�V��po9�\�k;:\�8�4\��\�*�n8���j\�`�d|\�һ���i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��^뒠\�\��*\�2p\�\��v#f\�~\���\�֫\�﷤�B�/�j��\�U�)����^��ĉ�,y-`\0$|kk�ם:4�����\�0\�Mj8�Pߨ)\�\�(��Y\�\0-yVQ�ې&E���\0�@�>,�\�>�����\��\�8\��\�c\��K�����?��\�m a\nm3\�;sR��$\�%Զ�\�*ͭL�԰\�\��\�M\��q\��u�a= \�^�LrE\�} �ĚXRra�]�ޱMd~4�\�D\�e-�8\�\�eᲬ6�[§�q\����t�\�\�\�TB��:�)M\�a����G`t�\�+w\��0ٍ��\�XC�o�K�ˎp$Q�K\� vY �3\�%�\�K㈲\\��\�p㌌ᅸ;KVP\�\�!M�*y̫��8\�u��-��;99��\r��\�rB}\�^xt;����^�o�V�ZR���9\�g��\�',5,1,'','','',''),(12,3,0,'9305bf00c27303da','17543934065bf00c273021a183499779','2018-11-17 12:40:07','2018-11-17 12:40:07','','','Profile Photos','person-300.jpg','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\���(: �+��j�o��6;+\�Ya�>\�U\�aZ\�\�H�bqc1oz ���\�\�iق%a��\��-��]�\�o)s\�^\�x[�-4й\�\�\�;��\�jT[���P\�lQ\�\�-�-\\v\�$�\� �g3N�]c\�F�|J���^���f<�:Ī\�u m�Y��ȬƼ��1[\Z�,En֨E��SpRƆ�\�AKp����\"K5�\�c��\�\�k\�{�\�=�z\�\�e��V�<\�}O�^�����ԋʜ9E\�\�!\rf*9ʱ\'9�4n�T\�+NS?Ƈ�!���j5X\�&�\�<1)d\�-���\�\\u��)_�\�]�lQ9���1Y\�¸K�!�\�\�`5��R�_\�\�\��y\�P�\�Ұ �\�h{G\�\�1ԏ P�\�\�\�ۦ%�<\�U0��]y?�gp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�۔…�a�\�\�a�YXj[�G\�ؤ\r\�0�\�B�\�<\�\�uo���y\�j:齟����!5\�6�O\�\\�D���|���@��+\\�c|>o���b_j�\�m\0�&\�Q\�<\�E\�-�A��Zv�\��� ��Ɍ�ԏ.y��\�\�M+z\�Ga�\���\�J\�&�\�\�Q�\'�۫\�|0�\�&G�(\�p\�<\�s0\�\�z�y|�r\�S�h]\�\�n�\�H|j=[P\�`vm�\�P�YE�SGM�M���\�\�\�$��\�h\0S9�\�\�J\�LJL}X�IQ�C\�$�k��gD̛�\�:�ռJ�t\\WU�u}A�\�L\�JsC\�V\�m�؂P�b@�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1>CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�9\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�7\��`\�\rf�\��a\�\�#�\�w^\�s\�\���c�\�j^2\�\�|\�����AaB\�vN�\�g\�\�1�ʋ�,\�3Ŝ E!_��\��=�U\�;\0\'u��\�,)E\����%�\�\"6@M:��\� QH�Yy���Y\�}b\�\�υf���\�\�_\��\0\�OE�m\�1):\�\�t\�^�\�y{l�c\��d|��4ͮ|r\�ņǝ&���\Z�s�\n[\�\�l\�DGԃ\�\'B\�lzu\�\�J���\�(I�]���u\��\�l\�@~d�ǐ�BR7[��Vռ�6`,z\�\�X�׵\�\�\�\�}�\�wֻFٴZͼض+ɧ�����;\�α��%\�<�RNG��#�\�>\��n1�|l�߷����\0�=�\��x\�?�\��S����g���\�{�\�KI˵\�\�=k\�a�&���\�\�\��S(#x�\�kY&�N�\��\�97�m\��Z�����Y��\��ڨ�uE���(�@�8��b���\�{\�٭\�\�\�sݜ�\�v~�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\���&\Z\�΍o\\\�9A/0\�p��4��\�xF|i\�\�C\�\�uXm.���S4Uq\n�\�ʟ��\rM\�%rV�\�[�\�\�<��\�o��]\�ضͮ�Ve\�\�[\�\'\��dك\�\�2<8QF5Ѓ\Z��4X1�\�G_�\�s�s���\�4OKW��v_x��ڍ3\�\�~9��N���\�\�\�B��2�r���\0���!���t�\�\�D\�E�u�\�+@\0E\0cF�F��8�72����\�\Zہ\���e%\�\�>\�ԁq\���^�cog/\�o\�+\�ɘ\�e\�dw�J��w�OŷGi����{oq��5\�Ϗ����c�\"rL:\�!�\n�����e\�B9��1f\'�Ө/��O\�gdcB�{fDM��&\�\�=��؆k*�e\�\���~v1\�c\���\�i\�\��\�\��)�\��#��[H�<�|��nږ�VV\��i{\�}��I��L\�Kf 9\�#\��򙿍KrԷ\�8�6��P\�\���1�\�\�XW5�\�\�#ᬙ\0\�ـ�Dx\\�m#>\�7.\�eDDDDDDDDDDD\\d \�2\�`�&<�)� cf2琏vp\�1�\�\\��8k�\�9\�1����M~C\�\�\�ҵ���|բ\�\�9�Ug��{\\����v�0��5�\��U,SH� x��&B|\"\\X\�\�t\�l{\��\�\�\�_v���\� ���[\�;d卮$RH�\�z\�?a�!\�8 \��dmf�D\�<+��n�&|\�2\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�\�\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�dJ�>\rm�s�1�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���\�\���J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf�`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8�R�\�7$ș>�W n\�I�#�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W�p\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i\"J�\�_0\�\�{\r�kA\"\�{�\�\�jwƯ��X*\�Z[\�m3�Ɋ\�\���\�\�l�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�_`F5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\"/\�u�w��\�1�p%\�\n<�9~3��G��-~XB�#��]f_��#f\�N�t\�S\�+Mk{3��7b(�pZF\�k�\�&\�{;�r �]\rE���\�8\� 7�0�!\� /-DDDDDDDDDDQH��p�D�V>V�,�g_����Z\�0�c��}��ƒd��\�b\�x\���m�&)\�<�# ��E.OF�\�:�\0\'܇�#v��\�Bp��\�\�{\�\�5�nq\�<�8g\�\�DDDDE�/Q�/j�E\�׺\�l��\�\�}�~rY[�g��0@\���\��G#�֊3O�\n0�\�\�8���\�E\�\�ϞX�ӕ\��;���k�/*�|lÍm{�]\�\ZuT�;��\�˹���x\�h2�C�26dF�f�w��ԝ\�\�dz\�|\�?�\�S�k��0$\�C�\�y&\�K�l�b��X��|��\0I]�f<�|�����U\�t]�f\�6ڹ{V��\\\�5,��ҩ�~\�ME\�\\���dW\�C�\� J,�.\�\��{;=}Iқ\�Z\�\ry�\�H�<�D/q�&�\�1 �`]}���\�\�0\�07�+>�JI0YNJA�L\��\�\�p�����/\�UZ�t+]�(��}���\�|@�ȣB\��S�\�:f�{\\\���Vtٻ$��<��>ql\09����O\��\��gAz<V\�-i���\�g\�\\��2K[�-g���8\�G)3�P��[\��6~+�{�\�U\�˪E�ę;,\� pa����n�s!�\�\�.\�f\�8\�\�a\�H����kaW�\�7�\�P��\\\�B�RX\�e�M,I�\�C�^�\����q\�+\�R�3X\�+X\�hdF�)�}V\�M\�q(8�\�\�cDW5�\� 7�\�c�\�c\�w�|㳬��\0e�\���\Z�iY�밚9[��\�\�ck���Z�s.u��\�G9\�x�\��\'J�aΦ�=�\��v��{Mʙd}��v\�\�أ�2L\�\�V�.�h�j�Hh���\�|�q#�\��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0���S\�2����&�\�.�\'\�hN \�mSfǩ�(\�5��\�ljb�1�F�ƍL x�\�ƌ \0F֌A\�шCkX\�5�cp\�c ���\�x\�\�|�t{��@\�\�\�\�xF\�x;c���\�#\�\�(\�\Z91�1��6̋\�*}T�Uc#8�*i9�w�\"\�6\�1\�\�b\�Kߴk\�\�\�׫\�\��{:k�\�\�2b��s\�Vg8i�̊S����L�r\0rt�h��o\�\�Oc�N~�n\���\�ԇ���v\�\�i\�Wg��>E\Z4cQ\r`\�#�U�b\�G���:4\�9t\"\"\"\�1�E� �\0\07�\�3\� �\"n^B��\�X1��s\���\rcq�;8\�3�Qw�\�v\�\�/�;�k��u��6\�\�\�c��\��g��Z�\�~����2noŠ�L�Z64Y)g�\�&.#��\�\�\��-c\����ߩ�c��J\�皭I�\� \Z\�\�Ut�\�$�\�o̸x����\�(,cB\�+\�Ǫj�֋�\�i\�u%n��\�u��(��\"�m]t1\�q\�Ŏ,a�c�w;?\"�y����{�\"\"\"\"\"\"\"\"\"\"\"(J���瞵\�\"�Y\�\�Ys��[�\�\�z�s\�X��\�7*�\����\�QHi O���i�G��=n����^7{\�ɾ8{}\�}�\�d�Ԭ�G޵܋\�\�\�7\�\"�w\�?̯\�\�έ fS�C6\�]Q*�s+�.����َ\'\��c�Ka���\�)k5\�%�;-��c��*.!��UsZW\�=�]�XVQ �6\\(\�wՌ�DDZ��\�Vg�\�\�\�g��nըu\��H.3\�\�N�k\Z�m\�\Z\��8�#_\�\�7\�\�D�\���^lٖS%\�\�˓>\�|�͝:i\�*dْ��ʗ.Q\��ȓ \�y�s=\�1^�\�{���\02�\�]ӊ>���\�S2�\�w�\�Ճ��&ma*�q�A�Z�KȦM�Z�\0`Ћ�\�L���\���cߚ\"\"\"\"\"\"\"\"\"\"\"\"\�0C(&�$\"�\Z@��\�aB`��BaaF9\� \�ܱ\�\�Z\�g\�U��n�pwM{ˣ�\'��Z�\�F��r\�\�T\�cck;\�\r\�L{\��\��\�\�n\�\�b\�b\0�i�&ۘ��ml$�+�k~�N\�s\�N\�r\�P��ϴ\�\� \���+Z�)3!꼉�\�\�M=t\��\'\�P͎�a�ͭc��\�I���V\�:\�\�\�DE�?S\r5\�\��\���&n�s=��\�\�t�㲽a~9ǴsUF�\�ϻp\�\�.\�p��^�\��U\�G)�q��v.@\�h�!� fW[\�u��b��x\�\"H� �aA�n~7�\�ñ|\'UWQ���OQ5Օ���\n  �X\�\�b ah��5�kq�\�\�=��\0�]��9\�{�\"\"\"\"\"\"\"\"\"\",h\�q��\�*\�\�8q\�ku�]�e��br\�o�\�$\nw6s%\�@�\�H�\"0\Z�>@�\���g\�m\�n2�zη�.vl䭐h[XNՁf\�e�\�Y3v�.\�����X\Z얺+s��2��T\��]�t\�\�\�K�}\Z\�c]\nª%�v��\�ݎ=@�\nh4䷱��\�\�5X�\�G9{�`t\� I\�\'\�Ɗs\�ܧ\�\�\�\�\�%r�\�\���jH3�ܭ�F�ڷ�*,�\�\�J\�7Q���*\�<�F�]\�6@\'�dcJ(C\ZO�X�L�\�1\�3��\0\��>i\�g/\\}���\�h���}dF\�e}xN����\�\�\�K\��R>�\0�r\'X\�$bO�9Y\�\�N<`�v�\�\��d��\�[\�WH\�=����dș\���\�Xg�����\��\�o�^4!m�\�7 �G�ދ�v\�}�\�\�\�\�y\Z�(5gض�K9q)`�������̰��8�5�ǛcȈ�\r|�p\\�\�tO�\\ ��,��ڲ��{\�oi�\�6��fXL>@l���\�\�;G��79v(\�\�h.uK�\�[c��O�kWT\�3�̫���j\�J\�L\�]�I�:1\��s�҉�\�s�{�\�\�\�$7��\�o�~E[����X�!�84{m��\�!aJ\\�H\� B!�|Xǻ8no�\�\�h���{\0Ñ\�P֭�H�\�%�4)��7�9ÆP��c��\�\�v3�\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $K�ca-\�#Ǝ\"��\�s�D&q�� �j�x\�{�xΉ\Z����jó\�p�M��8��;�#�4���\�j��b��\�b�?�G�\�\�Ŵ :��\�y+\�lZ:��\�k;Ȳ$f;lm�\�\� �Ξioc\�\�&��T\� X[8qy���)lA!bTh\���GW\�\"ֹ_\����O\�~�\�p���\�I�\�\�\�\Z�\��-\�i�m�\�\�c1�� UTVP&J���>Z�w½y\���\��\��p��U� \�\�y�R\�\�\��8\�2�$g\�\�J\�$��\��˟�l{{\Z\"\"\"\"*�}Q�-l�?݋~\��ޱ*[{o}o�\�\�.5f�\�\�2{�B\�\�8�;��m \�ϗ>Ćي\�Ɓ_y�\�W\�#\�\�.\��\�\�\�JM���P\�\�h�ȸ��y���\�\�E��줟8��m�\��\�R\�5�$��\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ\�q\n�;�\�\�\�M�^(ѽ\�$��0���eK^m<\�󟕎U�֨\�n�\�:��\��A\Zk��J\Z٤}~\�ȟCC�k\�\n5��$\�մ� /�O�_�Kŗ�\��[\�AqO \�f��\�8�K�/\�%\�\�x昙\�~\��X����\��\0\�\�Y\�O1\�\r.\�W\�\�����\�\�\�0\���F\����\0�y�a��?%�5��|\�a�~q\�gI��TV:5D\0�+\�F��]��\"\"\"\"\"ž\�t��{\�׾@\�o?j\��m}�,7��k�u�qe�)�Z)Xp\�@��� [(\�H�$2b���@\� U�m��\��\0�˝S�`H\�x[m��\�$\�\Zxq�\�\�\�$\��xo���\�\�@�YF�3�+hq�\�Ӹ��y_GּD�I\�]��\�=6n=٢F\�y���8��\�z.g2W\�8�qW�h֏�X\��1�����lʶ\�\Z\�%�}9\�_\0w��5.��\�z�ݴ}�s���[k�o3_\�\�\n\�N����\�\�Ÿ\Z,�ʋ&9\�Q�FT\"\"\"\"\"\"\"\".�(\Z0�\"D�`@\0�!�c\��!�\�!汌k�\�a�\�UR��\�2�=�\�\�[�gf��\��lvZ\�cF!�C\�.J����\��y`_:��\��:М\���$&�in\"�W���\�;y\�ɧfu>�ptC|�\nӐ9\n\�)�ka��\��[i�h�\�\�4-(5�p\�\�\�-~�ΗWT {ʛ�z/Ѿ�\�ם/��}ա\�\�z\�l&^ߺ$q\�[\�\�8\�e�ݵM2I�ֲ�\"[��Q3 �\\\�\�\�DDDDDDD^a\��+Ľ�\�\�Ú�\�R\��?\�\�\�U_j{�4K��\Z���fF %\r\�d \�Ex\"a�(�,�RJ \0O+\�\�\�\���EA\� ��\�}���_f�\�\rv�\�;TH�\�y/�3S\�j\�\�,���ӨgÃ!�ulK[\�X^���|\�\0n��r\� ��=jS�.m\�\����H�\"Xj܉�\�m�R�Kk,���66E,c`�3\�d �����1d�?\�\�Geb\�\��6�#�<\�(��\�j� �\��c��~��\�/Q+ \�I�\\M2���������0�\�\�Sw�_O�`\�8\�?$rg\\\�o1\"\�!���u\�s-\\Ytⰻ��i\Z�\�\�:�����7\Z�\�\�0̬���,\��Z�Qq�po�^�w��Y\��~�4\r{F䚢\�r�\�\�D���œ#J\�.,X�3eM��|Ղ%\�\�;3ƃ1aR#��0 6& \�ADn2 �˘A��Ø�\�-ss�79\�q�Ȉ�����)�@w絞W9w\\l\�B\�m_\�q�[I�Q\'9.˵lQ��\rE������\� ɝ\'\Z\� (��A������\�Ozfv��)d\�\�^��f\�J\�\�k\�6>h�\�\"\�\�t��\�\��Q6f\�iZf �q�\�\�~�\����}r��\0vOG�\�5{&^�q�\r�.��:)cD\�uv\�*m���k\�\�l�{�\���J\Z|�x\�\�ݩظ�\�O�\�,\�m���G�H\Z.N\�\�Ipc\�W\�`� ��G\�*6ڑ\�2\�\� B�kW�M#ҵ\�b\�\��~;;Y���\n)2��\0\�;4�>\�u:�}�4��\�l�b\�o\�㽭��w>\�h��a\�\\@خeN��\�m;�yKn�wF���\�ﱂK�:9���;1�\�q�8O�\�5\�\�2ܷ\�Ƿ��������7�\��\�#��r\�o[<�\r\���m��u$bs\�\��VJ���s\Z�7/~q�Ͷ~�\�kK�Q\�\�%i&\�\��Qd7�\�ā܇\��o\�EJֽ\�i\�v\�\��\��\�\�\��sK\�\�\�k\��4�\��Ȝ�\��,�D~G)\�\�vʊ610\�\�;`/\���-w\�\�\�ٽ\�C\�(�\�I\��-jh\Zv�Q�\�ĉQ\0(UA9lp�\0�7���\�e\�#\���\�s\�\�QS�\�/\�\�7L<�vM\�7ꜻ&cu\�l̒>��:\�\�\�ǒ�d\�f\� c�\�\�X0�,5�#2��Z�n�&7\�;o\�9\��\�V��}\�\�r\�\"ޠ\��+\�W\�\�K� \�A�ߜ\�\�\�^\0���\"\"\"\"\�O�=2^�\�S�:\�lSM�\'\\��\��c\�\�Dz�����߾Xǃ9Ǿ}�x��\��\�icoq�Pn\�N쎭\'\�\�p�% \�x�V� ԭe�Nr\�g$v�.��?���\0��m�\��5����\�@!�k\�y�\\�����\�\�i\�\�\�8�\0# HL��yBY�\�q2\���ai\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���F�\�/�\'c\�!\�h�\���\�\�L�\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\���\�V\0 �� �RDDDD_\���Al(�\� �#Z�onZ�\�Z�=�\�\\\�c-ss�g\�s�\�L]z\�2~q��9�(# �\�kc�����\�1�ٍ�[\�lc9���S�DDDDDDDDDDDDQ��`�\�\�e�\�\��]ό\��r\�p\�#|��\������\0�����Xj�B�\��1fWVx�\�\�\Zܘ֔ ���8n2Be�2�{�\�\�3\�X\�m�\"\"\"\"\"�G�m�C�\���\���\�\Zia�<\�\�w.X坲\�\�!=\��c(\�\�|\01�\0\�l��\"\"\"\"\"\"\"\"\"\"\"\"��~�\��\0\�gi\�V2�\0�/<9����\�\�\�\��\�\�\��E��*V[\�?\��\r�#�\�\���ĉ\�K\��Ӿ���\0�\�hT�\��v3����DDDDZ�\�\�qO��.�\�*��\�s\�6RZC��l �(�\�z�q`\��n>�\n\�^\�dNs�ƈ������������\�c�\��\�c��?PfF��h\�\�\� [(p%�\� \�\�Gt�ı$3�0\��3?�ɇ�\�_\�=8\�<,��?��ڄA\���,\r^�Q0\�?710`x�ȱ��\�k\�\�\�*|*�f��uՕMdx3�)��w$?g\�c1�&\Zs�\���ۂ�\�\��\��_\�}\�DDDDDDDDDDDE\�7=F�|\�\�u\r�e55\�\\C�ѕ\���|a�x\�\�\�\� 3\�\�=�ѡ���Q\�kՍ{+hjk��\�Ga\�d\Z�a��{Z\�9\�\0�; n\�g8kq�c\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/�\�',4,1,'','','',''),(14,4,0,'9305bf00c3cedc12','24803714715bf00c3ced9f6942163975','2018-11-17 12:40:28','2018-11-17 12:40:28','','','Profile Photos','person-300.jpg','image/jpeg',80,80,2354,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0+\0\0\0\0\0\0\0\0 \n!#\"AB�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�\�\��7��M�\�PCm;\n\�f˲�\�A�\"&\n(�\�\�\�z\�r+\�\�*�������N\��1dVq��+:}\�w�N\�\�z/۝ \�\�I�̮���֩�\�uJ�7\Z��I��A�\�y�\��d�2m`\�^�N|HKFow\�n�R�ɭc5mö�0�\�~�S��r\�\�\�t<Ŏ8\�M�i\�-2\��C���z*�\�}\�\�`�C\�_��\�qț\�\�\'�0\�g�(督�v\�\�pQ�aYV\�[=��AXFR�\�\�Y�\Z�p�|6R�����Po��S�z�\��\��B�9d\�\�\�V�؛�����jl.e6Nڔ\"S\�\���pσ��\�U�{\r�<���є\�\�*�F�@�V�Ԙ��:�W�V`�b6�]���hX��\�\�F\�G�8a\n\�\��\�hN�c�sѿ7�?�\Z*GLn�.���{^\�m}k��Y(IF\�s(���\���#\�#Y�+{|2���&%hZ�\�OL��~�\0\�:\�vڗ-���\�\����E�\�;=����!\0\�DZ�\�vW\�^���Q\�\"\��5y\0\Z\�G\�ل��J �]�\�٥\�C�����T��\�\Z���iR.!-:eZ�_���>�(q԰J\�d��H�qya�\0\�\�T��9\��\�\�/�Z�Ź��\�潩D��O�\�\"f4D�\�r�R\�wDPm5�-\�\�\�]P���>A\r���+\�s��њ�K^�_\�z\�A\�t\�n3�V\�)\�ٚ@��.g��E0{�H�\0.`F�Z��P\�qi7�k\��\\=����4ą��םL\�c\��\�+c\�\�\�\�ΩN h\�HH\�\�&\��O\r^��m\�\�K1�R\��\�\0z�\�?W��\'�m\�\�r���`�\�Ƶ�D\�*��\��Z��.�4��.\�D\�)&�\��Z\"�\�1yȐ_\�2\���e��ǧ�X�v:�r\�^*d!!ܭ��;����@\�3�\�=�?\'�kR\� \�Fq\�u*�\�\"�ˆ0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O��caY�r\�]��C\�(��%�g\�F6�X�͟+�\�\�\�a0Qԫ$]#cJ4 �\�HZ�&f�:\�dפ3b�\�\rs`*�\�%IZp��\nJ��%I\�2�c8�\�q�|\�8\�?�g\�qϞ8\�~�S&�S���\�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o��^�V��po9�\�k;:\�8�4\��\�*�n8���j\�`�d|\�һ���i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��^뒠\�\��*\�2p\�\��v#f\�~\���\�֫\�﷤�B�/�j��\�U�)����^��ĉ�,y-`\0$|kk�ם:4�����\�0\�Mj8�Pߨ)\�\�(��Y\�\0-yVQ�ې&E���\0�@�>,�\�>�����\��\�8\��\�c\��K�����?��\�m a\nm3\�;sR��$\�%Զ�\�*ͭL�԰\�\��\�M\��q\��u�a= \�^�LrE\�} �ĚXRra�]�ޱMd~4�\�D\�e-�8\�\�eᲬ6�[§�q\����t�\�\�\�TB��:�)M\�a����G`t�\�+w\��0ٍ��\�XC�o�K�ˎp$Q�K\� vY �3\�%�\�K㈲\\��\�p㌌ᅸ;KVP\�\�!M�*y̫��8\�u��-��;99��\r��\�rB}\�^xt;����^�o�V�ZR���9\�g��\�',5,1,'','','',''),(15,4,0,'9305bf00c3cedc12','24803714715bf00c3ced9f6942163975','2018-11-17 12:40:28','2018-11-17 12:40:28','','','Profile Photos','person-300.jpg','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\���(: �+��j�o��6;+\�Ya�>\�U\�aZ\�\�H�bqc1oz ���\�\�iق%a��\��-��]�\�o)s\�^\�x[�-4й\�\�\�;��\�jT[���P\�lQ\�\�-�-\\v\�$�\� �g3N�]c\�F�|J���^���f<�:Ī\�u m�Y��ȬƼ��1[\Z�,En֨E��SpRƆ�\�AKp����\"K5�\�c��\�\�k\�{�\�=�z\�\�e��V�<\�}O�^�����ԋʜ9E\�\�!\rf*9ʱ\'9�4n�T\�+NS?Ƈ�!���j5X\�&�\�<1)d\�-���\�\\u��)_�\�]�lQ9���1Y\�¸K�!�\�\�`5��R�_\�\�\��y\�P�\�Ұ �\�h{G\�\�1ԏ P�\�\�\�ۦ%�<\�U0��]y?�gp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�۔…�a�\�\�a�YXj[�G\�ؤ\r\�0�\�B�\�<\�\�uo���y\�j:齟����!5\�6�O\�\\�D���|���@��+\\�c|>o���b_j�\�m\0�&\�Q\�<\�E\�-�A��Zv�\��� ��Ɍ�ԏ.y��\�\�M+z\�Ga�\���\�J\�&�\�\�Q�\'�۫\�|0�\�&G�(\�p\�<\�s0\�\�z�y|�r\�S�h]\�\�n�\�H|j=[P\�`vm�\�P�YE�SGM�M���\�\�\�$��\�h\0S9�\�\�J\�LJL}X�IQ�C\�$�k��gD̛�\�:�ռJ�t\\WU�u}A�\�L\�JsC\�V\�m�؂P�b@�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1>CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�9\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�7\��`\�\rf�\��a\�\�#�\�w^\�s\�\���c�\�j^2\�\�|\�����AaB\�vN�\�g\�\�1�ʋ�,\�3Ŝ E!_��\��=�U\�;\0\'u��\�,)E\����%�\�\"6@M:��\� QH�Yy���Y\�}b\�\�υf���\�\�_\��\0\�OE�m\�1):\�\�t\�^�\�y{l�c\��d|��4ͮ|r\�ņǝ&���\Z�s�\n[\�\�l\�DGԃ\�\'B\�lzu\�\�J���\�(I�]���u\��\�l\�@~d�ǐ�BR7[��Vռ�6`,z\�\�X�׵\�\�\�\�}�\�wֻFٴZͼض+ɧ�����;\�α��%\�<�RNG��#�\�>\��n1�|l�߷����\0�=�\��x\�?�\��S����g���\�{�\�KI˵\�\�=k\�a�&���\�\�\��S(#x�\�kY&�N�\��\�97�m\��Z�����Y��\��ڨ�uE���(�@�8��b���\�{\�٭\�\�\�sݜ�\�v~�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\���&\Z\�΍o\\\�9A/0\�p��4��\�xF|i\�\�C\�\�uXm.���S4Uq\n�\�ʟ��\rM\�%rV�\�[�\�\�<��\�o��]\�ضͮ�Ve\�\�[\�\'\��dك\�\�2<8QF5Ѓ\Z��4X1�\�G_�\�s�s���\�4OKW��v_x��ڍ3\�\�~9��N���\�\�\�B��2�r���\0���!���t�\�\�D\�E�u�\�+@\0E\0cF�F��8�72����\�\Zہ\���e%\�\�>\�ԁq\���^�cog/\�o\�+\�ɘ\�e\�dw�J��w�OŷGi����{oq��5\�Ϗ����c�\"rL:\�!�\n�����e\�B9��1f\'�Ө/��O\�gdcB�{fDM��&\�\�=��؆k*�e\�\���~v1\�c\���\�i\�\��\�\��)�\��#��[H�<�|��nږ�VV\��i{\�}��I��L\�Kf 9\�#\��򙿍KrԷ\�8�6��P\�\���1�\�\�XW5�\�\�#ᬙ\0\�ـ�Dx\\�m#>\�7.\�eDDDDDDDDDDD\\d \�2\�`�&<�)� cf2琏vp\�1�\�\\��8k�\�9\�1����M~C\�\�\�ҵ���|բ\�\�9�Ug��{\\����v�0��5�\��U,SH� x��&B|\"\\X\�\�t\�l{\��\�\�\�_v���\� ���[\�;d卮$RH�\�z\�?a�!\�8 \��dmf�D\�<+��n�&|\�2\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�\�\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�dJ�>\rm�s�1�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���\�\���J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf�`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8�R�\�7$ș>�W n\�I�#�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W�p\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i\"J�\�_0\�\�{\r�kA\"\�{�\�\�jwƯ��X*\�Z[\�m3�Ɋ\�\���\�\�l�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�_`F5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\"/\�u�w��\�1�p%\�\n<�9~3��G��-~XB�#��]f_��#f\�N�t\�S\�+Mk{3��7b(�pZF\�k�\�&\�{;�r �]\rE���\�8\� 7�0�!\� /-DDDDDDDDDDQH��p�D�V>V�,�g_����Z\�0�c��}��ƒd��\�b\�x\���m�&)\�<�# ��E.OF�\�:�\0\'܇�#v��\�Bp��\�\�{\�\�5�nq\�<�8g\�\�DDDDE�/Q�/j�E\�׺\�l��\�\�}�~rY[�g��0@\���\��G#�֊3O�\n0�\�\�8���\�E\�\�ϞX�ӕ\��;���k�/*�|lÍm{�]\�\ZuT�;��\�˹���x\�h2�C�26dF�f�w��ԝ\�\�dz\�|\�?�\�S�k��0$\�C�\�y&\�K�l�b��X��|��\0I]�f<�|�����U\�t]�f\�6ڹ{V��\\\�5,��ҩ�~\�ME\�\\���dW\�C�\� J,�.\�\��{;=}Iқ\�Z\�\ry�\�H�<�D/q�&�\�1 �`]}���\�\�0\�07�+>�JI0YNJA�L\��\�\�p�����/\�UZ�t+]�(��}���\�|@�ȣB\��S�\�:f�{\\\���Vtٻ$��<��>ql\09����O\��\��gAz<V\�-i���\�g\�\\��2K[�-g���8\�G)3�P��[\��6~+�{�\�U\�˪E�ę;,\� pa����n�s!�\�\�.\�f\�8\�\�a\�H����kaW�\�7�\�P��\\\�B�RX\�e�M,I�\�C�^�\����q\�+\�R�3X\�+X\�hdF�)�}V\�M\�q(8�\�\�cDW5�\� 7�\�c�\�c\�w�|㳬��\0e�\���\Z�iY�밚9[��\�\�ck���Z�s.u��\�G9\�x�\��\'J�aΦ�=�\��v��{Mʙd}��v\�\�أ�2L\�\�V�.�h�j�Hh���\�|�q#�\��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0���S\�2����&�\�.�\'\�hN \�mSfǩ�(\�5��\�ljb�1�F�ƍL x�\�ƌ \0F֌A\�шCkX\�5�cp\�c ���\�x\�\�|�t{��@\�\�\�\�xF\�x;c���\�#\�\�(\�\Z91�1��6̋\�*}T�Uc#8�*i9�w�\"\�6\�1\�\�b\�Kߴk\�\�\�׫\�\��{:k�\�\�2b��s\�Vg8i�̊S����L�r\0rt�h��o\�\�Oc�N~�n\���\�ԇ���v\�\�i\�Wg��>E\Z4cQ\r`\�#�U�b\�G���:4\�9t\"\"\"\�1�E� �\0\07�\�3\� �\"n^B��\�X1��s\���\rcq�;8\�3�Qw�\�v\�\�/�;�k��u��6\�\�\�c��\��g��Z�\�~����2noŠ�L�Z64Y)g�\�&.#��\�\�\��-c\����ߩ�c��J\�皭I�\� \Z\�\�Ut�\�$�\�o̸x����\�(,cB\�+\�Ǫj�֋�\�i\�u%n��\�u��(��\"�m]t1\�q\�Ŏ,a�c�w;?\"�y����{�\"\"\"\"\"\"\"\"\"\"\"(J���瞵\�\"�Y\�\�Ys��[�\�\�z�s\�X��\�7*�\����\�QHi O���i�G��=n����^7{\�ɾ8{}\�}�\�d�Ԭ�G޵܋\�\�\�7\�\"�w\�?̯\�\�έ fS�C6\�]Q*�s+�.����َ\'\��c�Ka���\�)k5\�%�;-��c��*.!��UsZW\�=�]�XVQ �6\\(\�wՌ�DDZ��\�Vg�\�\�\�g��nըu\��H.3\�\�N�k\Z�m\�\Z\��8�#_\�\�7\�\�D�\���^lٖS%\�\�˓>\�|�͝:i\�*dْ��ʗ.Q\��ȓ \�y�s=\�1^�\�{���\02�\�]ӊ>���\�S2�\�w�\�Ճ��&ma*�q�A�Z�KȦM�Z�\0`Ћ�\�L���\���cߚ\"\"\"\"\"\"\"\"\"\"\"\"\�0C(&�$\"�\Z@��\�aB`��BaaF9\� \�ܱ\�\�Z\�g\�U��n�pwM{ˣ�\'��Z�\�F��r\�\�T\�cck;\�\r\�L{\��\��\�\�n\�\�b\�b\0�i�&ۘ��ml$�+�k~�N\�s\�N\�r\�P��ϴ\�\� \���+Z�)3!꼉�\�\�M=t\��\'\�P͎�a�ͭc��\�I���V\�:\�\�\�DE�?S\r5\�\��\���&n�s=��\�\�t�㲽a~9ǴsUF�\�ϻp\�\�.\�p��^�\��U\�G)�q��v.@\�h�!� fW[\�u��b��x\�\"H� �aA�n~7�\�ñ|\'UWQ���OQ5Օ���\n  �X\�\�b ah��5�kq�\�\�=��\0�]��9\�{�\"\"\"\"\"\"\"\"\"\",h\�q��\�*\�\�8q\�ku�]�e��br\�o�\�$\nw6s%\�@�\�H�\"0\Z�>@�\���g\�m\�n2�zη�.vl䭐h[XNՁf\�e�\�Y3v�.\�����X\Z얺+s��2��T\��]�t\�\�\�K�}\Z\�c]\nª%�v��\�ݎ=@�\nh4䷱��\�\�5X�\�G9{�`t\� I\�\'\�Ɗs\�ܧ\�\�\�\�\�%r�\�\���jH3�ܭ�F�ڷ�*,�\�\�J\�7Q���*\�<�F�]\�6@\'�dcJ(C\ZO�X�L�\�1\�3��\0\��>i\�g/\\}���\�h���}dF\�e}xN����\�\�\�K\��R>�\0�r\'X\�$bO�9Y\�\�N<`�v�\�\��d��\�[\�WH\�=����dș\���\�Xg�����\��\�o�^4!m�\�7 �G�ދ�v\�}�\�\�\�\�y\Z�(5gض�K9q)`�������̰��8�5�ǛcȈ�\r|�p\\�\�tO�\\ ��,��ڲ��{\�oi�\�6��fXL>@l���\�\�;G��79v(\�\�h.uK�\�[c��O�kWT\�3�̫���j\�J\�L\�]�I�:1\��s�҉�\�s�{�\�\�\�$7��\�o�~E[����X�!�84{m��\�!aJ\\�H\� B!�|Xǻ8no�\�\�h���{\0Ñ\�P֭�H�\�%�4)��7�9ÆP��c��\�\�v3�\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $K�ca-\�#Ǝ\"��\�s�D&q�� �j�x\�{�xΉ\Z����jó\�p�M��8��;�#�4���\�j��b��\�b�?�G�\�\�Ŵ :��\�y+\�lZ:��\�k;Ȳ$f;lm�\�\� �Ξioc\�\�&��T\� X[8qy���)lA!bTh\���GW\�\"ֹ_\����O\�~�\�p���\�I�\�\�\�\Z�\��-\�i�m�\�\�c1�� UTVP&J���>Z�w½y\���\��\��p��U� \�\�y�R\�\�\��8\�2�$g\�\�J\�$��\��˟�l{{\Z\"\"\"\"*�}Q�-l�?݋~\��ޱ*[{o}o�\�\�.5f�\�\�2{�B\�\�8�;��m \�ϗ>Ćي\�Ɓ_y�\�W\�#\�\�.\��\�\�\�JM���P\�\�h�ȸ��y���\�\�E��줟8��m�\��\�R\�5�$��\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ\�q\n�;�\�\�\�M�^(ѽ\�$��0���eK^m<\�󟕎U�֨\�n�\�:��\��A\Zk��J\Z٤}~\�ȟCC�k\�\n5��$\�մ� /�O�_�Kŗ�\��[\�AqO \�f��\�8�K�/\�%\�\�x昙\�~\��X����\��\0\�\�Y\�O1\�\r.\�W\�\�����\�\�\�0\���F\����\0�y�a��?%�5��|\�a�~q\�gI��TV:5D\0�+\�F��]��\"\"\"\"\"ž\�t��{\�׾@\�o?j\��m}�,7��k�u�qe�)�Z)Xp\�@��� [(\�H�$2b���@\� U�m��\��\0�˝S�`H\�x[m��\�$\�\Zxq�\�\�\�$\��xo���\�\�@�YF�3�+hq�\�Ӹ��y_GּD�I\�]��\�=6n=٢F\�y���8��\�z.g2W\�8�qW�h֏�X\��1�����lʶ\�\Z\�%�}9\�_\0w��5.��\�z�ݴ}�s���[k�o3_\�\�\n\�N����\�\�Ÿ\Z,�ʋ&9\�Q�FT\"\"\"\"\"\"\"\".�(\Z0�\"D�`@\0�!�c\��!�\�!汌k�\�a�\�UR��\�2�=�\�\�[�gf��\��lvZ\�cF!�C\�.J����\��y`_:��\��:М\���$&�in\"�W���\�;y\�ɧfu>�ptC|�\nӐ9\n\�)�ka��\��[i�h�\�\�4-(5�p\�\�\�-~�ΗWT {ʛ�z/Ѿ�\�ם/��}ա\�\�z\�l&^ߺ$q\�[\�\�8\�e�ݵM2I�ֲ�\"[��Q3 �\\\�\�\�DDDDDDD^a\��+Ľ�\�\�Ú�\�R\��?\�\�\�U_j{�4K��\Z���fF %\r\�d \�Ex\"a�(�,�RJ \0O+\�\�\�\���EA\� ��\�}���_f�\�\rv�\�;TH�\�y/�3S\�j\�\�,���ӨgÃ!�ulK[\�X^���|\�\0n��r\� ��=jS�.m\�\����H�\"Xj܉�\�m�R�Kk,���66E,c`�3\�d �����1d�?\�\�Geb\�\��6�#�<\�(��\�j� �\��c��~��\�/Q+ \�I�\\M2���������0�\�\�Sw�_O�`\�8\�?$rg\\\�o1\"\�!���u\�s-\\Ytⰻ��i\Z�\�\�:�����7\Z�\�\�0̬���,\��Z�Qq�po�^�w��Y\��~�4\r{F䚢\�r�\�\�D���œ#J\�.,X�3eM��|Ղ%\�\�;3ƃ1aR#��0 6& \�ADn2 �˘A��Ø�\�-ss�79\�q�Ȉ�����)�@w絞W9w\\l\�B\�m_\�q�[I�Q\'9.˵lQ��\rE������\� ɝ\'\Z\� (��A������\�Ozfv��)d\�\�^��f\�J\�\�k\�6>h�\�\"\�\�t��\�\��Q6f\�iZf �q�\�\�~�\����}r��\0vOG�\�5{&^�q�\r�.��:)cD\�uv\�*m���k\�\�l�{�\���J\Z|�x\�\�ݩظ�\�O�\�,\�m���G�H\Z.N\�\�Ipc\�W\�`� ��G\�*6ڑ\�2\�\� B�kW�M#ҵ\�b\�\��~;;Y���\n)2��\0\�;4�>\�u:�}�4��\�l�b\�o\�㽭��w>\�h��a\�\\@خeN��\�m;�yKn�wF���\�ﱂK�:9���;1�\�q�8O�\�5\�\�2ܷ\�Ƿ��������7�\��\�#��r\�o[<�\r\���m��u$bs\�\��VJ���s\Z�7/~q�Ͷ~�\�kK�Q\�\�%i&\�\��Qd7�\�ā܇\��o\�EJֽ\�i\�v\�\��\��\�\�\��sK\�\�\�k\��4�\��Ȝ�\��,�D~G)\�\�vʊ610\�\�;`/\���-w\�\�\�ٽ\�C\�(�\�I\��-jh\Zv�Q�\�ĉQ\0(UA9lp�\0�7���\�e\�#\���\�s\�\�QS�\�/\�\�7L<�vM\�7ꜻ&cu\�l̒>��:\�\�\�ǒ�d\�f\� c�\�\�X0�,5�#2��Z�n�&7\�;o\�9\��\�V��}\�\�r\�\"ޠ\��+\�W\�\�K� \�A�ߜ\�\�\�^\0���\"\"\"\"\�O�=2^�\�S�:\�lSM�\'\\��\��c\�\�Dz�����߾Xǃ9Ǿ}�x��\��\�icoq�Pn\�N쎭\'\�\�p�% \�x�V� ԭe�Nr\�g$v�.��?���\0��m�\��5����\�@!�k\�y�\\�����\�\�i\�\�\�8�\0# HL��yBY�\�q2\���ai\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���F�\�/�\'c\�!\�h�\���\�\�L�\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\���\�V\0 �� �RDDDD_\���Al(�\� �#Z�onZ�\�Z�=�\�\\\�c-ss�g\�s�\�L]z\�2~q��9�(# �\�kc�����\�1�ٍ�[\�lc9���S�DDDDDDDDDDDDQ��`�\�\�e�\�\��]ό\��r\�p\�#|��\������\0�����Xj�B�\��1fWVx�\�\�\Zܘ֔ ���8n2Be�2�{�\�\�3\�X\�m�\"\"\"\"\"�G�m�C�\���\���\�\Zia�<\�\�w.X坲\�\�!=\��c(\�\�|\01�\0\�l��\"\"\"\"\"\"\"\"\"\"\"\"��~�\��\0\�gi\�V2�\0�/<9����\�\�\�\��\�\�\��E��*V[\�?\��\r�#�\�\���ĉ\�K\��Ӿ���\0�\�hT�\��v3����DDDDZ�\�\�qO��.�\�*��\�s\�6RZC��l �(�\�z�q`\��n>�\n\�^\�dNs�ƈ������������\�c�\��\�c��?PfF��h\�\�\� [(p%�\� \�\�Gt�ı$3�0\��3?�ɇ�\�_\�=8\�<,��?��ڄA\���,\r^�Q0\�?710`x�ȱ��\�k\�\�\�*|*�f��uՕMdx3�)��w$?g\�c1�&\Zs�\���ۂ�\�\��\��_\�}\�DDDDDDDDDDDE\�7=F�|\�\�u\r�e55\�\\C�ѕ\���|a�x\�\�\�\� 3\�\�=�ѡ���Q\�kՍ{+hjk��\�Ga\�d\Z�a��{Z\�9\�\0�; n\�g8kq�c\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/�\�',4,1,'','','',''),(17,5,0,'9305bf00c9709025','12966450605bf00c9708ea1748285071','2018-11-17 12:41:59','2018-11-17 12:41:59','','','Profile Photos','person-300.jpg','image/jpeg',80,80,2354,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0+\0\0\0\0\0\0\0\0 \n!#\"AB�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�\�\��7��M�\�PCm;\n\�f˲�\�A�\"&\n(�\�\�\�z\�r+\�\�*�������N\��1dVq��+:}\�w�N\�\�z/۝ \�\�I�̮���֩�\�uJ�7\Z��I��A�\�y�\��d�2m`\�^�N|HKFow\�n�R�ɭc5mö�0�\�~�S��r\�\�\�t<Ŏ8\�M�i\�-2\��C���z*�\�}\�\�`�C\�_��\�qț\�\�\'�0\�g�(督�v\�\�pQ�aYV\�[=��AXFR�\�\�Y�\Z�p�|6R�����Po��S�z�\��\��B�9d\�\�\�V�؛�����jl.e6Nڔ\"S\�\���pσ��\�U�{\r�<���є\�\�*�F�@�V�Ԙ��:�W�V`�b6�]���hX��\�\�F\�G�8a\n\�\��\�hN�c�sѿ7�?�\Z*GLn�.���{^\�m}k��Y(IF\�s(���\���#\�#Y�+{|2���&%hZ�\�OL��~�\0\�:\�vڗ-���\�\����E�\�;=����!\0\�DZ�\�vW\�^���Q\�\"\��5y\0\Z\�G\�ل��J �]�\�٥\�C�����T��\�\Z���iR.!-:eZ�_���>�(q԰J\�d��H�qya�\0\�\�T��9\��\�\�/�Z�Ź��\�潩D��O�\�\"f4D�\�r�R\�wDPm5�-\�\�\�]P���>A\r���+\�s��њ�K^�_\�z\�A\�t\�n3�V\�)\�ٚ@��.g��E0{�H�\0.`F�Z��P\�qi7�k\��\\=����4ą��םL\�c\��\�+c\�\�\�\�ΩN h\�HH\�\�&\��O\r^��m\�\�K1�R\��\�\0z�\�?W��\'�m\�\�r���`�\�Ƶ�D\�*��\��Z��.�4��.\�D\�)&�\��Z\"�\�1yȐ_\�2\���e��ǧ�X�v:�r\�^*d!!ܭ��;����@\�3�\�=�?\'�kR\� \�Fq\�u*�\�\"�ˆ0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O��caY�r\�]��C\�(��%�g\�F6�X�͟+�\�\�\�a0Qԫ$]#cJ4 �\�HZ�&f�:\�dפ3b�\�\rs`*�\�%IZp��\nJ��%I\�2�c8�\�q�|\�8\�?�g\�qϞ8\�~�S&�S���\�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o��^�V��po9�\�k;:\�8�4\��\�*�n8���j\�`�d|\�һ���i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��^뒠\�\��*\�2p\�\��v#f\�~\���\�֫\�﷤�B�/�j��\�U�)����^��ĉ�,y-`\0$|kk�ם:4�����\�0\�Mj8�Pߨ)\�\�(��Y\�\0-yVQ�ې&E���\0�@�>,�\�>�����\��\�8\��\�c\��K�����?��\�m a\nm3\�;sR��$\�%Զ�\�*ͭL�԰\�\��\�M\��q\��u�a= \�^�LrE\�} �ĚXRra�]�ޱMd~4�\�D\�e-�8\�\�eᲬ6�[§�q\����t�\�\�\�TB��:�)M\�a����G`t�\�+w\��0ٍ��\�XC�o�K�ˎp$Q�K\� vY �3\�%�\�K㈲\\��\�p㌌ᅸ;KVP\�\�!M�*y̫��8\�u��-��;99��\r��\�rB}\�^xt;����^�o�V�ZR���9\�g��\�',5,1,'','','',''),(18,5,0,'9305bf00c9709025','12966450605bf00c9708ea1748285071','2018-11-17 12:41:59','2018-11-17 12:41:59','','','Profile Photos','person-300.jpg','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\���(: �+��j�o��6;+\�Ya�>\�U\�aZ\�\�H�bqc1oz ���\�\�iق%a��\��-��]�\�o)s\�^\�x[�-4й\�\�\�;��\�jT[���P\�lQ\�\�-�-\\v\�$�\� �g3N�]c\�F�|J���^���f<�:Ī\�u m�Y��ȬƼ��1[\Z�,En֨E��SpRƆ�\�AKp����\"K5�\�c��\�\�k\�{�\�=�z\�\�e��V�<\�}O�^�����ԋʜ9E\�\�!\rf*9ʱ\'9�4n�T\�+NS?Ƈ�!���j5X\�&�\�<1)d\�-���\�\\u��)_�\�]�lQ9���1Y\�¸K�!�\�\�`5��R�_\�\�\��y\�P�\�Ұ �\�h{G\�\�1ԏ P�\�\�\�ۦ%�<\�U0��]y?�gp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�۔…�a�\�\�a�YXj[�G\�ؤ\r\�0�\�B�\�<\�\�uo���y\�j:齟����!5\�6�O\�\\�D���|���@��+\\�c|>o���b_j�\�m\0�&\�Q\�<\�E\�-�A��Zv�\��� ��Ɍ�ԏ.y��\�\�M+z\�Ga�\���\�J\�&�\�\�Q�\'�۫\�|0�\�&G�(\�p\�<\�s0\�\�z�y|�r\�S�h]\�\�n�\�H|j=[P\�`vm�\�P�YE�SGM�M���\�\�\�$��\�h\0S9�\�\�J\�LJL}X�IQ�C\�$�k��gD̛�\�:�ռJ�t\\WU�u}A�\�L\�JsC\�V\�m�؂P�b@�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1>CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�9\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�7\��`\�\rf�\��a\�\�#�\�w^\�s\�\���c�\�j^2\�\�|\�����AaB\�vN�\�g\�\�1�ʋ�,\�3Ŝ E!_��\��=�U\�;\0\'u��\�,)E\����%�\�\"6@M:��\� QH�Yy���Y\�}b\�\�υf���\�\�_\��\0\�OE�m\�1):\�\�t\�^�\�y{l�c\��d|��4ͮ|r\�ņǝ&���\Z�s�\n[\�\�l\�DGԃ\�\'B\�lzu\�\�J���\�(I�]���u\��\�l\�@~d�ǐ�BR7[��Vռ�6`,z\�\�X�׵\�\�\�\�}�\�wֻFٴZͼض+ɧ�����;\�α��%\�<�RNG��#�\�>\��n1�|l�߷����\0�=�\��x\�?�\��S����g���\�{�\�KI˵\�\�=k\�a�&���\�\�\��S(#x�\�kY&�N�\��\�97�m\��Z�����Y��\��ڨ�uE���(�@�8��b���\�{\�٭\�\�\�sݜ�\�v~�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\���&\Z\�΍o\\\�9A/0\�p��4��\�xF|i\�\�C\�\�uXm.���S4Uq\n�\�ʟ��\rM\�%rV�\�[�\�\�<��\�o��]\�ضͮ�Ve\�\�[\�\'\��dك\�\�2<8QF5Ѓ\Z��4X1�\�G_�\�s�s���\�4OKW��v_x��ڍ3\�\�~9��N���\�\�\�B��2�r���\0���!���t�\�\�D\�E�u�\�+@\0E\0cF�F��8�72����\�\Zہ\���e%\�\�>\�ԁq\���^�cog/\�o\�+\�ɘ\�e\�dw�J��w�OŷGi����{oq��5\�Ϗ����c�\"rL:\�!�\n�����e\�B9��1f\'�Ө/��O\�gdcB�{fDM��&\�\�=��؆k*�e\�\���~v1\�c\���\�i\�\��\�\��)�\��#��[H�<�|��nږ�VV\��i{\�}��I��L\�Kf 9\�#\��򙿍KrԷ\�8�6��P\�\���1�\�\�XW5�\�\�#ᬙ\0\�ـ�Dx\\�m#>\�7.\�eDDDDDDDDDDD\\d \�2\�`�&<�)� cf2琏vp\�1�\�\\��8k�\�9\�1����M~C\�\�\�ҵ���|բ\�\�9�Ug��{\\����v�0��5�\��U,SH� x��&B|\"\\X\�\�t\�l{\��\�\�\�_v���\� ���[\�;d卮$RH�\�z\�?a�!\�8 \��dmf�D\�<+��n�&|\�2\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�\�\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�dJ�>\rm�s�1�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���\�\���J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf�`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8�R�\�7$ș>�W n\�I�#�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W�p\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i\"J�\�_0\�\�{\r�kA\"\�{�\�\�jwƯ��X*\�Z[\�m3�Ɋ\�\���\�\�l�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�_`F5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\"/\�u�w��\�1�p%\�\n<�9~3��G��-~XB�#��]f_��#f\�N�t\�S\�+Mk{3��7b(�pZF\�k�\�&\�{;�r �]\rE���\�8\� 7�0�!\� /-DDDDDDDDDDQH��p�D�V>V�,�g_����Z\�0�c��}��ƒd��\�b\�x\���m�&)\�<�# ��E.OF�\�:�\0\'܇�#v��\�Bp��\�\�{\�\�5�nq\�<�8g\�\�DDDDE�/Q�/j�E\�׺\�l��\�\�}�~rY[�g��0@\���\��G#�֊3O�\n0�\�\�8���\�E\�\�ϞX�ӕ\��;���k�/*�|lÍm{�]\�\ZuT�;��\�˹���x\�h2�C�26dF�f�w��ԝ\�\�dz\�|\�?�\�S�k��0$\�C�\�y&\�K�l�b��X��|��\0I]�f<�|�����U\�t]�f\�6ڹ{V��\\\�5,��ҩ�~\�ME\�\\���dW\�C�\� J,�.\�\��{;=}Iқ\�Z\�\ry�\�H�<�D/q�&�\�1 �`]}���\�\�0\�07�+>�JI0YNJA�L\��\�\�p�����/\�UZ�t+]�(��}���\�|@�ȣB\��S�\�:f�{\\\���Vtٻ$��<��>ql\09����O\��\��gAz<V\�-i���\�g\�\\��2K[�-g���8\�G)3�P��[\��6~+�{�\�U\�˪E�ę;,\� pa����n�s!�\�\�.\�f\�8\�\�a\�H����kaW�\�7�\�P��\\\�B�RX\�e�M,I�\�C�^�\����q\�+\�R�3X\�+X\�hdF�)�}V\�M\�q(8�\�\�cDW5�\� 7�\�c�\�c\�w�|㳬��\0e�\���\Z�iY�밚9[��\�\�ck���Z�s.u��\�G9\�x�\��\'J�aΦ�=�\��v��{Mʙd}��v\�\�أ�2L\�\�V�.�h�j�Hh���\�|�q#�\��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0���S\�2����&�\�.�\'\�hN \�mSfǩ�(\�5��\�ljb�1�F�ƍL x�\�ƌ \0F֌A\�шCkX\�5�cp\�c ���\�x\�\�|�t{��@\�\�\�\�xF\�x;c���\�#\�\�(\�\Z91�1��6̋\�*}T�Uc#8�*i9�w�\"\�6\�1\�\�b\�Kߴk\�\�\�׫\�\��{:k�\�\�2b��s\�Vg8i�̊S����L�r\0rt�h��o\�\�Oc�N~�n\���\�ԇ���v\�\�i\�Wg��>E\Z4cQ\r`\�#�U�b\�G���:4\�9t\"\"\"\�1�E� �\0\07�\�3\� �\"n^B��\�X1��s\���\rcq�;8\�3�Qw�\�v\�\�/�;�k��u��6\�\�\�c��\��g��Z�\�~����2noŠ�L�Z64Y)g�\�&.#��\�\�\��-c\����ߩ�c��J\�皭I�\� \Z\�\�Ut�\�$�\�o̸x����\�(,cB\�+\�Ǫj�֋�\�i\�u%n��\�u��(��\"�m]t1\�q\�Ŏ,a�c�w;?\"�y����{�\"\"\"\"\"\"\"\"\"\"\"(J���瞵\�\"�Y\�\�Ys��[�\�\�z�s\�X��\�7*�\����\�QHi O���i�G��=n����^7{\�ɾ8{}\�}�\�d�Ԭ�G޵܋\�\�\�7\�\"�w\�?̯\�\�έ fS�C6\�]Q*�s+�.����َ\'\��c�Ka���\�)k5\�%�;-��c��*.!��UsZW\�=�]�XVQ �6\\(\�wՌ�DDZ��\�Vg�\�\�\�g��nըu\��H.3\�\�N�k\Z�m\�\Z\��8�#_\�\�7\�\�D�\���^lٖS%\�\�˓>\�|�͝:i\�*dْ��ʗ.Q\��ȓ \�y�s=\�1^�\�{���\02�\�]ӊ>���\�S2�\�w�\�Ճ��&ma*�q�A�Z�KȦM�Z�\0`Ћ�\�L���\���cߚ\"\"\"\"\"\"\"\"\"\"\"\"\�0C(&�$\"�\Z@��\�aB`��BaaF9\� \�ܱ\�\�Z\�g\�U��n�pwM{ˣ�\'��Z�\�F��r\�\�T\�cck;\�\r\�L{\��\��\�\�n\�\�b\�b\0�i�&ۘ��ml$�+�k~�N\�s\�N\�r\�P��ϴ\�\� \���+Z�)3!꼉�\�\�M=t\��\'\�P͎�a�ͭc��\�I���V\�:\�\�\�DE�?S\r5\�\��\���&n�s=��\�\�t�㲽a~9ǴsUF�\�ϻp\�\�.\�p��^�\��U\�G)�q��v.@\�h�!� fW[\�u��b��x\�\"H� �aA�n~7�\�ñ|\'UWQ���OQ5Օ���\n  �X\�\�b ah��5�kq�\�\�=��\0�]��9\�{�\"\"\"\"\"\"\"\"\"\",h\�q��\�*\�\�8q\�ku�]�e��br\�o�\�$\nw6s%\�@�\�H�\"0\Z�>@�\���g\�m\�n2�zη�.vl䭐h[XNՁf\�e�\�Y3v�.\�����X\Z얺+s��2��T\��]�t\�\�\�K�}\Z\�c]\nª%�v��\�ݎ=@�\nh4䷱��\�\�5X�\�G9{�`t\� I\�\'\�Ɗs\�ܧ\�\�\�\�\�%r�\�\���jH3�ܭ�F�ڷ�*,�\�\�J\�7Q���*\�<�F�]\�6@\'�dcJ(C\ZO�X�L�\�1\�3��\0\��>i\�g/\\}���\�h���}dF\�e}xN����\�\�\�K\��R>�\0�r\'X\�$bO�9Y\�\�N<`�v�\�\��d��\�[\�WH\�=����dș\���\�Xg�����\��\�o�^4!m�\�7 �G�ދ�v\�}�\�\�\�\�y\Z�(5gض�K9q)`�������̰��8�5�ǛcȈ�\r|�p\\�\�tO�\\ ��,��ڲ��{\�oi�\�6��fXL>@l���\�\�;G��79v(\�\�h.uK�\�[c��O�kWT\�3�̫���j\�J\�L\�]�I�:1\��s�҉�\�s�{�\�\�\�$7��\�o�~E[����X�!�84{m��\�!aJ\\�H\� B!�|Xǻ8no�\�\�h���{\0Ñ\�P֭�H�\�%�4)��7�9ÆP��c��\�\�v3�\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $K�ca-\�#Ǝ\"��\�s�D&q�� �j�x\�{�xΉ\Z����jó\�p�M��8��;�#�4���\�j��b��\�b�?�G�\�\�Ŵ :��\�y+\�lZ:��\�k;Ȳ$f;lm�\�\� �Ξioc\�\�&��T\� X[8qy���)lA!bTh\���GW\�\"ֹ_\����O\�~�\�p���\�I�\�\�\�\Z�\��-\�i�m�\�\�c1�� UTVP&J���>Z�w½y\���\��\��p��U� \�\�y�R\�\�\��8\�2�$g\�\�J\�$��\��˟�l{{\Z\"\"\"\"*�}Q�-l�?݋~\��ޱ*[{o}o�\�\�.5f�\�\�2{�B\�\�8�;��m \�ϗ>Ćي\�Ɓ_y�\�W\�#\�\�.\��\�\�\�JM���P\�\�h�ȸ��y���\�\�E��줟8��m�\��\�R\�5�$��\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ\�q\n�;�\�\�\�M�^(ѽ\�$��0���eK^m<\�󟕎U�֨\�n�\�:��\��A\Zk��J\Z٤}~\�ȟCC�k\�\n5��$\�մ� /�O�_�Kŗ�\��[\�AqO \�f��\�8�K�/\�%\�\�x昙\�~\��X����\��\0\�\�Y\�O1\�\r.\�W\�\�����\�\�\�0\���F\����\0�y�a��?%�5��|\�a�~q\�gI��TV:5D\0�+\�F��]��\"\"\"\"\"ž\�t��{\�׾@\�o?j\��m}�,7��k�u�qe�)�Z)Xp\�@��� [(\�H�$2b���@\� U�m��\��\0�˝S�`H\�x[m��\�$\�\Zxq�\�\�\�$\��xo���\�\�@�YF�3�+hq�\�Ӹ��y_GּD�I\�]��\�=6n=٢F\�y���8��\�z.g2W\�8�qW�h֏�X\��1�����lʶ\�\Z\�%�}9\�_\0w��5.��\�z�ݴ}�s���[k�o3_\�\�\n\�N����\�\�Ÿ\Z,�ʋ&9\�Q�FT\"\"\"\"\"\"\"\".�(\Z0�\"D�`@\0�!�c\��!�\�!汌k�\�a�\�UR��\�2�=�\�\�[�gf��\��lvZ\�cF!�C\�.J����\��y`_:��\��:М\���$&�in\"�W���\�;y\�ɧfu>�ptC|�\nӐ9\n\�)�ka��\��[i�h�\�\�4-(5�p\�\�\�-~�ΗWT {ʛ�z/Ѿ�\�ם/��}ա\�\�z\�l&^ߺ$q\�[\�\�8\�e�ݵM2I�ֲ�\"[��Q3 �\\\�\�\�DDDDDDD^a\��+Ľ�\�\�Ú�\�R\��?\�\�\�U_j{�4K��\Z���fF %\r\�d \�Ex\"a�(�,�RJ \0O+\�\�\�\���EA\� ��\�}���_f�\�\rv�\�;TH�\�y/�3S\�j\�\�,���ӨgÃ!�ulK[\�X^���|\�\0n��r\� ��=jS�.m\�\����H�\"Xj܉�\�m�R�Kk,���66E,c`�3\�d �����1d�?\�\�Geb\�\��6�#�<\�(��\�j� �\��c��~��\�/Q+ \�I�\\M2���������0�\�\�Sw�_O�`\�8\�?$rg\\\�o1\"\�!���u\�s-\\Ytⰻ��i\Z�\�\�:�����7\Z�\�\�0̬���,\��Z�Qq�po�^�w��Y\��~�4\r{F䚢\�r�\�\�D���œ#J\�.,X�3eM��|Ղ%\�\�;3ƃ1aR#��0 6& \�ADn2 �˘A��Ø�\�-ss�79\�q�Ȉ�����)�@w絞W9w\\l\�B\�m_\�q�[I�Q\'9.˵lQ��\rE������\� ɝ\'\Z\� (��A������\�Ozfv��)d\�\�^��f\�J\�\�k\�6>h�\�\"\�\�t��\�\��Q6f\�iZf �q�\�\�~�\����}r��\0vOG�\�5{&^�q�\r�.��:)cD\�uv\�*m���k\�\�l�{�\���J\Z|�x\�\�ݩظ�\�O�\�,\�m���G�H\Z.N\�\�Ipc\�W\�`� ��G\�*6ڑ\�2\�\� B�kW�M#ҵ\�b\�\��~;;Y���\n)2��\0\�;4�>\�u:�}�4��\�l�b\�o\�㽭��w>\�h��a\�\\@خeN��\�m;�yKn�wF���\�ﱂK�:9���;1�\�q�8O�\�5\�\�2ܷ\�Ƿ��������7�\��\�#��r\�o[<�\r\���m��u$bs\�\��VJ���s\Z�7/~q�Ͷ~�\�kK�Q\�\�%i&\�\��Qd7�\�ā܇\��o\�EJֽ\�i\�v\�\��\��\�\�\��sK\�\�\�k\��4�\��Ȝ�\��,�D~G)\�\�vʊ610\�\�;`/\���-w\�\�\�ٽ\�C\�(�\�I\��-jh\Zv�Q�\�ĉQ\0(UA9lp�\0�7���\�e\�#\���\�s\�\�QS�\�/\�\�7L<�vM\�7ꜻ&cu\�l̒>��:\�\�\�ǒ�d\�f\� c�\�\�X0�,5�#2��Z�n�&7\�;o\�9\��\�V��}\�\�r\�\"ޠ\��+\�W\�\�K� \�A�ߜ\�\�\�^\0���\"\"\"\"\�O�=2^�\�S�:\�lSM�\'\\��\��c\�\�Dz�����߾Xǃ9Ǿ}�x��\��\�icoq�Pn\�N쎭\'\�\�p�% \�x�V� ԭe�Nr\�g$v�.��?���\0��m�\��5����\�@!�k\�y�\\�����\�\�i\�\�\�8�\0# HL��yBY�\�q2\���ai\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���F�\�/�\'c\�!\�h�\���\�\�L�\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\���\�V\0 �� �RDDDD_\���Al(�\� �#Z�onZ�\�Z�=�\�\\\�c-ss�g\�s�\�L]z\�2~q��9�(# �\�kc�����\�1�ٍ�[\�lc9���S�DDDDDDDDDDDDQ��`�\�\�e�\�\��]ό\��r\�p\�#|��\������\0�����Xj�B�\��1fWVx�\�\�\Zܘ֔ ���8n2Be�2�{�\�\�3\�X\�m�\"\"\"\"\"�G�m�C�\���\���\�\Zia�<\�\�w.X坲\�\�!=\��c(\�\�|\01�\0\�l��\"\"\"\"\"\"\"\"\"\"\"\"��~�\��\0\�gi\�V2�\0�/<9����\�\�\�\��\�\�\��E��*V[\�?\��\r�#�\�\���ĉ\�K\��Ӿ���\0�\�hT�\��v3����DDDDZ�\�\�qO��.�\�*��\�s\�6RZC��l �(�\�z�q`\��n>�\n\�^\�dNs�ƈ������������\�c�\��\�c��?PfF��h\�\�\� [(p%�\� \�\�Gt�ı$3�0\��3?�ɇ�\�_\�=8\�<,��?��ڄA\���,\r^�Q0\�?710`x�ȱ��\�k\�\�\�*|*�f��uՕMdx3�)��w$?g\�c1�&\Zs�\���ۂ�\�\��\��_\�}\�DDDDDDDDDDDE\�7=F�|\�\�u\r�e55\�\\C�ѕ\���|a�x\�\�\�\� 3\�\�=�ѡ���Q\�kՍ{+hjk��\�Ga\�d\Z�a��{Z\�9\�\0�; n\�g8kq�c\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/�\�',4,1,'','','',''),(20,6,0,'9305bf00cb1412b6','19504305895bf00cb14111f611783866','2018-11-17 12:42:25','2018-11-17 12:42:25','','','Profile Photos','person-300.jpg','image/jpeg',80,80,2354,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0+\0\0\0\0\0\0\0\0 \n!#\"AB�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�\�\��7��M�\�PCm;\n\�f˲�\�A�\"&\n(�\�\�\�z\�r+\�\�*�������N\��1dVq��+:}\�w�N\�\�z/۝ \�\�I�̮���֩�\�uJ�7\Z��I��A�\�y�\��d�2m`\�^�N|HKFow\�n�R�ɭc5mö�0�\�~�S��r\�\�\�t<Ŏ8\�M�i\�-2\��C���z*�\�}\�\�`�C\�_��\�qț\�\�\'�0\�g�(督�v\�\�pQ�aYV\�[=��AXFR�\�\�Y�\Z�p�|6R�����Po��S�z�\��\��B�9d\�\�\�V�؛�����jl.e6Nڔ\"S\�\���pσ��\�U�{\r�<���є\�\�*�F�@�V�Ԙ��:�W�V`�b6�]���hX��\�\�F\�G�8a\n\�\��\�hN�c�sѿ7�?�\Z*GLn�.���{^\�m}k��Y(IF\�s(���\���#\�#Y�+{|2���&%hZ�\�OL��~�\0\�:\�vڗ-���\�\����E�\�;=����!\0\�DZ�\�vW\�^���Q\�\"\��5y\0\Z\�G\�ل��J �]�\�٥\�C�����T��\�\Z���iR.!-:eZ�_���>�(q԰J\�d��H�qya�\0\�\�T��9\��\�\�/�Z�Ź��\�潩D��O�\�\"f4D�\�r�R\�wDPm5�-\�\�\�]P���>A\r���+\�s��њ�K^�_\�z\�A\�t\�n3�V\�)\�ٚ@��.g��E0{�H�\0.`F�Z��P\�qi7�k\��\\=����4ą��םL\�c\��\�+c\�\�\�\�ΩN h\�HH\�\�&\��O\r^��m\�\�K1�R\��\�\0z�\�?W��\'�m\�\�r���`�\�Ƶ�D\�*��\��Z��.�4��.\�D\�)&�\��Z\"�\�1yȐ_\�2\���e��ǧ�X�v:�r\�^*d!!ܭ��;����@\�3�\�=�?\'�kR\� \�Fq\�u*�\�\"�ˆ0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O��caY�r\�]��C\�(��%�g\�F6�X�͟+�\�\�\�a0Qԫ$]#cJ4 �\�HZ�&f�:\�dפ3b�\�\rs`*�\�%IZp��\nJ��%I\�2�c8�\�q�|\�8\�?�g\�qϞ8\�~�S&�S���\�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o��^�V��po9�\�k;:\�8�4\��\�*�n8���j\�`�d|\�һ���i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��^뒠\�\��*\�2p\�\��v#f\�~\���\�֫\�﷤�B�/�j��\�U�)����^��ĉ�,y-`\0$|kk�ם:4�����\�0\�Mj8�Pߨ)\�\�(��Y\�\0-yVQ�ې&E���\0�@�>,�\�>�����\��\�8\��\�c\��K�����?��\�m a\nm3\�;sR��$\�%Զ�\�*ͭL�԰\�\��\�M\��q\��u�a= \�^�LrE\�} �ĚXRra�]�ޱMd~4�\�D\�e-�8\�\�eᲬ6�[§�q\����t�\�\�\�TB��:�)M\�a����G`t�\�+w\��0ٍ��\�XC�o�K�ˎp$Q�K\� vY �3\�%�\�K㈲\\��\�p㌌ᅸ;KVP\�\�!M�*y̫��8\�u��-��;99��\r��\�rB}\�^xt;����^�o�V�ZR���9\�g��\�',5,1,'','','',''),(21,6,0,'9305bf00cb1412b6','19504305895bf00cb14111f611783866','2018-11-17 12:42:25','2018-11-17 12:42:25','','','Profile Photos','person-300.jpg','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\���(: �+��j�o��6;+\�Ya�>\�U\�aZ\�\�H�bqc1oz ���\�\�iق%a��\��-��]�\�o)s\�^\�x[�-4й\�\�\�;��\�jT[���P\�lQ\�\�-�-\\v\�$�\� �g3N�]c\�F�|J���^���f<�:Ī\�u m�Y��ȬƼ��1[\Z�,En֨E��SpRƆ�\�AKp����\"K5�\�c��\�\�k\�{�\�=�z\�\�e��V�<\�}O�^�����ԋʜ9E\�\�!\rf*9ʱ\'9�4n�T\�+NS?Ƈ�!���j5X\�&�\�<1)d\�-���\�\\u��)_�\�]�lQ9���1Y\�¸K�!�\�\�`5��R�_\�\�\��y\�P�\�Ұ �\�h{G\�\�1ԏ P�\�\�\�ۦ%�<\�U0��]y?�gp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�۔…�a�\�\�a�YXj[�G\�ؤ\r\�0�\�B�\�<\�\�uo���y\�j:齟����!5\�6�O\�\\�D���|���@�e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(23,0,9,'9305bf00e7f8a224','53312796025bf00e7f82e82896954942','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(24,0,9,'9305bf00e7f8a224','53312796025bf00e7f82e82896954942','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(25,0,10,'9305bf00e7f924b7','95550517915bf00e7f84cd7979805449','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(26,0,10,'9305bf00e7f924b7','95550517915bf00e7f84cd7979805449','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(27,0,10,'9305bf00e7f924b7','95550517915bf00e7f84cd7979805449','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(28,0,12,'9305bf00e7fb7439','73148550885bf00e7fb1447228602212','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(29,0,12,'9305bf00e7fb7439','73148550885bf00e7fb1447228602212','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(30,0,12,'9305bf00e7fb7439','73148550885bf00e7fb1447228602212','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(31,0,11,'9305bf00e7fb6ff3','11822615065bf00e7fab8ba088168210','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(32,0,11,'9305bf00e7fb6ff3','11822615065bf00e7fab8ba088168210','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(33,0,11,'9305bf00e7fb6ff3','11822615065bf00e7fab8ba088168210','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(34,3,5,'9305bf00e7fdf2d2','15820583805bf00e7fd7909940374120','2018-11-17 12:50:07','2018-11-17 12:50:07','','','Contact Photos','3.jpg','image/jpeg',300,300,11005,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0\0 \n!\"1A#3BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`>V�\��<|\�\r\�\�Rľ\��\�vM��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\Ή�6�\�u\�x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7�\�+�\�,;:\�\�<Լe����\�KM�…�\�81f\�-��cM�Y�g�8�B�\"=;}��{.��v\0N\��U�XR�\��EFKi�Dl��uW\���(�*�\�l���(����\n\��ò�\��\0����\�\�bRuۆ\���q���\�\n\�\�;,\��q�i�\\�屋 3�:MUd350\�\Z�/Ř٢\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"���\�N�\�\��멶���c�P���|k\�\�Y�\�΀�\��!Z��n�M8��x%\\l�D<>�U�\�\� \�03EW����\0�\�\��W%o�ſm��\�]\��\�;\�\�͋l\�\�\�f]�ռ�|\�&A=�1\r�\�#Å`�]1�\�\�E��㛑��\�ؙ|~N\�*Yc+�+*�a� �j�\Z?Ћ�K}��$L\�Z\�\�Q\�β�\0P4`�4h�#\�\0\�G\0Y��!\�шBZ���k\�\�n�as\",=\�|z�\�\�#s/ky�q�M� R]\�S\�-H�kiu\�66\�r�������f\\fGx$��\�7z\��[�\�v������F� ^\�l���{\�\�\�\'$à�H\���y \�^�D#�\�bz\�:��Xd��vF4/��dD۸\�l�3\�͈f��]\�\�c�\�>��6��~�\��\�2��ϱ�8ke��\�ɇ\�\�6\�k�enq�Ɨ�ˠn�\�g/1�كs\�F|�\�<�o\�Rܵ-��&ͤl\�;v�=�|;�r\�\r\�a����k&@1��`\'?Hϛ�c ��\"\"\"\"\"\"\"\"\"\".2a b0BB��\�1�s\�G�8k\�\�.{ݜ5�\�s�\�\�UFަ�!�]\��?�\�Z\�\�\�> \�\�d�\�ƪ�\�\� ���IK\�[�@\�QK\Z\�m�_��)�X<}M�!>.,bb:��=\�\��\�䯻|G\�jO��[Z;d卮$W\��\�z\�?a�!\�8 \��dmf�L\�<++��n�&|\�6\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�S\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�J�>\rm�s>�)�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���X\�܍J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8 9R�\�7\�ș>�W�n\�I�#\�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W��\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i!ʾ\�_0\�\�{\r�kA\"\�y�\�\�jwƯ��X*\�Z[\�l�3�Ɋ\�\���\�Il�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�YxF5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\".\"u�w��\�1�p%\�\n<�9~3����fZ���3\�Gc4�\"̿zF\�ȝ�\�Ƨ�V�\��ge8n\�Q\0഍�\�w�M��w�\�:�\Z�+C\r�q�oaC\�^Z������������\�\n\�(�\�|�$Y,οsOl��a\�G.�w�$\�#\�3\�\�=��\�)9p\�\"LS�y0FAU|�\\��\r�u�O�Rx��\�c\�m�f|F\�ceԄ\�\�9\�\�0���kr\�\�;�ynpϕ������H^�^\���ït_�\�\n 9�3\�:�^�d��\r\�\�\rMt`��!M����G5�f�$a��qI{ҋ\�}��<�q�+��w\�]T\��^U\�mp�ه\Z\��^�\�4깂v 3/��ska��\�\ne�.dlȌ)v\�\"\"\"\"\"\"\"\"\"\",>\��Y�;�\�\������8�j\�!`I��u��M��P\�8\�hh�1����\�y,�G-n��\�Vͤm�r(��7`�\�vjYyeS\�:������9 ȯ��&!��Y ]��\��vz���7���\Z�đ�yL�>^\�~M\�\�b\����a�W��a�`o V}z��`����6��%��\�@_֫\�`�n\�V�>Q7�;\���e�F��\�c\�t\�z�����\�vHn%By)Bh�`ϯ��~��\0�: \�\��)\�M�\�gk?J\�\rŒZ\�\�kSv\�!}\�\���]���\�`ٶ.6\�m�<8w�=<\�\�\��\�w\�a�p{x�T\�N[F¦�\"E,��\�d\�\�wV�}=�%\�|�w�;�4\�us��]��U�#\�u�a#XB���Α���;#\����\�1u���hB��`;x�MSZ\�u�M;N��\��}n�5EDaî���<ᕷ���uhO\�\�Myy῵��|�Ʌ[�\\\�||;9\�-�\�X_�q\�\�Q�G6s\�\�0�˱�*|נq=v\�\�|i�\\E$ꝋ�4\�+HB)Y�\��m|أ0^3�#H(�Ae���\�v0\�_ \�U\�|}�\�\�B\ruef��ƒ`4z��Z&c\rfZ\�61�\��\0,�9vs��DDDDDDDDDDDX\�\�>\�u�:U��\0\�p\�$\�\�7�^\�[\�\�\���H\�>l\�K�+�\Z�ؑ*D`5�|�aс\�Ϭ۠\�e(��o\�\\\�\�\�[ ж��;�ͬ\�cIJf\�D]�\�y�<�5\�-tV\�9 d;n�\�\��\�\�=���B�5�ƺ�TKZ\�O߻z�^\�i\�oc[[�\�j�\�\�\��ݛ�`bO)?4S�6\�8>�^g~�+�v~���P\�A�\�\�l�5&տ\�Qd\�>\ZVi��ŔyVq\�\�\"4b\��<�#\ZQB\�|�\�\�e�\0i�Y�\��H���Ok9z\�\�\rͅ�GǴ\�\"6S+\�\�\�u�̭v��2_(5������:\�#|\�\�\�x>�\�q\�S��V��<\�%��Rޢ�F1\�;&D\�\�\�\"\�?�\�{-��\�\�D�hB\�\�n�u�\Z;�\�\��gٷ�\��40�Pjϱmv�r\�R�sO&=n!WG�ai(q�k)�6Ǒ\Z�\��\�n��+Y\�\�)i�ekp��\�\�S�mA�̰�|�\�2)#5\�\�v��nr\�Qհ\�\\\����\�]&�`֮,�/�f3�WsO4\�֕ҙ�� �\nlc\�;0\�a��\��]���H:o)�\�f���U\�\r7d�+FC8ph�:\�9o�B”�`\"�\��B?8���vp\�\�\�\�\�/�-\"��\"֡�[B�\'a\�\��hj5MR���\'�I�R�\�!2%F��\�x\��uq��-k���\�/\���请_��D����� �\�\���v\�n.�&3\ZH0\�UCed��z\�\�g\\+מ \�~�]�p�\�i�Q�\0�ǚ�.�^(�\0c��#*�F|�\�\��IO9\\\�l���\�q숈������Gx��\��\0v-�ǚĨ�m\���\�S` �՚\�3L\�\� Ps� \�\�bm���>d�f+G\Z|Q\�/�\�_L�{h��\�#�u)6yC���p_\"\�\�i\�\�\�_�\Z^���|\�~e�\�4\�eK�\�1䝵\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ�q\n�;�\�\�\�M�(�\�$�\� \nVT�\�\�\�9�X\�[�j�\�\�:��\\lJJ���8�\�5\�O�&.J�Ǩ\�\�ȍ�o�\'Ģ�#0�\�o\�A\�n�K:v\��Ym7�[\n\�C@\�ͪ,\�5�7\��ȉ��Y����\�\�5~(\�?�\�\�\�6��]\�([\r\\\�\�\�\\\�+h\�r�>�K`NJܖD�lF5�\�\�C��\�aշ]7x�\�=\'m\�w\n\�9�}��}U�Acߌ\�t��r\�\�\�]��$\�s��8\�}��:\"\"\"\"\"\"���;\�~z3\�\�\�݅�\\�\0\�V\�q��J��\�lڎ����>\�*\�ð���\�\�d\rM�)Hz}�\�\�\�\��T%EE���e\r\re�\�\�݄*�jj�Rl�m\�l��ueet1\Zd� � ��E dʒQ\0y^\�\�\�?O��*],\�c\�5�2�5\�pk��yڢE�$s\�|ɚ��W6Id\�m&�C> �bZ޲\��\�Us\�HwȈ���������Ö�O��\�R��soh<��ZG�\�V\�MN�o��\Z[X\�`-m\�)��)c\�)� M����ݹ�\'��\0H\�J;+�\��A\�9D|\�kT\�e\�\0\�;���\�ǟ\\iz�Y�LJ\�\�\�U,��5\�\�M���\Z��|��|�a\�q��:\�{y��\r�l��8+�j\�˧�\�D H�Զ\� Ք�,%O���\�V���eeu\�igL\�\�ת��������\��\���Ѡk\�7$\�Xx���\��%l�N,�\ZV\�q`\�����*mt�\�.�Yٞ4���!�a��0^\��#p�df\\\� �vǷ9k��9�\�3��DDDDDEQO��w=��˺\�f8�Y+j�C�\Z\�L\�8�\�v]�b�^f056\�\�z�P�&t�k�$�\�YO�J�v �=\�\�A��\'�z��-+���\���\� ��S\�b˃8Z\�Dɻ�i�2\�ƣ�#��g\�\�tDDDDDDDDDDXs\�N��\�\�gl\�=�l\�\�yz�\�@6l�.\�\�ōi\�mۆ̩��\�e�tS���\�g\�(i��\�\'��Wv�b\�O�am�Y�\�G �\��4\\�\�ƒ\�Dz�5�d\'\�Q8�\�jG�˫�`dH�\n�_\�4�Jל\r��k\��\�\�f\�K\�V\�(�\�\��\0 \�\����A\�\��\�\�6 ��1�}�[����A\�����\�9�iqb��8DDDD^s\�!�\�%\�-�1\�\ZF�\�[�\� ,vX\�\�\�l\�\�f�\�X\�>3H\�c8\�r\�|gʇ\�B޶P\���Kۤ�f\�\�{�ͽl�7R��\�m�ԑ�\�+�3\�XI+\�\�k�ܽ�\�\�6\��c�i�u/\�G l���#n\�DX}�\�\�7��\�m���(j�A �L�\�j\�\�28�\0Dy$��E\r�\�\�4�Q���x\�\����;��\�\���\�Yrwl\�6O׭\�N�MH\�?Y\�\Z�o��\�Z\�q,$Fq+�\�s\�EP�r_&���t�S\�\�g��\n�f\�T;\0\�UΨ\�u �E-�s\r[d\�\�E&\�G �_.�\�4�ќ\�Wst?��\�\�\�]�=J\�\�g1qV��\�\�`�\�\"�`���k\�i\�SG�:�� �R6>2ƌ�Fr\�\"\"\"\"\�\�.C�\��/�\�ȕn��\�:\�\�6U��r�o鿍93Z�\r�9\�? ~[�g?\�\�/|���9���\�S$>7\"r�\����m\�*( \�\�\��H\�\��� vXL�\�{{f�� L�\�\'Sе�q�Pi\�\�F�S$aD\0�TA \�\��\�V�\�/�\�/)��\�s�\�ڈ������������QO)�a\�{��n�q�T\�\�0��Cfd�� \��\�\�u�<��\�[��8��� a���\��\�ףw�1�x�\�x�1\�7�Gb��{\�>[�\����X\���\"_�_ &�\�\�vZ�t�Q�|\�\�r�\���\�kb�m�:\�ϧ�>\�=�lMq\�.`ݙM \"\�\�\�\�/\�X\�4���\0���\�o-�\�\�ŝ˘�e�L\��u}Of\�\�\�#~\�3u$i\�+X瀱\�a\�\�6���p>��4֨;9ҾR��\06�H\�P׶[�X�e��m9\�\���iI\Z����c�#@kI�[�.n6I\�iqҾ\�-\�\0\�\�R�o���V�>��7���\�<\�=�\��\�\�\�kH����{���v\�wdui?�ۄ�(Nkǂ���k,bp�\�81#�Yw\�������So���<\��O ț_+ϒ\�\r\�dp\�\�W{Ng7���\0ZBe��\�\�9!\�\\H��������������\�\�k\�\�\� \�~�{P�W2k?~p\��+\�\����\�\�|>M�$\���\'\��\0P��c#˳�>�]n\rO��\�4 Ǘ�vGw�c$bh\�`j\�]z��F�1\0\"1\�˜\�\��\�1�keˆ�������\�w\ns e\�l\�-\��s}�XčJ\�9\�}��0�\0|{c\�\�\�\��UR�O4�m�e�vX�6�H\�a\��|��q5�R \�Z\�\����\�\�\rvK�s�\�9ض�Bc\�\�[�����_�`�Wj����\��W�\�}/돷�\\;\���>�\0\�\�\�H�~!�iФ` \�s\�4�\�j_\�8�5z\�D\�p<�\�\��]�\�\�\"\�~�5�k���DDDDE�u�\Z9\�VU5�\�\�ئ6\�\�H~ϲ\�c0L4\���\��7\'��7�\���\�\"\"\"\"\"\"\"\"\"\"\".��\�4\�\�s�l)��b\������ #\� {\�V�?`Y�|�\��\�q\����hi�\�t\��c^\�\�\Z�\�j�\�y�`\�k\�ֱ�~\0\�\�\�[�;\��~\��Ȉ�����������������������������������������������������������\�',4,0,'','','',''),(35,2,4,'9305bf00e7fdc853','12287146285bf00e7fd3b7e500413187','2018-11-17 12:50:07','2018-11-17 12:50:07','','','Contact Photos','2.jpg','image/jpeg',300,300,11005,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0\0 \n!\"1A#3BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`>V�\��<|\�\r\�\�Rľ\��\�vM��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\Ή�6�\�u\�x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7�\�+�\�,;:\�\�<Լe����\�KM�…�\�81f\�-��cM�Y�g�8�B�\"=;}��{.��v\0N\��U�XR�\��EFKi�Dl��uW\���(�*�\�l���(����\n\��ò�\��\0����\�\�bRuۆ\���q���\�\n\�\�;,\��q�i�\\�屋 3�:MUd350\�\Z�/Ř٢\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"���\�N�\�\��멶���c�P���|k\�\�Y�\�΀�\��!Z��n�M8��x%\\l�D<>�U�\�\� \�03EW����\0�\�\��W%o�ſm��\�]\��\�;\�\�͋l\�\�\�f]�ռ�|\�&A=�1\r�\�#Å`�]1�\�\�E��㛑��\�ؙ|~N\�*Yc+�+*�a� �j�\Z?Ћ�K}��$L\�Z\�\�Q\�β�\0P4`�4h�#\�\0\�G\0Y��!\�шBZ���k\�\�n�as\",=\�|z�\�\�#s/ky�q�M� R]\�S\�-H�kiu\�66\�r�������f\\fGx$��\�7z\��[�\�v������F� ^\�l���{\�\�\�\'$à�H\���y \�^�D#�\�bz\�:��Xd��vF4/��dD۸\�l�3\�͈f��]\�\�c�\�>��6��~�\��\�2��ϱ�8ke��\�ɇ\�\�6\�k�enq�Ɨ�ˠn�\�g/1�كs\�F|�\�<�o\�Rܵ-��&ͤl\�;v�=�|;�r\�\r\�a����k&@1��`\'?Hϛ�c ��\"\"\"\"\"\"\"\"\"\".2a b0BB��\�1�s\�G�8k\�\�.{ݜ5�\�s�\�\�UFަ�!�]\��?�\�Z\�\�\�> \�\�d�\�ƪ�\�\� ���IK\�[�@\�QK\Z\�m�_��)�X<}M�!>.,bb:��=\�\��\�䯻|G\�jO��[Z;d卮$W\��\�z\�?a�!\�8 \��dmf�L\�<++��n�&|\�6\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�S\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�J�>\rm�s>�)�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���X\�܍J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8 9R�\�7\�ș>�W�n\�I�#\�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W��\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i!ʾ\�_0\�\�{\r�kA\"\�y�\�\�jwƯ��X*\�Z[\�l�3�Ɋ\�\���\�Il�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�YxF5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\".\"u�w��\�1�p%\�\n<�9~3����fZ���3\�Gc4�\"̿zF\�ȝ�\�Ƨ�V�\��ge8n\�Q\0഍�\�w�M��w�\�:�\Z�+C\r�q�oaC\�^Z������������\�\n\�(�\�|�$Y,οsOl��a\�G.�w�$\�#\�3\�\�=��\�)9p\�\"LS�y0FAU|�\\��\r�u�O�Rx��\�c\�m�f|F\�ceԄ\�\�9\�\�0���kr\�\�;�ynpϕ������H^�^\���ït_�\�\n 9�3\�:�^�d��\r\�\�\rMt`��!M����G5�f�$a��qI{ҋ\�}��<�q�+��w\�]T\��^U\�mp�ه\Z\��^�\�4깂v 3/��ska��\�\ne�.dlȌ)v\�\"\"\"\"\"\"\"\"\"\",>\��Y�;�\�\������8�j\�!`I��u��M��P\�8\�hh�1����\�y,�G-n��\�Vͤm�r(��7`�\�vjYyeS\�:������9 ȯ��&!��Y ]��\��vz���7���\Z�đ�yL�>^\�~M\�\�b\����a�W��a�`o V}z��`����6��%��\�@_֫\�`�n\�V�>Q7�;\���e�F��\�c\�t\�z�����\�vHn%By)Bh�`ϯ��~��\0�: \�\��)\�M�\�gk?J\�\rŒZ\�\�kSv\�!}\�\���]���\�`ٶ.6\�m�<8w�=<\�\�\��\�w\�a�p{x�T\�N[F¦�\"E,��\�d\�\�wV�}=�%\�|�w�;�4\�us��]��U�#\�u�a#XB���Α���;#\����\�1u���hB��`;x�MSZ\�u�M;N��\��}n�5EDaî���<ᕷ���uhO\�\�Myy῵��|�Ʌ[�\\\�||;9\�-�\�X_�q\�\�Q�G6s\�\�0�˱�*|נq=v\�\�|i�\\E$ꝋ�4\�+HB)Y�\��m|أ0^3�#H(�Ae���\�v0\�_ \�U\�|}�\�\�B\ruef��ƒ`4z��Z&c\rfZ\�61�\��\0,�9vs��DDDDDDDDDDDX\�\�>\�u�:U��\0\�p\�$\�\�7�^\�[\�\�\���H\�>l\�K�+�\Z�ؑ*D`5�|�aс\�Ϭ۠\�e(��o\�\\\�\�\�[ ж��;�ͬ\�cIJf\�D]�\�y�<�5\�-tV\�9 d;n�\�\��\�\�=���B�5�ƺ�TKZ\�O߻z�^\�i\�oc[[�\�j�\�\�\��ݛ�`bO)?4S�6\�8>�^g~�+�v~���P\�A�\�\�l�5&տ\�Qd\�>\ZVi��ŔyVq\�\�\"4b\��<�#\ZQB\�|�\�\�e�\0i�Y�\��H���Ok9z\�\�\rͅ�GǴ\�\"6S+\�\�\�u�̭v��2_(5������:\�#|\�\�\�x>�\�q\�S��V��<\�%��Rޢ�F1\�;&D\�\�\�\"\�?�\�{-��\�\�D�hB\�\�n�u�\Z;�\�\��gٷ�\��40�Pjϱmv�r\�R�sO&=n!WG�ai(q�k)�6Ǒ\Z�\��\�n��+Y\�\�)i�ekp��\�\�S�mA�̰�|�\�2)#5\�\�v��nr\�Qհ\�\\\����\�]&�`֮,�/�f3�WsO4\�֕ҙ�� �\nlc\�;0\�a��\��]���H:o)�\�f���U\�\r7d�+FC8ph�:\�9o�B”�`\"�\��B?8���vp\�\�\�\�\�/�-\"��\"֡�[B�\'a\�\��hj5MR���\'�I�R�\�!2%F��\�x\��uq��-k���\�/\���请_��D����� �\�\���v\�n.�&3\ZH0\�UCed��z\�\�g\\+מ \�~�]�p�\�i�Q�\0�ǚ�.�^(�\0c��#*�F|�\�\��IO9\\\�l���\�q숈������Gx��\��\0v-�ǚĨ�m\���\�S` �՚\�3L\�\� Ps� \�\�bm���>d�f+G\Z|Q\�/�\�_L�{h��\�#�u)6yC���p_\"\�\�i\�\�\�_�\Z^���|\�~e�\�4\�eK�\�1䝵\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ�q\n�;�\�\�\�M�(�\�$�\� \nVT�\�\�\�9�X\�[�j�\�\�:��\\lJJ���8�\�5\�O�&.J�Ǩ\�\�ȍ�o�\'Ģ�#0�\�o\�A\�n�K:v\��Ym7�[\n\�C@\�ͪ,\�5�7\��ȉ��Y����\�\�5~(\�?�\�\�\�6��]\�([\r\\\�\�\�\\\�+h\�r�>�K`NJܖD�lF5�\�\�C��\�aշ]7x�\�=\'m\�w\n\�9�}��}U�Acߌ\�t��r\�\�\�]��$\�s��8\�}��:\"\"\"\"\"\"���;\�~z3\�\�\�݅�\\�\0\�V\�q��J��\�lڎ����>\�*\�ð���\�\�d\rM�)Hz}�\�\�\�\��T%EE���e\r\re�\�\�݄*�jj�Rl�m\�l��ueet1\Zd� � ��E dʒQ\0y^\�\�\�?O��*],\�c\�5�2�5\�pk��yڢE�$s\�|ɚ��W6Id\�m&�C> �bZ޲\��\�Us\�HwȈ���������Ö�O��\�R��soh<��ZG�\�V\�MN�o��\Z[X\�`-m\�)��)c\�)� M����ݹ�\'��\0H\�J;+�\��A\�9D|\�kT\�e\�\0\�;���\�ǟ\\iz�Y�LJ\�\�\�U,��5\�\�M���\Z��|��|�a\�q��:\�{y��\r�l��8+�j\�˧�\�D H�Զ\� Ք�,%O���\�V���eeu\�igL\�\�ת��������\��\���Ѡk\�7$\�Xx���\��%l�N,�\ZV\�q`\�����*mt�\�.�Yٞ4���!�a��0^\��#p�df\\\� �vǷ9k��9�\�3��DDDDDEQO��w=��˺\�f8�Y+j�C�\Z\�L\�8�\�v]�b�^f056\�\�z�P�&t�k�$�\�YO�J�v �=\�\�A��\'�z��-+���\���\� ��S\�b˃8Z\�Dɻ�i�2\�ƣ�#��g\�\�tDDDDDDDDDDXs\�N��\�\�gl\�=�l\�\�yz�\�@6l�.\�\�ōi\�mۆ̩��\�e�tS���\�g\�(i��\�\'��Wv�b\�O�am�Y�\�G �\��4\\�\�ƒ\�Dz�5�d\'\�Q8�\�jG�˫�`dH�\n�_\�4�Jל\r��k\��\�\�f\�K\�V\�(�\�\��\0 \�\����A\�\��\�\�6 ��1�}�[����A\�����\�9�iqb��8DDDD^s\�!�\�%\�-�1\�\ZF�\�[�\� ,vX\�\�\�l\�\�f�\�X\�>3H\�c8\�r\�|gʇ\�B޶P\���Kۤ�f\�\�{�ͽl�7R��\�m�ԑ�\�+�3\�XI+\�\�k�ܽ�\�\�6\��c�i�u/\�G l���#n\�DX}�\�\�7��\�m���(j�A �L�\�j\�\�28�\0Dy$��E\r�\�\�4�Q���x\�\����;��\�\���\�Yrwl\�6O׭\�N�MH\�?Y\�\Z�o��\�Z\�q,$Fq+�\�s\�EP�r_&���t�S\�\�g��\n�f\�T;\0\�UΨ\�u �E-�s\r[d\�\�E&\�G �_.�\�4�ќ\�Wst?��\�\�\�]�=J\�\�g1qV��\�\�`�\�\"�`���k\�i\�SG�:�� �R6>2ƌ�Fr\�\"\"\"\"\�\�.C�\��/�\�ȕn��\�:\�\�6U��r�o鿍93Z�\r�9\�? ~[�g?\�\�/|���9���\�S$>7\"r�\����m\�*( \�\�\��H\�\��� vXL�\�{{f�� L�\�\'Sе�q�Pi\�\�F�S$aD\0�TA \�\��\�V�\�/�\�/)��\�s�\�ڈ������������QO)�a\�{��n�q�T\�\�0��Cfd�� \��\�\�u�<��\�[��8��� a���\��\�ףw�1�x�\�x�1\�7�Gb��{\�>[�\����X\���\"_�_ &�\�\�vZ�t�Q�|\�\�r�\���\�kb�m�:\�ϧ�>\�=�lMq\�.`ݙM \"\�\�\�\�/\�X\�4���\0���\�o-�\�\�ŝ˘�e�L\��u}Of\�\�\�#~\�3u$i\�+X瀱\�a\�\�6���p>��4֨;9ҾR��\06�H\�P׶[�X�e��m9\�\���iI\Z����c�#@kI�[�.n6I\�iqҾ\�-\�\0\�\�R�o���V�>��7���\�<\�=�\��\�\�\�kH����{���v\�wdui?�ۄ�(Nkǂ���k,bp�\�81#�Yw\�������So���<\��O ț_+ϒ\�\r\�dp\�\�W{Ng7���\0ZBe��\�\�9!\�\\H��������������\�\�k\�\�\� \�~�{P�W2k?~p\��+\�\����\�\�|>M�$\���\'\��\0P��c#˳�>�]n\rO��\�4 Ǘ�vGw�c$bh\�`j\�]z��F�1\0\"1\�˜\�\��\�1�keˆ�������\�w\ns e\�l\�-\��s}�XčJ\�9\�}��0�\0|{c\�\�\�\��UR�O4�m�e�vX�6�H\�a\��|��q5�R \�Z\�\����\�\�\rvK�s�\�9ض�Bc\�\�[�����_�`�Wj����\��W�\�}/돷�\\;\���>�\0\�\�\�H�~!�iФ` \�s\�4�\�j_\�8�5z\�D\�p<�\�\��]�\�\�\"\�~�5�k���DDDDE�u�\Z9\�VU5�\�\�ئ6\�\�H~ϲ\�c0L4\���\��7\'��7�\���\�\"\"\"\"\"\"\"\"\"\"\".��\�4\�\�s�l)��b\������ #\� {\�V�?`Y�|�\��\�q\����hi�\�t\��c^\�\�\Z�\�j�\�y�`\�k\�ֱ�~\0\�\�\�[�;\��~\��Ȉ�����������������������������������������������������������\�',4,0,'','','',''),(36,3,5,'9305bf00e7fdf2d2','15820583805bf00e7fd7909940374120','2018-11-17 12:50:07','2018-11-17 12:50:07','','','Contact Photos','3.jpg','image/jpeg',80,80,2355,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0+\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���9�ZM\�*��x\�\�.d% �\r1!h�u\�GS6���J\�\�9j�džBÏ-��_��e9+\r 0p�\�m<�\�\�Ƌ���\�?Jt>\�l�\�\�ϭ���u�1y[�U�:�^R���\0�rT �Ƕ>,G6�\��\�9\�� �\�\�\�o�CN0�!.��.�-�ӭ/\nC���)!i\�V�\�*\�q�㕈w�{\�C\�J�g]U%,}$�\��\�p����\�\�I��׉�\�\�5��W�ț�$�D�E�W�I.f\"\�\�A��\��պ�\0t\�KDu\�Y\�\Z�%ލj�R\� \�n\� 2�4��\���\�J ��0��f0�L<\�#��N\�Ҏ�v��@F 37��\�\�W��K�����LE҂�C2��I�H\�5)�]i\����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��vb�?���N\Z \�\��\�\�S 6a\0��\0p��|;���8\��s�:S��7`�3��x\��5�2\�,�\���y\�)\�\r \�\�ɻ$��\�DW��\�t�iR\� fԷ=���\��\0�B\�\�mg/Ň��\�{\Z\�\�P�^��\�Z\��|\�!���v\Z&��I�\�^\n\�X<���\0��f\\�,��,zq�+u�\�c��.\�\�A�2\�\�;�\�\ZjI�\r#;Sؓ�z\�,`ͤg�R��2/\�Qe��N�-��鵍u�\�U�� [�R\���ڽb�:�����Xب�Fm �C2;-� B1�x�o:\�V\��\0Ww\�W��?[\�ڪ\�N��\\\�XbA���:�e]�X3�㭶�1\�4\�\�՞i�\�\�\�V[\�>$<�E��-{?j1��Ƕl�\\V�ޓ ���Y\"\�\ZQ�`e?d��\�fi��MzC6*�\�\��6���T��\nJ���RT�\�)V3�|gǾ3�\��\�q�g�\�j\'岙5\�O\�?I�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o�\�~�v�CZ\�\�s\r�\�v&t#�qxi\�[��|�\�q\�{�V�f vGƎ�\�Տ���\�^�n(v�\�;���\�\�?+S\"=)r[\r�%��u\��.���k��I��\� �Lɻ�yܪa���P\�\�8R\��\0t %W�Y�/O��\�ZO���\��\0\�D�o�-� �\�a\�\� �^���-��\�,R\�\\u\�!Y� \�tu��\�\�U��5�t\�\�\��B[��Q\�\�\�\'�S�Y�\"P\�\n\�>VQ\� | #x˥��\�4��\�q�m\�Z����\n���\�5\�#g�-Ӟ�W]ۦ��Zjy�bN�-\�r53c-{{o\�+\�D\�\�\��P*���#T��-L\�\ZCM\��,fj�Y@\�g\\LnF\�#N�\�W\�\�-8S�HY\�\��t\�I�\�N,�\�ϳ\�\�S�\���^\�+�w~��Ak>�\�m����\�l���}\�h\� (��MN\�4,�^ J��1�(\�2\�b>\�\�\�{�(U��՚�ۭ)jdvvӢ�\�\�ڞT�\�Rw\�\�ƌת1\�\�\��3Z�^�F\���¶�\�r\�$㦣c\�a\�R&X$\�\�\�\�h�$c�a��8\"\�Z\�$B\�u�!����q6�!X\�gO�\rv�\�\�]��ZA/3{��^PQ[ˎ_�\�\�4%�5\�\��\0�%_rL��e\r�C\�^2���\� 8c� ��0��\�\�\�\�#�\�\�,�\�1�6\�M�(m \��\' N1�c{xy��ՠ/5[-&\�\�Z\�_��\�!dGl��x�\�\"�\"\�䭒><�%�R�\�eն�\�*\�et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���9�ZM\�*��x\�\�.d% �\r1!h�u\�GS6���J\�\�9j�džBÏ-��_��e9+\r 0p�\�m<�\�\�Ƌ���\�?Jt>\�l�\�\�ϭ���u�1y[�U�:�^R���\0�rT �Ƕ>,G6�\��\�9\�� �\�\�\�o�CN0�!.��.�-�ӭ/\nC���)!i\�V�\�*\�q�㕈w�{\�C\�J�g]U%,}$�\��\�p����\�\�I��׉�\�\�5��W�ț�$�D�E�W�I.f\"\�\�A��\��պ�\0t\�KDu\�Y\�\Z�%ލj�R\� \�n\� 2�4��\���\�J ��0��f0�L<\�#��N\�Ҏ�v��@F 37��\�\�W��K�����LE҂�C2��I�H\�5)�]i\����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��vb�?���N\Z \�\��\�\�S 6a\0��\0p��|;���8\��s�:S��7`�3��x\��5�2\�,�\���y\�)\�\r \�\�ɻ$��\�DW��\�t�iR\� fԷ=���\��\0�B\�\�mg/Ň��\�{\Z\�\�P�^��\�Z\��|\�!���v\Z&��I�\�^\n\�X<���\0��f\\�,��,zq�+u�\�c��.\�\�A�2\�\�;�\�\ZjI�\r#;Sؓ�z\�,`ͤg�R��2/\�Qe��N�-��鵍u�\�U�� [�R\���ڽb�:�����Xب�Fm �C2;-� B1�x�o:\�V\��\0Ww\�W��?[\�ڪ\�N��\\\�XbA���:�e]�X3�㭶�1\�4\�\�՞i�\�\�\�V[\�>$<�E��-{?j1��Ƕl�\\V�ޓ ���Y\"\�\ZQ�`e?d��\�fi��MzC6*�\�\��6���T��\nJ���RT�\�)V3�|gǾ3�\��\�q�g�\�j\'岙5\�O\�?I�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o�\�~�v�CZ\�\�s\r�\�v&t#�qxi\�[��|�\�q\�{�V�f vGƎ�\�Տ���\�^�n(v�\�;���\�\�?+S\"=)r[\r�%��u\��.���k��I��\� �Lɻ�yܪa���P\�\�8R\��\0t %W�Y�/O��\�ZO���\��\0\�D�o�-� �\�a\�\� �^���-��\�,R\�\\u\�!Y� \�tu��\�\�U��5�t\�\�\��B[��Q\�\�\�\'�S�Y�\"P\�\n\�>VQ\� | #x˥��\�4��\�q�m\�Z����\n���\�5\�#g�-Ӟ�W]ۦ��Zjy�bN�-\�r53c-{{o\�+\�D\�\�\��P*���#T��-L\�\ZCM\��,fj�Y@\�g\\LnF\�#N�\�W\�\�-8S�HY\�\��t\�I�\�N,�\�ϳ\�\�S�\���^\�+�w~��Ak>�\�m����\�l���}\�h\� (��MN\�4,�^ J��1�(\�2\�b>\�\�\�{�(U��՚�ۭ)jdvvӢ�\�\�ڞT�\�Rw\�\�ƌת1\�\�\��3Z�^�F\���¶�\�r\�$㦣c\�a\�R&X$\�\�\�\�h�$c�a��8\"\�Z\�$B\�u�!����q6�!X\�gO�\rv�\�\�]��ZA/3{��^PQ[ˎ_�\�\�4%�5\�\��\0�%_rL��e\r�C\�^2���\� 8c� ��0��\�\�\�\�#�\�\�,�\�1�6\�M�(m \��\' N1�c{xy��ՠ/5[-&\�\�Z\�_��\�!dGl��x�\�\"�\"\�䭒><�%�R�\�eն�\�*\�V�\��<|\�\r\�\�Rľ\��\�vM��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\Ή�6�\�u\�x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7�\�+�\�,;:\�\�<Լe����\�KM�…�\�81f\�-��cM�Y�g�8�B�\"=;}��{.��v\0N\��U�XR�\��EFKi�Dl��uW\���(�*�\�l���(����\n\��ò�\��\0����\�\�bRuۆ\���q���\�\n\�\�;,\��q�i�\\�屋 3�:MUd350\�\Z�/Ř٢\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"���\�N�\�\��멶���c�P���|k\�\�Y�\�΀�\��!Z��n�M8��x%\\l�D<>�U�\�\� \�03EW����\0�\�\��W%o�ſm��\�]\��\�;\�\�͋l\�\�\�f]�ռ�|\�&A=�1\r�\�#Å`�]1�\�\�E��㛑��\�ؙ|~N\�*Yc+�+*�a� �j�\Z?Ћ�K}��$L\�Z\�\�Q\�β�\0P4`�4h�#\�\0\�G\0Y��!\�шBZ���k\�\�n�as\",=\�|z�\�\�#s/ky�q�M� R]\�S\�-H�kiu\�66\�r�������f\\fGx$��\�7z\��[�\�v������F� ^\�l���{\�\�\�\'$à�H\���y \�^�D#�\�bz\�:��Xd��vF4/��dD۸\�l�3\�͈f��]\�\�c�\�>��6��~�\��\�2��ϱ�8ke��\�ɇ\�\�6\�k�enq�Ɨ�ˠn�\�g/1�كs\�F|�\�<�o\�Rܵ-��&ͤl\�;v�=�|;�r\�\r\�a����k&@1��`\'?Hϛ�c ��\"\"\"\"\"\"\"\"\"\".2a b0BB��\�1�s\�G�8k\�\�.{ݜ5�\�s�\�\�UFަ�!�]\��?�\�Z\�\�\�> \�\�d�\�ƪ�\�\� ���IK\�[�@\�QK\Z\�m�_��)�X<}M�!>.,bb:��=\�\��\�䯻|G\�jO��[Z;d卮$W\��\�z\�?a�!\�8 \��dmf�L\�<++��n�&|\�6\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�S\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�J�>\rm�s>�)�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���X\�܍J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8 9R�\�7\�ș>�W�n\�I�#\�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W��\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i!ʾ\�_0\�\�{\r�kA\"\�y�\�\�jwƯ��X*\�Z[\�l�3�Ɋ\�\���\�Il�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�YxF5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\".\"u�w��\�1�p%\�\n<�9~3����fZ���3\�Gc4�\"̿zF\�ȝ�\�Ƨ�V�\��ge8n\�Q\0഍�\�w�M��w�\�:�\Z�+C\r�q�oaC\�^Z������������\�\n\�(�\�|�$Y,οsOl��a\�G.�w�$\�#\�3\�\�=��\�)9p\�\"LS�y0FAU|�\\��\r�u�O�Rx��\�c\�m�f|F\�ceԄ\�\�9\�\�0���kr\�\�;�ynpϕ������H^�^\���ït_�\�\n 9�3\�:�^�d��\r\�\�\rMt`��!M����G5�f�$a��qI{ҋ\�}��<�q�+��w\�]T\��^U\�mp�ه\Z\��^�\�4깂v 3/��ska��\�\ne�.dlȌ)v\�\"\"\"\"\"\"\"\"\"\",>\��Y�;�\�\������8�j\�!`I��u��M��P\�8\�hh�1����\�y,�G-n��\�Vͤm�r(��7`�\�vjYyeS\�:������9 ȯ��&!��Y ]��\��vz���7���\Z�đ�yL�>^\�~M\�\�b\����a�W��a�`o V}z��`����6��%��\�@_֫\�`�n\�V�>Q7�;\���e�F��\�c\�t\�z�����\�vHn%By)Bh�`ϯ��~��\0�: \�\��)\�M�\�gk?J\�\rŒZ\�\�kSv\�!}\�\���]���\�`ٶ.6\�m�<8w�=<\�\�\��\�w\�a�p{x�T\�N[F¦�\"E,��\�d\�\�wV�}=�%\�|�w�;�4\�us��]��U�#\�u�a#XB���Α���;#\����\�1u���hB��`;x�MSZ\�u�M;N��\��}n�5EDaî���<ᕷ���uhO\�\�Myy῵��|�Ʌ[�\\\�||;9\�-�\�X_�q\�\�Q�G6s\�\�0�˱�*|נq=v\�\�|i�\\E$ꝋ�4\�+HB)Y�\��m|أ0^3�#H(�Ae���\�v0\�_ \�U\�|}�\�\�B\ruef��ƒ`4z��Z&c\rfZ\�61�\��\0,�9vs��DDDDDDDDDDDX\�\�>\�u�:U��\0\�p\�$\�\�7�^\�[\�\�\���H\�>l\�K�+�\Z�ؑ*D`5�|�aс\�Ϭ۠\�e(��o\�\\\�\�\�[ ж��;�ͬ\�cIJf\�D]�\�y�<�5\�-tV\�9 d;n�\�\��\�\�=���B�5�ƺ�TKZ\�O߻z�^\�i\�oc[[�\�j�\�\�\��ݛ�`bO)?4S�6\�8>�^g~�+�v~���P\�A�\�\�l�5&տ\�Qd\�>\ZVi��ŔyVq\�\�\"4b\��<�#\ZQB\�|�\�\�e�\0i�Y�\��H���Ok9z\�\�\rͅ�GǴ\�\"6S+\�\�\�u�̭v��2_(5������:\�#|\�\�\�x>�\�q\�S��V��<\�%��Rޢ�F1\�;&D\�\�\�\"\�?�\�{-��\�\�D�hB\�\�n�u�\Z;�\�\��gٷ�\��40�Pjϱmv�r\�R�sO&=n!WG�ai(q�k)�6Ǒ\Z�\��\�n��+Y\�\�)i�ekp��\�\�S�mA�̰�|�\�2)#5\�\�v��nr\�Qհ\�\\\����\�]&�`֮,�/�f3�WsO4\�֕ҙ�� �\nlc\�;0\�a��\��]���H:o)�\�f���U\�\r7d�+FC8ph�:\�9o�B”�`\"�\��B?8���vp\�\�\�\�\�/�-\"��\"֡�[B�\'a\�\��hj5MR���\'�I�R�\�!2%F��\�x\��uq��-k���\�/\���请_��D����� �\�\���v\�n.�&3\ZH0\�UCed��z\�\�g\\+מ \�~�]�p�\�i�Q�\0�ǚ�.�^(�\0c��#*�F|�\�\��IO9\\\�l���\�q숈������Gx��\��\0v-�ǚĨ�m\���\�S` �՚\�3L\�\� Ps� \�\�bm���>d�f+G\Z|Q\�/�\�_L�{h��\�#�u)6yC���p_\"\�\�i\�\�\�_�\Z^���|\�~e�\�4\�eK�\�1䝵\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ�q\n�;�\�\�\�M�(�\�$�\� \nVT�\�\�\�9�X\�[�j�\�\�:��\\lJJ���8�\�5\�O�&.J�Ǩ\�\�ȍ�o�\'Ģ�#0�\�o\�A\�n�K:v\��Ym7�[\n\�C@\�ͪ,\�5�7\��ȉ��Y����\�\�5~(\�?�\�\�\�6��]\�([\r\\\�\�\�\\\�+h\�r�>�K`NJܖD�lF5�\�\�C��\�aշ]7x�\�=\'m\�w\n\�9�}��}U�Acߌ\�t��r\�\�\�]��$\�s��8\�}��:\"\"\"\"\"\"���;\�~z3\�\�\�݅�\\�\0\�V\�q��J��\�lڎ����>\�*\�ð���\�\�d\rM�)Hz}�\�\�\�\��T%EE���e\r\re�\�\�݄*�jj�Rl�m\�l��ueet1\Zd� � ��E dʒQ\0y^\�\�\�?O��*],\�c\�5�2�5\�pk��yڢE�$s\�|ɚ��W6Id\�m&�C> �bZ޲\��\�Us\�HwȈ���������Ö�O��\�R��soh<��ZG�\�V\�MN�o��\Z[X\�`-m\�)��)c\�)� M����ݹ�\'��\0H\�J;+�\��A\�9D|\�kT\�e\�\0\�;���\�ǟ\\iz�Y�LJ\�\�\�U,��5\�\�M���\Z��|��|�a\�q��:\�{y��\r�l��8+�j\�˧�\�D H�Զ\� Ք�,%O���\�V���eeu\�igL\�\�ת��������\��\���Ѡk\�7$\�Xx���\��%l�N,�\ZV\�q`\�����*mt�\�.�Yٞ4���!�a��0^\��#p�df\\\� �vǷ9k��9�\�3��DDDDDEQO��w=��˺\�f8�Y+j�C�\Z\�L\�8�\�v]�b�^f056\�\�z�P�&t�k�$�\�YO�J�v �=\�\�A��\'�z��-+���\���\� ��S\�b˃8Z\�Dɻ�i�2\�ƣ�#��g\�\�tDDDDDDDDDDXs\�N��\�\�gl\�=�l\�\�yz�\�@6l�.\�\�ōi\�mۆ̩��\�e�tS���\�g\�(i��\�\'��Wv�b\�O�am�Y�\�G �\��4\\�\�ƒ\�Dz�5�d\'\�Q8�\�jG�˫�`dH�\n�_\�4�Jל\r��k\��\�\�f\�K\�V\�(�\�\��\0 \�\����A\�\��\�\�6 ��1�}�[����A\�����\�9�iqb��8DDDD^s\�!�\�%\�-�1\�\ZF�\�[�\� ,vX\�\�\�l\�\�f�\�X\�>3H\�c8\�r\�|gʇ\�B޶P\���Kۤ�f\�\�{�ͽl�7R��\�m�ԑ�\�+�3\�XI+\�\�k�ܽ�\�\�6\��c�i�u/\�G l���#n\�DX}�\�\�7��\�m���(j�A �L�\�j\�\�28�\0Dy$��E\r�\�\�4�Q���x\�\����;��\�\���\�Yrwl\�6O׭\�N�MH\�?Y\�\Z�o��\�Z\�q,$Fq+�\�s\�EP�r_&���t�S\�\�g��\n�f\�T;\0\�UΨ\�u �E-�s\r[d\�\�E&\�G �_.�\�4�ќ\�Wst?��\�\�\�]�=J\�\�g1qV��\�\�`�\�\"�`���k\�i\�SG�:�� �R6>2ƌ�Fr\�\"\"\"\"\�\�.C�\��/�\�ȕn��\�:\�\�6U��r�o鿍93Z�\r�9\�? ~[�g?\�\�/|���9���\�S$>7\"r�\����m\�*( \�\�\��H\�\��� vXL�\�{{f�� L�\�\'Sе�q�Pi\�\�F�S$aD\0�TA \�\��\�V�\�/�\�/)��\�s�\�ڈ������������QO)�a\�{��n�q�T\�\�0��Cfd�� \��\�\�u�<��\�[��8��� a���\��\�ףw�1�x�\�x�1\�7�Gb��{\�>[�\����X\���\"_�_ &�\�\�vZ�t�Q�|\�\�r�\���\�kb�m�:\�ϧ�>\�=�lMq\�.`ݙM \"\�\�\�\�/\�X\�4���\0���\�o-�\�\�ŝ˘�e�L\��u}Of\�\�\�#~\�3u$i\�+X瀱\�a\�\�6���p>��4֨;9ҾR��\06�H\�P׶[�X�e��m9\�\���iI\Z����c�#@kI�[�.n6I\�iqҾ\�-\�\0\�\�R�o���V�>��7���\�<\�=�\��\�\�\�kH����{���v\�wdui?�ۄ�(Nkǂ���k,bp�\�81#�Yw\�������So���<\��O ț_+ϒ\�\r\�dp\�\�W{Ng7���\0ZBe��\�\�9!\�\\H��������������\�\�k\�\�\� \�~�{P�W2k?~p\��+\�\����\�\�|>M�$\���\'\��\0P��c#˳�>�]n\rO��\�4 Ǘ�vGw�c$bh\�`j\�]z��F�1\0\"1\�˜\�\��\�1�keˆ�������\�w\ns e\�l\�-\��s}�XčJ\�9\�}��0�\0|{c\�\�\�\��UR�O4�m�e�vX�6�H\�a\��|��q5�R \�Z\�\����\�\�\rvK�s�\�9ض�Bc\�\�[�����_�`�Wj����\��W�\�}/돷�\\;\���>�\0\�\�\�H�~!�iФ` \�s\�4�\�j_\�8�5z\�D\�p<�\�\��]�\�\�\"\�~�5�k���DDDDE�u�\Z9\�VU5�\�\�ئ6\�\�H~ϲ\�c0L4\���\��7\'��7�\���\�\"\"\"\"\"\"\"\"\"\"\".��\�4\�\�s�l)��b\������ #\� {\�V�?`Y�|�\��\�q\����hi�\�t\��c^\�\�\Z�\�j�\�y�`\�k\�ֱ�~\0\�\�\�[�;\��~\��Ȉ�����������������������������������������������������������\�',4,0,'','','',''),(41,4,6,'9305bf00e8022c7a','12242958235bf00e8006f72987476580','2018-11-17 12:50:08','2018-11-17 12:50:08','','','Contact Photos','4.jpg','image/jpeg',80,80,2355,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0+\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���9�ZM\�*��x\�\�.d% �\r1!h�u\�GS6���J\�\�9j�džBÏ-��_��e9+\r 0p�\�m<�\�\�Ƌ���\�?Jt>\�l�\�\�ϭ���u�1y[�U�:�^R���\0�rT �Ƕ>,G6�\��\�9\�� �\�\�\�o�CN0�!.��.�-�ӭ/\nC���)!i\�V�\�*\�q�㕈w�{\�C\�J�g]U%,}$�\��\�p����\�\�I��׉�\�\�5��W�ț�$�D�E�W�I.f\"\�\�A��\��պ�\0t\�KDu\�Y\�\Z�%ލj�R\� \�n\� 2�4��\���\�J ��0��f0�L<\�#��N\�Ҏ�v��@F 37��\�\�W��K�����LE҂�C2��I�H\�5)�]i\����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��vb�?���N\Z \�\��\�\�S 6a\0��\0p��|;���8\��s�:S��7`�3��x\��5�2\�,�\���y\�)\�\r \�\�ɻ$��\�DW��\�t�iR\� fԷ=���\��\0�B\�\�mg/Ň��\�{\Z\�\�P�^��\�Z\��|\�!���v\Z&��I�\�^\n\�X<���\0��f\\�,��,zq�+u�\�c��.\�\�A�2\�\�;�\�\ZjI�\r#;Sؓ�z\�,`ͤg�R��2/\�Qe��N�-��鵍u�\�U�� [�R\���ڽb�:�����Xب�Fm �C2;-� B1�x�o:\�V\��\0Ww\�W��?[\�ڪ\�N��\\\�XbA���:�e]�X3�㭶�1\�4\�\�՞i�\�\�\�V[\�>$<�E��-{?j1��Ƕl�\\V�ޓ ���Y\"\�\ZQ�`e?d��\�fi��MzC6*�\�\��6���T��\nJ���RT�\�)V3�|gǾ3�\��\�q�g�\�j\'岙5\�O\�?I�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o�\�~�v�CZ\�\�s\r�\�v&t#�qxi\�[��|�\�q\�{�V�f vGƎ�\�Տ���\�^�n(v�\�;���\�\�?+S\"=)r[\r�%��u\��.���k��I��\� �Lɻ�yܪa���P\�\�8R\��\0t %W�Y�/O��\�ZO���\��\0\�D�o�-� �\�a\�\� �^���-��\�,R\�\\u\�!Y� \�tu��\�\�U��5�t\�\�\��B[��Q\�\�\�\'�S�Y�\"P\�\n\�>VQ\� | #x˥��\�4��\�q�m\�Z����\n���\�5\�#g�-Ӟ�W]ۦ��Zjy�bN�-\�r53c-{{o\�+\�D\�\�\��P*���#T��-L\�\ZCM\��,fj�Y@\�g\\LnF\�#N�\�W\�\�-8S�HY\�\��t\�I�\�N,�\�ϳ\�\�S�\���^\�+�w~��Ak>�\�m����\�l���}\�h\� (��MN\�4,�^ J��1�(\�2\�b>\�\�\�{�(U��՚�ۭ)jdvvӢ�\�\�ڞT�\�Rw\�\�ƌת1\�\�\��3Z�^�F\���¶�\�r\�$㦣c\�a\�R&X$\�\�\�\�h�$c�a��8\"\�Z\�$B\�u�!����q6�!X\�gO�\rv�\�\�]��ZA/3{��^PQ[ˎ_�\�\�4%�5\�\��\0�%_rL��e\r�C\�^2���\� 8c� ��0��\�\�\�\�#�\�\�,�\�1�6\�M�(m \��\' N1�c{xy��ՠ/5[-&\�\�Z\�_��\�!dGl��x�\�\"�\"\�䭒><�%�R�\�eն�\�*\�V�\��<|\�\r\�\�Rľ\��\�vM��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\Ή�6�\�u\�x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7�\�+�\�,;:\�\�<Լe����\�KM�…�\�81f\�-��cM�Y�g�8�B�\"=;}��{.��v\0N\��U�XR�\��EFKi�Dl��uW\���(�*�\�l���(����\n\��ò�\��\0����\�\�bRuۆ\���q���\�\n\�\�;,\��q�i�\\�屋 3�:MUd350\�\Z�/Ř٢\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"���\�N�\�\��멶���c�P���|k\�\�Y�\�΀�\��!Z��n�M8��x%\\l�D<>�U�\�\� \�03EW����\0�\�\��W%o�ſm��\�]\��\�;\�\�͋l\�\�\�f]�ռ�|\�&A=�1\r�\�#Å`�]1�\�\�E��㛑��\�ؙ|~N\�*Yc+�+*�a� �j�\Z?Ћ�K}��$L\�Z\�\�Q\�β�\0P4`�4h�#\�\0\�G\0Y��!\�шBZ���k\�\�n�as\",=\�|z�\�\�#s/ky�q�M� R]\�S\�-H�kiu\�66\�r�������f\\fGx$��\�7z\��[�\�v������F� ^\�l���{\�\�\�\'$à�H\���y \�^�D#�\�bz\�:��Xd��vF4/��dD۸\�l�3\�͈f��]\�\�c�\�>��6��~�\��\�2��ϱ�8ke��\�ɇ\�\�6\�k�enq�Ɨ�ˠn�\�g/1�كs\�F|�\�<�o\�Rܵ-��&ͤl\�;v�=�|;�r\�\r\�a����k&@1��`\'?Hϛ�c ��\"\"\"\"\"\"\"\"\"\".2a b0BB��\�1�s\�G�8k\�\�.{ݜ5�\�s�\�\�UFަ�!�]\��?�\�Z\�\�\�> \�\�d�\�ƪ�\�\� ���IK\�[�@\�QK\Z\�m�_��)�X<}M�!>.,bb:��=\�\��\�䯻|G\�jO��[Z;d卮$W\��\�z\�?a�!\�8 \��dmf�L\�<++��n�&|\�6\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�S\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�J�>\rm�s>�)�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���X\�܍J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8 9R�\�7\�ș>�W�n\�I�#\�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W��\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i!ʾ\�_0\�\�{\r�kA\"\�y�\�\�jwƯ��X*\�Z[\�l�3�Ɋ\�\���\�Il�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�YxF5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\".\"u�w��\�1�p%\�\n<�9~3����fZ���3\�Gc4�\"̿zF\�ȝ�\�Ƨ�V�\��ge8n\�Q\0഍�\�w�M��w�\�:�\Z�+C\r�q�oaC\�^Z������������\�\n\�(�\�|�$Y,οsOl��a\�G.�w�$\�#\�3\�\�=��\�)9p\�\"LS�y0FAU|�\\��\r�u�O�Rx��\�c\�m�f|F\�ceԄ\�\�9\�\�0���kr\�\�;�ynpϕ������H^�^\���ït_�\�\n 9�3\�:�^�d��\r\�\�\rMt`��!M����G5�f�$a��qI{ҋ\�}��<�q�+��w\�]T\��^U\�mp�ه\Z\��^�\�4깂v 3/��ska��\�\ne�.dlȌ)v\�\"\"\"\"\"\"\"\"\"\",>\��Y�;�\�\������8�j\�!`I��u��M��P\�8\�hh�1����\�y,�G-n��\�Vͤm�r(��7`�\�vjYyeS\�:������9 ȯ��&!��Y ]��\��vz���7���\Z�đ�yL�>^\�~M\�\�b\����a�W��a�`o V}z��`����6��%��\�@_֫\�`�n\�V�>Q7�;\���e�F��\�c\�t\�z�����\�vHn%By)Bh�`ϯ��~��\0�: \�\��)\�M�\�gk?J\�\rŒZ\�\�kSv\�!}\�\���]���\�`ٶ.6\�m�<8w�=<\�\�\��\�w\�a�p{x�T\�N[F¦�\"E,��\�d\�\�wV�}=�%\�|�w�;�4\�us��]��U�#\�u�a#XB���Α���;#\����\�1u���hB��`;x�MSZ\�u�M;N��\��}n�5EDaî���<ᕷ���uhO\�\�Myy῵��|�Ʌ[�\\\�||;9\�-�\�X_�q\�\�Q�G6s\�\�0�˱�*|נq=v\�\�|i�\\E$ꝋ�4\�+HB)Y�\��m|أ0^3�#H(�Ae���\�v0\�_ \�U\�|}�\�\�B\ruef��ƒ`4z��Z&c\rfZ\�61�\��\0,�9vs��DDDDDDDDDDDX\�\�>\�u�:U��\0\�p\�$\�\�7�^\�[\�\�\���H\�>l\�K�+�\Z�ؑ*D`5�|�aс\�Ϭ۠\�e(��o\�\\\�\�\�[ ж��;�ͬ\�cIJf\�D]�\�y�<�5\�-tV\�9 d;n�\�\��\�\�=���B�5�ƺ�TKZ\�O߻z�^\�i\�oc[[�\�j�\�\�\��ݛ�`bO)?4S�6\�8>�^g~�+�v~���P\�A�\�\�l�5&տ\�Qd\�>\ZVi��ŔyVq\�\�\"4b\��<�#\ZQB\�|�\�\�e�\0i�Y�\��H���Ok9z\�\�\rͅ�GǴ\�\"6S+\�\�\�u�̭v��2_(5������:\�#|\�\�\�x>�\�q\�S��V��<\�%��Rޢ�F1\�;&D\�\�\�\"\�?�\�{-��\�\�D�hB\�\�n�u�\Z;�\�\��gٷ�\��40�Pjϱmv�r\�R�sO&=n!WG�ai(q�k)�6Ǒ\Z�\��\�n��+Y\�\�)i�ekp��\�\�S�mA�̰�|�\�2)#5\�\�v��nr\�Qհ\�\\\����\�]&�`֮,�/�f3�WsO4\�֕ҙ�� �\nlc\�;0\�a��\��]���H:o)�\�f���U\�\r7d�+FC8ph�:\�9o�B”�`\"�\��B?8���vp\�\�\�\�\�/�-\"��\"֡�[B�\'a\�\��hj5MR���\'�I�R�\�!2%F��\�x\��uq��-k���\�/\���请_��D����� �\�\���v\�n.�&3\ZH0\�UCed��z\�\�g\\+מ \�~�]�p�\�i�Q�\0�ǚ�.�^(�\0c��#*�F|�\�\��IO9\\\�l���\�q숈������Gx��\��\0v-�ǚĨ�m\���\�S` �՚\�3L\�\� Ps� \�\�bm���>d�f+G\Z|Q\�/�\�_L�{h��\�#�u)6yC���p_\"\�\�i\�\�\�_�\Z^���|\�~e�\�4\�eK�\�1䝵\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ�q\n�;�\�\�\�M�(�\�$�\� \nVT�\�\�\�9�X\�[�j�\�\�:��\\lJJ���8�\�5\�O�&.J�Ǩ\�\�ȍ�o�\'Ģ�#0�\�o\�A\�n�K:v\��Ym7�[\n\�C@\�ͪ,\�5�7\��ȉ��Y����\�\�5~(\�?�\�\�\�6��]\�([\r\\\�\�\�\\\�+h\�r�>�K`NJܖD�lF5�\�\�C��\�aշ]7x�\�=\'m\�w\n\�9�}��}U�Acߌ\�t��r\�\�\�]��$\�s��8\�}��:\"\"\"\"\"\"���;\�~z3\�\�\�݅�\\�\0\�V\�q��J��\�lڎ����>\�*\�ð���\�\�d\rM�)Hz}�\�\�\�\��T%EE���e\r\re�\�\�݄*�jj�Rl�m\�l��ueet1\Zd� � ��E dʒQ\0y^\�\�\�?O��*],\�c\�5�2�5\�pk��yڢE�$s\�|ɚ��W6Id\�m&�C> �bZ޲\��\�Us\�HwȈ���������Ö�O��\�R��soh<��ZG�\�V\�MN�o��\Z[X\�`-m\�)��)c\�)� M����ݹ�\'��\0H\�J;+�\��A\�9D|\�kT\�e\�\0\�;���\�ǟ\\iz�Y�LJ\�\�\�U,��5\�\�M���\Z��|��|�a\�q��:\�{y��\r�l��8+�j\�˧�\�D H�Զ\� Ք�,%O���\�V���eeu\�igL\�\�ת��������\��\���Ѡk\�7$\�Xx���\��%l�N,�\ZV\�q`\�����*mt�\�.�Yٞ4���!�a��0^\��#p�df\\\� �vǷ9k��9�\�3��DDDDDEQO��w=��˺\�f8�Y+j�C�\Z\�L\�8�\�v]�b�^f056\�\�z�P�&t�k�$�\�YO�J�v �=\�\�A��\'�z��-+���\���\� ��S\�b˃8Z\�Dɻ�i�2\�ƣ�#��g\�\�tDDDDDDDDDDXs\�N��\�\�gl\�=�l\�\�yz�\�@6l�.\�\�ōi\�mۆ̩��\�e�tS���\�g\�(i��\�\'��Wv�b\�O�am�Y�\�G �\��4\\�\�ƒ\�Dz�5�d\'\�Q8�\�jG�˫�`dH�\n�_\�4�Jל\r��k\��\�\�f\�K\�V\�(�\�\��\0 \�\����A\�\��\�\�6 ��1�}�[����A\�����\�9�iqb��8DDDD^s\�!�\�%\�-�1\�\ZF�\�[�\� ,vX\�\�\�l\�\�f�\�X\�>3H\�c8\�r\�|gʇ\�B޶P\���Kۤ�f\�\�{�ͽl�7R��\�m�ԑ�\�+�3\�XI+\�\�k�ܽ�\�\�6\��c�i�u/\�G l���#n\�DX}�\�\�7��\�m���(j�A �L�\�j\�\�28�\0Dy$��E\r�\�\�4�Q���x\�\����;��\�\���\�Yrwl\�6O׭\�N�MH\�?Y\�\Z�o��\�Z\�q,$Fq+�\�s\�EP�r_&���t�S\�\�g��\n�f\�T;\0\�UΨ\�u �E-�s\r[d\�\�E&\�G �_.�\�4�ќ\�Wst?��\�\�\�]�=J\�\�g1qV��\�\�`�\�\"�`���k\�i\�SG�:�� �R6>2ƌ�Fr\�\"\"\"\"\�\�.C�\��/�\�ȕn��\�:\�\�6U��r�o鿍93Z�\r�9\�? ~[�g?\�\�/|���9���\�S$>7\"r�\����m\�*( \�\�\��H\�\��� vXL�\�{{f�� L�\�\'Sе�q�Pi\�\�F�S$aD\0�TA \�\��\�V�\�/�\�/)��\�s�\�ڈ������������QO)�a\�{��n�q�T\�\�0��Cfd�� \��\�\�u�<��\�[��8��� a���\��\�ףw�1�x�\�x�1\�7�Gb��{\�>[�\����X\���\"_�_ &�\�\�vZ�t�Q�|\�\�r�\���\�kb�m�:\�ϧ�>\�=�lMq\�.`ݙM \"\�\�\�\�/\�X\�4���\0���\�o-�\�\�ŝ˘�e�L\��u}Of\�\�\�#~\�3u$i\�+X瀱\�a\�\�6���p>��4֨;9ҾR��\06�H\�P׶[�X�e��m9\�\���iI\Z����c�#@kI�[�.n6I\�iqҾ\�-\�\0\�\�R�o���V�>��7���\�<\�=�\��\�\�\�kH����{���v\�wdui?�ۄ�(Nkǂ���k,bp�\�81#�Yw\�������So���<\��O ț_+ϒ\�\r\�dp\�\�W{Ng7���\0ZBe��\�\�9!\�\\H��������������\�\�k\�\�\� \�~�{P�W2k?~p\��+\�\����\�\�|>M�$\���\'\��\0P��c#˳�>�]n\rO��\�4 Ǘ�vGw�c$bh\�`j\�]z��F�1\0\"1\�˜\�\��\�1�keˆ�������\�w\ns e\�l\�-\��s}�XčJ\�9\�}��0�\0|{c\�\�\�\��UR�O4�m�e�vX�6�H\�a\��|��q5�R \�Z\�\����\�\�\rvK�s�\�9ض�Bc\�\�[�����_�`�Wj����\��W�\�}/돷�\\;\���>�\0\�\�\�H�~!�iФ` \�s\�4�\�j_\�8�5z\�D\�p<�\�\��]�\�\�\"\�~�5�k���DDDDE�u�\Z9\�VU5�\�\�ئ6\�\�H~ϲ\�c0L4\���\��7\'��7�\���\�\"\"\"\"\"\"\"\"\"\"\".��\�4\�\�s�l)��b\������ #\� {\�V�?`Y�|�\��\�q\����hi�\�t\��c^\�\�\Z�\�j�\�y�`\�k\�ֱ�~\0\�\�\�[�;\��~\��Ȉ�����������������������������������������������������������\�',4,0,'','','',''),(44,5,7,'9305bf00e803570d','97971161215bf00e801fdd7464746900','2018-11-17 12:50:08','2018-11-17 12:50:08','','','Contact Photos','5.jpg','image/jpeg',80,80,2355,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0+\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���9�ZM\�*��x\�\�.d% �\r1!h�u\�GS6���J\�\�9j�džBÏ-��_��e9+\r 0p�\�m<�\�\�Ƌ���\�?Jt>\�l�\�\�ϭ���u�1y[�U�:�^R���\0�rT �Ƕ>,G6�\��\�9\�� �\�\�\�o�CN0�!.��.�-�ӭ/\nC���)!i\�V�\�*\�q�㕈w�{\�C\�J�g]U%,}$�\��\�p����\�\�I��׉�\�\�5��W�ț�$�D�E�W�I.f\"\�\�A��\��պ�\0t\�KDu\�Y\�\Z�%ލj�R\� \�n\� 2�4��\���\�J ��0��f0�L<\�#��N\�Ҏ�v��@F 37��\�\�W��K�����LE҂�C2��I�H\�5)�]i\����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��vb�?���N\Z \�\��\�\�S 6a\0��\0p��|;���8\��s�:S��7`�3��x\��5�2\�,�\���y\�)\�\r \�\�ɻ$��\�DW��\�t�iR\� fԷ=���\��\0�B\�\�mg/Ň��\�{\Z\�\�P�^��\�Z\��|\�!���v\Z&��I�\�^\n\�X<���\0��f\\�,��,zq�+u�\�c��.\�\�A�2\�\�;�\�\ZjI�\r#;Sؓ�z\�,`ͤg�R��2/\�Qe��N�-��鵍u�\�U�� [�R\���ڽb�:�����Xب�Fm �C2;-� B1�x�o:\�V\��\0Ww\�W��?[\�ڪ\�N��\\\�XbA���:�e]�X3�㭶�1\�4\�\�՞i�\�\�\�V[\�>$<�E��-{?j1��Ƕl�\\V�ޓ ���Y\"\�\ZQ�`e?d��\�fi��MzC6*�\�\��6���T��\nJ���RT�\�)V3�|gǾ3�\��\�q�g�\�j\'岙5\�O\�?I�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o�\�~�v�CZ\�\�s\r�\�v&t#�qxi\�[��|�\�q\�{�V�f vGƎ�\�Տ���\�^�n(v�\�;���\�\�?+S\"=)r[\r�%��u\��.���k��I��\� �Lɻ�yܪa���P\�\�8R\��\0t %W�Y�/O��\�ZO���\��\0\�D�o�-� �\�a\�\� �^���-��\�,R\�\\u\�!Y� \�tu��\�\�U��5�t\�\�\��B[��Q\�\�\�\'�S�Y�\"P\�\n\�>VQ\� | #x˥��\�4��\�q�m\�Z����\n���\�5\�#g�-Ӟ�W]ۦ��Zjy�bN�-\�r53c-{{o\�+\�D\�\�\��P*���#T��-L\�\ZCM\��,fj�Y@\�g\\LnF\�#N�\�W\�\�-8S�HY\�\��t\�I�\�N,�\�ϳ\�\�S�\���^\�+�w~��Ak>�\�m����\�l���}\�h\� (��MN\�4,�^ J��1�(\�2\�b>\�\�\�{�(U��՚�ۭ)jdvvӢ�\�\�ڞT�\�Rw\�\�ƌת1\�\�\��3Z�^�F\���¶�\�r\�$㦣c\�a\�R&X$\�\�\�\�h�$c�a��8\"\�Z\�$B\�u�!����q6�!X\�gO�\rv�\�\�]��ZA/3{��^PQ[ˎ_�\�\�4%�5\�\��\0�%_rL��e\r�C\�^2���\� 8c� ��0��\�\�\�\�#�\�\�,�\�1�6\�M�(m \��\' N1�c{xy��ՠ/5[-&\�\�Z\�_��\�!dGl��x�\�\"�\"\�䭒><�%�R�\�eն�\�*\�e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(47,0,13,'9305bf00e80b8132','14369303895bf00e80b1e28207310424','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(48,0,13,'9305bf00e80b8132','14369303895bf00e80b1e28207310424','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(49,6,8,'9305bf00e80e68a1','63436871415bf00e80dfe9c308856934','2018-11-17 12:50:08','2018-11-17 12:50:08','','','Contact Photos','6.jpg','image/jpeg',300,300,11005,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0\0 \n!\"1A#3BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`>V�\��<|\�\r\�\�Rľ\��\�vM��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\Ή�6�\�u\�x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)AT\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7�\�+�\�,;:\�\�<Լe����\�KM�…�\�81f\�-��cM�Y�g�8�B�\"=;}��{.��v\0N\��U�XR�\��EFKi�Dl��uW\���(�*�\�l���(����\n\��ò�\��\0����\�\�bRuۆ\���q���\�\n\�\�;,\��q�i�\\�屋 3�:MUd350\�\Z�/Ř٢\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"���\�N�\�\��멶���c�P���|k\�\�Y�\�΀�\��!Z��n�M8��x%\\l�D<>�U�\�\� \�03EW����\0�\�\��W%o�ſm��\�]\��\�;\�\�͋l\�\�\�f]�ռ�|\�&A=�1\r�\�#Å`�]1�\�\�E��㛑��\�ؙ|~N\�*Yc+�+*�a� �j�\Z?Ћ�K}��$L\�Z\�\�Q\�β�\0P4`�4h�#\�\0\�G\0Y��!\�шBZ���k\�\�n�as\",=\�|z�\�\�#s/ky�q�M� R]\�S\�-H�kiu\�66\�r�������f\\fGx$��\�7z\��[�\�v������F� ^\�l���{\�\�\�\'$à�H\���y \�^�D#�\�bz\�:��Xd��vF4/��dD۸\�l�3\�͈f��]\�\�c�\�>��6��~�\��\�2��ϱ�8ke��\�ɇ\�\�6\�k�enq�Ɨ�ˠn�\�g/1�كs\�F|�\�<�o\�Rܵ-��&ͤl\�;v�=�|;�r\�\r\�a����k&@1��`\'?Hϛ�c ��\"\"\"\"\"\"\"\"\"\".2a b0BB��\�1�s\�G�8k\�\�.{ݜ5�\�s�\�\�UFަ�!�]\��?�\�Z\�\�\�> \�\�d�\�ƪ�\�\� ���IK\�[�@\�QK\Z\�m�_��)�X<}M�!>.,bb:��=\�\��\�䯻|G\�jO��[Z;d卮$W\��\�z\�?a�!\�8 \��dmf�L\�<++��n�&|\�6\�>!\�m�8\�D\�*\�\��9\�]f�P\�u��\��_OEO��Í\Z0\�,k[���C�=�\0PDϋ1\�舼G�z\��=�\�$q�cx���@� 3 ��&�]�S\�dr \�\�<�1�\�8A&<�\��*��\0�\�\�Kv\�\�\�s\�r?o�\"g]��\"~ \�d3N$a�L��#\���6�[\�J�>\rm�s>�)�/�DDE\nS�x\�6�!]M\����\�\�:�f^^Z\�\��86\�UI�|\�\�W�\���\�5\��@3N�ֿ�E�\n\�Q{\�Y{+\�B\�>\�/\�2u.I\�{\�wtӆ��`8\�&P\�D�&!,���0\�`�Lc<�\�J�`s�\\�\��\�k䣧�]\�\�<>#\�\�*�Q\�Z���X\�܍J6Aۨ�\�p�V\�\r\�ff�cA�6P�\�Ur\'@pf`�����������\">��r�\�~6x�\"�\�\�Nv\�\�,��� ^8 9R�\�7\�ș>�W�n\�I�#\�\�m^��s�8�W:�\� �����\�|�\��W\� isW!nTڌy��֓1Kn\�\�\�\Z��\�\�W��\'\�\\\����\�\"\"\"(��\���c̞94\�m\�i!ʾ\�_0\�\�{\r�kA\"\�y�\�\�jwƯ��X*\�Z[\�l�3�Ɋ\�\���\�Il�\�ʋc� ��W^<�t��aG$�5\\�k#���q\�N�\�D�\�8�[�\�Hc�YxF5�s�\�]������������\�\�}ׂN��Okll~D^b\�Mx��\0�9�g\�T�Qcg�\�ی��aq톽�\��e\�p\�\�QKOѭ�͟\�Ks\�\�\�]\�\�%V\�v]����-�B}~ώ[��5%�\��\0�\\\�4-k�=ô�jK\�\r\\K_~BE,\".\"u�w��\�1�p%\�\n<�9~3����fZ���3\�Gc4�\"̿zF\�ȝ�\�Ƨ�V�\��ge8n\�Q\0഍�\�w�M��w�\�:�\Z�+C\r�q�oaC\�^Z������������\�\n\�(�\�|�$Y,οsOl��a\�G.�w�$\�#\�3\�\�=��\�)9p\�\"LS�y0FAU|�\\��\r�u�O�Rx��\�c\�m�f|F\�ceԄ\�\�9\�\�0���kr\�\�;�ynpϕ������H^�^\���ït_�\�\n 9�3\�:�^�d��\r\�\�\rMt`��!M����G5�f�$a��qI{ҋ\�}��<�q�+��w\�]T\��^U\�mp�ه\Z\��^�\�4깂v 3/��ska��\�\ne�.dlȌ)v\�\"\"\"\"\"\"\"\"\"\",>\��Y�;�\�\������8�j\�!`I��u��M��P\�8\�hh�1����\�y,�G-n��\�Vͤm�r(��7`�\�vjYyeS\�:������9 ȯ��&!��Y ]��\��vz���7���\Z�đ�yL�>^\�~M\�\�b\����a�W��a�`o V}z��`����6��%��\�@_֫\�`�n\�V�>Q7�;\���e�F��\�c\�t\�z�����\�vHn%By)Bh�`ϯ��~��\0�: \�\��)\�M�\�gk?J\�\rŒZ\�\�kSv\�!}\�\���]���\�`ٶ.6\�m�<8w�=<\�\�\��\�w\�a�p{x�T\�N[F¦�\"E,��\�d\�\�wV�}=�%\�|�w�;�4\�us��]��U�#\�u�a#XB���Α���;#\����\�1u���hB��`;x�MSZ\�u�M;N��\��}n�5EDaî���<ᕷ���uhO\�\�Myy῵��|�Ʌ[�\\\�||;9\�-�\�X_�q\�\�Q�G6s\�\�0�˱�*|נq=v\�\�|i�\\E$ꝋ�4\�+HB)Y�\��m|أ0^3�#H(�Ae���\�v0\�_ \�U\�|}�\�\�B\ruef��ƒ`4z��Z&c\rfZ\�61�\��\0,�9vs��DDDDDDDDDDDX\�\�>\�u�:U��\0\�p\�$\�\�7�^\�[\�\�\���H\�>l\�K�+�\Z�ؑ*D`5�|�aс\�Ϭ۠\�e(��o\�\\\�\�\�[ ж��;�ͬ\�cIJf\�D]�\�y�<�5\�-tV\�9 d;n�\�\��\�\�=���B�5�ƺ�TKZ\�O߻z�^\�i\�oc[[�\�j�\�\�\��ݛ�`bO)?4S�6\�8>�^g~�+�v~���P\�A�\�\�l�5&տ\�Qd\�>\ZVi��ŔyVq\�\�\"4b\��<�#\ZQB\�|�\�\�e�\0i�Y�\��H���Ok9z\�\�\rͅ�GǴ\�\"6S+\�\�\�u�̭v��2_(5������:\�#|\�\�\�x>�\�q\�S��V��<\�%��Rޢ�F1\�;&D\�\�\�\"\�?�\�{-��\�\�D�hB\�\�n�u�\Z;�\�\��gٷ�\��40�Pjϱmv�r\�R�sO&=n!WG�ai(q�k)�6Ǒ\Z�\��\�n��+Y\�\�)i�ekp��\�\�S�mA�̰�|�\�2)#5\�\�v��nr\�Qհ\�\\\����\�]&�`֮,�/�f3�WsO4\�֕ҙ�� �\nlc\�;0\�a��\��]���H:o)�\�f���U\�\r7d�+FC8ph�:\�9o�B”�`\"�\��B?8���vp\�\�\�\�\�/�-\"��\"֡�[B�\'a\�\��hj5MR���\'�I�R�\�!2%F��\�x\��uq��-k���\�/\���请_��D����� �\�\���v\�n.�&3\ZH0\�UCed��z\�\�g\\+מ \�~�]�p�\�i�Q�\0�ǚ�.�^(�\0c��#*�F|�\�\��IO9\\\�l���\�q숈������Gx��\��\0v-�ǚĨ�m\���\�S` �՚\�3L\�\� Ps� \�\�bm���>d�f+G\Z|Q\�/�\�_L�{h��\�#�u)6yC���p_\"\�\�i\�\�\�_�\Z^���|\�~e�\�4\�eK�\�1䝵\�*\�<\�Dh���������\�{\�$\�\�o<\�\�;�^�Ɯ{G:�b��3�ǃ�q\n�;�\�\�\�M�(�\�$�\� \nVT�\�\�\�9�X\�[�j�\�\�:��\\lJJ���8�\�5\�O�&.J�Ǩ\�\�ȍ�o�\'Ģ�#0�\�o\�A\�n�K:v\��Ym7�[\n\�C@\�ͪ,\�5�7\��ȉ��Y����\�\�5~(\�?�\�\�\�6��]\�([\r\\\�\�\�\\\�+h\�r�>�K`NJܖD�lF5�\�\�C��\�aշ]7x�\�=\'m\�w\n\�9�}��}U�Acߌ\�t��r\�\�\�]��$\�s��8\�}��:\"\"\"\"\"\"���;\�~z3\�\�\�݅�\\�\0\�V\�q��J��\�lڎ����>\�*\�ð���\�\�d\rM�)Hz}�\�\�\�\��T%EE���e\r\re�\�\�݄*�jj�Rl�m\�l��ueet1\Zd� � ��E dʒQ\0y^\�\�\�?O��*],\�c\�5�2�5\�pk��yڢE�$s\�|ɚ��W6Id\�m&�C> �bZ޲\��\�Us\�HwȈ���������Ö�O��\�R��soh<��ZG�\�V\�MN�o��\Z[X\�`-m\�)��)c\�)� M����ݹ�\'��\0H\�J;+�\��A\�9D|\�kT\�e\�\0\�;���\�ǟ\\iz�Y�LJ\�\�\�U,��5\�\�M���\Z��|��|�a\�q��:\�{y��\r�l��8+�j\�˧�\�D H�Զ\� Ք�,%O���\�V���eeu\�igL\�\�ת��������\��\���Ѡk\�7$\�Xx���\��%l�N,�\ZV\�q`\�����*mt�\�.�Yٞ4���!�a��0^\��#p�df\\\� �vǷ9k��9�\�3��DDDDDEQO��w=��˺\�f8�Y+j�C�\Z\�L\�8�\�v]�b�^f056\�\�z�P�&t�k�$�\�YO�J�v �=\�\�A��\'�z��-+���\���\� ��S\�b˃8Z\�Dɻ�i�2\�ƣ�#��g\�\�tDDDDDDDDDDXs\�N��\�\�gl\�=�l\�\�yz�\�@6l�.\�\�ōi\�mۆ̩��\�e�tS���\�g\�(i��\�\'��Wv�b\�O�am�Y�\�G �\��4\\�\�ƒ\�Dz�5�d\'\�Q8�\�jG�˫�`dH�\n�_\�4�Jל\r��k\��\�\�f\�K\�V\�(�\�\��\0 \�\����A\�\��\�\�6 ��1�}�[����A\�����\�9�iqb��8DDDD^s\�!�\�%\�-�1\�\ZF�\�[�\� ,vX\�\�\�l\�\�f�\�X\�>3H\�c8\�r\�|gʇ\�B޶P\���Kۤ�f\�\�{�ͽl�7R��\�m�ԑ�\�+�3\�XI+\�\�k�ܽ�\�\�6\��c�i�u/\�G l���#n\�DX}�\�\�7��\�m���(j�A �L�\�j\�\�28�\0Dy$��E\r�\�\�4�Q���x\�\����;��\�\���\�Yrwl\�6O׭\�N�MH\�?Y\�\Z�o��\�Z\�q,$Fq+�\�s\�EP�r_&���t�S\�\�g��\n�f\�T;\0\�UΨ\�u �E-�s\r[d\�\�E&\�G �_.�\�4�ќ\�Wst?��\�\�\�]�=J\�\�g1qV��\�\�`�\�\"�`���k\�i\�SG�:�� �R6>2ƌ�Fr\�\"\"\"\"\�\�.C�\��/�\�ȕn��\�:\�\�6U��r�o鿍93Z�\r�9\�? ~[�g?\�\�/|���9���\�S$>7\"r�\����m\�*( \�\�\��H\�\��� vXL�\�{{f�� L�\�\'Sе�q�Pi\�\�F�S$aD\0�TA \�\��\�V�\�/�\�/)��\�s�\�ڈ������������QO)�a\�{��n�q�T\�\�0��Cfd�� \��\�\�u�<��\�[��8��� a���\��\�ףw�1�x�\�x�1\�7�Gb��{\�>[�\����X\���\"_�_ &�\�\�vZ�t�Q�|\�\�r�\���\�kb�m�:\�ϧ�>\�=�lMq\�.`ݙM \"\�\�\�\�/\�X\�4���\0���\�o-�\�\�ŝ˘�e�L\��u}Of\�\�\�#~\�3u$i\�+X瀱\�a\�\�6���p>��4֨;9ҾR��\06�H\�P׶[�X�e��m9\�\���iI\Z����c�#@kI�[�.n6I\�iqҾ\�-\�\0\�\�R�o���V�>��7���\�<\�=�\��\�\�\�kH����{���v\�wdui?�ۄ�(Nkǂ���k,bp�\�81#�Yw\�������So���<\��O ț_+ϒ\�\r\�dp\�\�W{Ng7���\0ZBe��\�\�9!\�\\H��������������\�\�k\�\�\� \�~�{P�W2k?~p\��+\�\����\�\�|>M�$\���\'\��\0P��c#˳�>�]n\rO��\�4 Ǘ�vGw�c$bh\�`j\�]z��F�1\0\"1\�˜\�\��\�1�keˆ�������\�w\ns e\�l\�-\��s}�XčJ\�9\�}��0�\0|{c\�\�\�\��UR�O4�m�e�vX�6�H\�a\��|��q5�R \�Z\�\����\�\�\rvK�s�\�9ض�Bc\�\�[�����_�`�Wj����\��W�\�}/돷�\\;\���>�\0\�\�\�H�~!�iФ` \�s\�4�\�j_\�8�5z\�D\�p<�\�\��]�\�\�\"\�~�5�k���DDDDE�u�\Z9\�VU5�\�\�ئ6\�\�H~ϲ\�c0L4\���\��7\'��7�\���\�\"\"\"\"\"\"\"\"\"\"\".��\�4\�\�s�l)��b\������ #\� {\�V�?`Y�|�\��\�q\����hi�\�t\��c^\�\�\Z�\�j�\�y�`\�k\�ֱ�~\0\�\�\�[�;\��~\��Ȉ�����������������������������������������������������������\�',4,0,'','','',''),(50,6,8,'9305bf00e80e68a1','63436871415bf00e80dfe9c308856934','2018-11-17 12:50:08','2018-11-17 12:50:08','','','Contact Photos','6.jpg','image/jpeg',80,80,2355,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0+\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���9�ZM\�*��x\�\�.d% �\r1!h�u\�GS6���J\�\�9j�džBÏ-��_��e9+\r 0p�\�m<�\�\�Ƌ���\�?Jt>\�l�\�\�ϭ���u�1y[�U�:�^R���\0�rT �Ƕ>,G6�\��\�9\�� �\�\�\�o�CN0�!.��.�-�ӭ/\nC���)!i\�V�\�*\�q�㕈w�{\�C\�J�g]U%,}$�\��\�p����\�\�I��׉�\�\�5��W�ț�$�D�E�W�I.f\"\�\�A��\��պ�\0t\�KDu\�Y\�\Z�%ލj�R\� \�n\� 2�4��\���\�J ��0��f0�L<\�#��N\�Ҏ�v��@F 37��\�\�W��K�����LE҂�C2��I�H\�5)�]i\����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��vb�?���N\Z \�\��\�\�S 6a\0��\0p��|;���8\��s�:S��7`�3��x\��5�2\�,�\���y\�)\�\r \�\�ɻ$��\�DW��\�t�iR\� fԷ=���\��\0�B\�\�mg/Ň��\�{\Z\�\�P�^��\�Z\��|\�!���v\Z&��I�\�^\n\�X<���\0��f\\�,��,zq�+u�\�c��.\�\�A�2\�\�;�\�\ZjI�\r#;Sؓ�z\�,`ͤg�R��2/\�Qe��N�-��鵍u�\�U�� [�R\���ڽb�:�����Xب�Fm �C2;-� B1�x�o:\�V\��\0Ww\�W��?[\�ڪ\�N��\\\�XbA���:�e]�X3�㭶�1\�4\�\�՞i�\�\�\�V[\�>$<�E��-{?j1��Ƕl�\\V�ޓ ���Y\"\�\ZQ�`e?d��\�fi��MzC6*�\�\��6���T��\nJ���RT�\�)V3�|gǾ3�\��\�q�g�\�j\'岙5\�O\�?I�II�(��\�\rn�w�s\ZQ3q\�$L��\�%r�\ZS��4\�j\0�*8}\�e�92(w\�f�\r\\�\�GAW\�#\0����xؘxx�Z2*.86\�\��d@���f[a�\�\��}�s�m}Bِn\�6=\"��+o�\�~�v�CZ\�\�s\r�\�v&t#�qxi\�[��|�\�q\�{�V�f vGƎ�\�Տ���\�^�n(v�\�;���\�\�?+S\"=)r[\r�%��u\��.���k��I��\� �Lɻ�yܪa���P\�\�8R\��\0t %W�Y�/O��\�ZO���\��\0\�D�o�-� �\�a\�\� �^���-��\�,R\�\\u\�!Y� \�tu��\�\�U��5�t\�\�\��B[��Q\�\�\�\'�S�Y�\"P\�\n\�>VQ\� | #x˥��\�4��\�q�m\�Z����\n���\�5\�#g�-Ӟ�W]ۦ��Zjy�bN�-\�r53c-{{o\�+\�D\�\�\��P*���#T��-L\�\ZCM\��,fj�Y@\�g\\LnF\�#N�\�W\�\�-8S�HY\�\��t\�I�\�N,�\�ϳ\�\�S�\���^\�+�w~��Ak>�\�m����\�l���}\�h\� (��MN\�4,�^ J��1�(\�2\�b>\�\�\�{�(U��՚�ۭ)jdvvӢ�\�\�ڞT�\�Rw\�\�ƌת1\�\�\��3Z�^�F\���¶�\�r\�$㦣c\�a\�R&X$\�\�\�\�h�$c�a��8\"\�Z\�$B\�u�!����q6�!X\�gO�\rv�\�\�]��ZA/3{��^PQ[ˎ_�\�\�4%�5\�\��\0�%_rL��e\r�C\�^2���\� 8c� ��0��\�\�\�\�#�\�\�,�\�1�6\�M�(m \��\' N1�c{xy��ՠ/5[-&\�\�Z\�_��\�!dGl��x�\�\"�\"\�䭒><�%�R�\�eն�\�*\�e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(53,3,14,'9305bf01a84c7ec6','21202845355bf01a84c5d33470288998','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(54,3,14,'9305bf01a84c7ec6','21202845355bf01a84c5d33470288998','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(55,2,15,'9305bf01a87c0b81','43713557095bf01a87be90d132625601','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(56,2,15,'9305bf01a87c0b81','43713557095bf01a87be90d132625601','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(57,2,15,'9305bf01a87c0b81','43713557095bf01a87be90d132625601','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(58,4,16,'9305bf01b2f3b2a5','17505033475bf01b2f38ff6177578340','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(59,4,16,'9305bf01b2f3b2a5','17505033475bf01b2f38ff6177578340','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(60,4,16,'9305bf01b2f3b2a5','17505033475bf01b2f38ff6177578340','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(61,2,17,'9305bf01b31f0b98','10942403815bf01b31eec59540800317','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(62,2,17,'9305bf01b31f0b98','10942403815bf01b31eec59540800317','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(63,2,17,'9305bf01b31f0b98','10942403815bf01b31eec59540800317','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(64,5,18,'9305bf01b7c2bcd7','18858997325bf01b7c29d79163158635','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(65,5,18,'9305bf01b7c2bcd7','18858997325bf01b7c29d79163158635','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(66,5,18,'9305bf01b7c2bcd7','18858997325bf01b7c29d79163158635','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(67,2,19,'9305bf01b7eb4ee9','34785303845bf01b7eb2f18737560971','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(68,2,19,'9305bf01b7eb4ee9','34785303845bf01b7eb2f18737560971','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(69,2,19,'9305bf01b7eb4ee9','34785303845bf01b7eb2f18737560971','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(70,6,20,'9305bf01b9732a1c','56404448515bf01b9730801345691622','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(71,6,20,'9305bf01b9732a1c','56404448515bf01b9730801345691622','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(72,6,20,'9305bf01b9732a1c','56404448515bf01b9730801345691622','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','12287146285bf00e7fd3b7e500413187-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(73,2,21,'9305bf01b999ea44','14034074815bf01b999cc19093001643','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(74,2,21,'9305bf01b999ea44','14034074815bf01b999cc19093001643','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(75,2,21,'9305bf01b999ea44','14034074815bf01b999cc19093001643','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(76,4,22,'9305bf01bd73e275','11253258925bf01bd73be2b220663529','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(77,4,22,'9305bf01bd73e275','11253258925bf01bd73be2b220663529','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(78,4,22,'9305bf01bd73e275','11253258925bf01bd73be2b220663529','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','15820583805bf00e7fd7909940374120-4.jpg?ts=1542459007','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(79,3,23,'9305bf01bd94ffec','13931663105bf01bd94d90e243464200','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(80,3,23,'9305bf01bd94ffec','13931663105bf01bd94d90e243464200','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(81,3,23,'9305bf01bd94ffec','13931663105bf01bd94d90e243464200','2018-11-17 13:50:05','2018-11-17 13:50:05','','','Contact Photos','12242958235bf00e8006f72987476580-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(82,6,24,'9305bf01c3d40ea6','10196270915bf01c3d3dcff106620826','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(83,6,24,'9305bf01c3d40ea6','10196270915bf01c3d3dcff106620826','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(84,6,24,'9305bf01c3d40ea6','10196270915bf01c3d3dcff106620826','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','97971161215bf00e801fdd7464746900-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''),(85,5,25,'9305bf01c3fdb20a','11789807665bf01c3fd8ccd829485384','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',300,300,11008,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 ,,\0�\�\0\0\0\0\0\0\0\0\0\0\0\n  �\�\08\0 \0\0\0 \n!\"13A#BQ��$%2Saqs��\�\0\0\0?\0�\�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"/6\�^b\� \�m� ��S\�m&��m�p��KY!\�\�\�\�K �V\�~\�4 +\�/r9�f3�D뺞����g\�]L\�\�ϵ[d_\�\"��W\��Ķ��Hx�\�x.�+\'=���Y����f0H�\�F��}`V�\��<|\�\r\�\�Rľ\��\�v=��/\�y���\�Z�d�\�{\�.%F2�9#�1�\\�?\�C���V�ގ\�\r\��Mƕ�M\�G\�9��O�W��ag\�,�\"Q\�\�x7\�\�a\�\����6\�-��Nл�\�\�\�q���\�z��\�.�\�\�-̡������n�ic!���I)�\�\0�sp1�ؕ׏���9J��h�\�^I\�\�\\΋�6�\�u��x�L\�8��x\���m����\��V\�m�؂P�bH�� |��u\�J\�\�=\�-KS\�NS�\�ܛW�\�y�=^-\�ϬM)T\�2\�1CO$y�Q2Ka��(�#��q�\���:\�\���O�\�\�y\�WPK�g\�\�\�ν\�C(�@l<�k�\0k��\�8�5��\�o\�#\���$\Z\�;\�\�ù�G7��j��\�s�\�E��\��\�5/h\�>e{R\�A ��b;\'N Y��en\�eEŖc\�\������N\�m|�˪䝀��\�l����\�Q�Zo�[ &�U\�t(�J,���\�\�,\�>�1l�\�ƒ���Dp\���\0\�+��6���v\�\0\�/\\l<��B���\�2>\\|\Zf\�>9lb\� \�ΓUY ÍL9ƅ-��f6h����������������#\�A��t6=:\�m�|�\�\�$�\0\�-}�w\�6A�\�9\�� �\�+P��\�\�\0��o������\�+�\�{6��_n{���Ѷm�o6-��i\�\�\��3�lgIy&T��\�)H�\�9϶=��c\�?�\���\��\0�{\���\�8�\0\� �O\�\�\��\0M\���-\'.\�g�=�\0 ��e�`�\�7\�/��r\�L��\�-�d�m:G�\�Dx\�\�\r�\'\�\rj\�\Z:*mb��_\�j�RQ\�E\ZʚȢ�@\�\�0E�0By�kq�v\\�g.s�������������������\�\�Ɇ�\��[\�3PK\�;�\�\�\\�=�).�&\��\Zy����]VK�l/�\�\\B��r����\�\�F\�^\��38\�B�p:6,��ا\�Z�.> \�\�\�\�lm\�\�}m�b%|93̸̎�IC\�n����\�\�\�7U�o`�\�6&��\���p�\�s\�NI�A�\�ρ^�3����G1�&,\���u�\��,\�h_/lȉ�q�\�Xg�?�\�e^,�\�\�?\�\�=�|�\0~�\�w���:s|\�j;>\�\�᭖\�+�&/h۶��U��\�?\Z^�.���I��L\�Kf 9\�!�򙿍KrԷ\�8�6��P\�\�����\�\�X75�\�\�#ᬙ\0\�فDx\\�m#>ln]�.ʈ�����������\�A�d1��Ly\nR? \�\�e\�!\�\�c��\�vp\�7\�s�c9Uz���\�w�\���\0�k#,�\'�E�\�|s\Z�\�3h.�:�%/!n\��E,k9�\�~�X��`��6L��D����\�\"\���/ۇ��\��Y�>�mh퓖6��_ :�\����|��\�,s[呵�\�1�\0$𬮣ٺ�U�pۤ����\�\r���[�\�8\�u��CM֪��%}=,ǣ�\"�}\�_\0��D�\�=�\��?\�L�\�&�Ⱥ\�v\�O��\�3G��\�\\Ɨ\���c8G�񤈌<`pH��\�n�M��\�=�͵mh\rwyխ�і��\0\�\r�ś��b�\��J��{j�bs͑\�\\VV\�� \�k�r\�\�^\�j���謧S^Q\���WqMqW(�l\�k\'k�����\�A�\0ʉ(%� C(\�\�|ձރy]\��Ͳ\�Zy���Qu�{\r��v96+\�\�H)cYj���־Y�fD\��\�\�\'H��)3�\�lWZ#\�·\\��hƩ� n&\�֧S �e��s\�(�b\�a\�\�\�#$\�Z�inp�)\��g3��\� 2�Th;\�DDDDDDDDDDE��,���\�\�^\��:Xbʰ\�x\�֧U�2fk�Y�\�5��\��\�a{o\n$S�4Dzi�{D�ߐ�I$�r�ʓ:t�gM�is&K1$ʗ*Iid\�3�c\�9��\�{\�R=\�{�\�g9�tV|�?�~?�-\�;��Uρ\������tS\�ȉ�|\'�ɐ\�8��I2bL��\�ʸ\�AopI*��\��h�Ŀ\�(}O�\�\�D\���u7M�~r\�\��\0q����qwg8���/`�G!j\�\�c�r5(\�n��\�[7!��ɘ \�B\�%Uȝ��m��\"\"\"\"\"\"\"\"\"(��\��\�wx\�\�\� �߲7a9\�_��n>�81x\� \�QJ˜߳\"d��q^1�q&�k�e�z��\�\0\�\\\�\�\'\�0ff�o1�\��\\; 1�\� \\��Sj1\�`n\�ZL\�-��;8k�ώs�e^���C�p�\�\��*�~$\�;�(\�U���W\�t0iB\�Q���\��y \����k�֌m\�\�[DDD\\�Ɲ\ZD)��.�\Z,���9�F�7�\�\0�x� \��V8e�Ƿ-\�q���G�9\�\�Ȧ\�N�m�4\�\�\�\0\�\�5���\r\�O,�;\�67b\�\�0\�j`A٫�*cf5S���c��곈;�ȝ@\�.Lg���n4h\'e���3�+\�[\�]\�\�[ݣS�\�\�(FLf�Q�%}a�\�\�\�DDDDDDDDDE�[�\�\�qCu,1�~y#�v,�圌�f��\�5�o\�ٯ^�a\�~r��\�\�[�\�_]\�)�Z8R\�\�0\\,\��Y� �t\�����<#�^ä����S\�\�N\��-k�ʖ�>$�r\"[������C\�\�\�2x\�\�y�Y��*��|\�O�\�6!���q\�\�Oq�\�W2n)`��ioU�\�\�&(EK#\�; %�W*-���\�\�]x�q\�nU���\�sΥ��B��M\�%:OC�l\�n An�!��e\� \�\�kv\�\"\"\"\"\"\"\"\"\"\"�/��^ :�\0\�=����y��5\�\���\�韥RYE��\�F\�e�� �l5\��\�\�.�ۆWB�Z~�mnl�\0*[�\�Ə5�\�V9*�S�\�����m\Z\��\�|r܍ѩ->\��\�湡kX�\�������R^xj\�Z�{�)aq�۽�w�s�.�Q\�\0�\�񟬸Ȝ?�2\�\� �b;�\�e�\�\�6nD\�N5=B�ַ�;)\�v\"��mv��\�l���ېLp\�\�j,� 6�\�(a�Q�iIyj\"\"\"\"\"\"\"\"\"\"�G�+��r\'���d�:�\�<}�ֹ�;�\�\�4� ��\��3\� �\�\�l�1N�\��U�)rz46�\��>\�=I\�\�_���y��\0�\�7k.�\'9\�~xq��<�\�[�\�\��\�s�|�DDDDZB����\�^{����PI\�\�q�)\�\���%��oVpjk� a\nl��r9�h�4�� �ݜӈ�Kޔ^�\�\\�勍9^ �Jꦿ��\�k�\�\�8\�׺�ޑ�U\��Y�}���[\'��S(D9s#fDaK�ia��\�I\�^�vG�W̓���\�;V��#�M�;�גm��\�\�+@\�E��G\���\�vc\�g\�9h\�\�u]�Eڶm#m��G�i�Ϋ�R\�ȳ*�a׬d\�]U\�\�`fE}�91\r���\�쌏g��ה�=)�լ�ט~$��\�dA���o\��6\�\� \Z��\r#xb�\�Ԥ��\���\�͑(\r}�������^\�\��\�wB�\��\�\�؎G\� ,�4=;��k׵\�Ȉ\�gM��@�q*\�J�G�\0}}譓�����\�^���OZm?c;W�W n,�\�\�\�Y\�\�d�6\�\�L���,ì���j魝���\��vr\���*\"\"\"\"\"\"\"\"\"\"*��U�=g�\�e\�:\�3Ý���D��(�\�N�\�Q3��\�Tc\�ClX �(�v\Z�\�ϑoq&N\�9�lh\�\0�۫\�\�~��G�9\��\�8{�x\�%Ǣ>#\�\�U\��mM\�\�DG�T!9\�5P�T�4�@�0Ki1\�W�qG#\�\�\�\�y\�����5�Iҵ�\�DYB�\�m4\�\�a7�� \�f4EsZ\�b�\�s湘�DDE�\��\�_�\�\�>�\0\�~w�\�}�Zfk�\�&�Vǽm�\�\����־D\\˝ifx�\�w�<:\�\�|\�ң��s��\�\�m�#��\�\�r�Yk坲m�(\�L�6�U�˾�=Z��\Z,b-p�/��<ɹ�d\�\�O�@3��}2� �^\�r��ޞ\�\�3j:\�\�\���5K��G�\0�\�ک\�W`FIJ\�^���C4\'���)�c\�\�}�\�\��\�Ď�@\�#Q\�F�&N:=\�\\ j\��r��#o<�\�\�}g�\�\�p��\�\n�\�^Z�fE��>�D�����_�4��\�;\�o�_�f�m�\�\�5\�\�sk\�\�\��=�5\�qr1Nƹ\�+3�4\�fE)\�O�PN�&L9\09:J�C\�7\�j��\�\'?Mwm�|\�f\�C\�[N;l�l�\�+�\�\��\"�\Z)��\r��h�\�*\�1k#X\� X�\Zu��qŽ\"ȐQ�\0\�s�\�B7/!JG\�\�ƹ\�{݆��˝�cʨ�ԃ\�7l�\�Ï5ˉѺ\�\�\r�b\�n1\�\��Çy�\�\�-N\�]\�|F7��aEM$\�l*h�$R\� \r�L\\Guo\�\�\�\�^\�\�q\�3�SN\�W8(�\�\�5Z�>[�5�/\�x� \�\�Ic�<ߙp(�-s_�PXƄ+�V��T\�5�[�Ӵ\�J\�oW\�\�TQQTF:\�\�c�\�ŋX\�X\�7\�v}\�R9\�+\�R=\�\�������������+\��o�z\�o<�ugO�e\�\ZMlVs�\�ϕc\�\ZdB�$ܫ\�F\�~۩E!�Hl1>���y���h����\�Vux\�\�o&�\�\�����M��R��z\�r/�;�\�H�\r\�T �\02��:�-�LRM X\�\ruD�̮ ���\�\�f8��\\\�}�\�-�>\�\�\\��\�l�r\�\�\\?͎2Ȩ��VmU\�i_��vQaYD&0\�p�\�V2k\�\�\�=Xu�\�sy���U�V�׮P>� �ϳ��:��j�8k�\�\�0��߯9�o�ޑy�fYL�cc.L� �O6t\�,��fJ+\�*\\�G{\�\"L��\�9\���\�{\�G�\�\�s�\�\�_MwN(��⟁LʷA\�; V�r<�����u\��)j)/\"�a6%j��B/\�%2Ӯ��\'����jx�̇��&��i4�Өfd�C6:��\�6��\�a&�S\�~[x\�K\'Q��L4ח��X\n\��U��\��\�ó�Q\�ێ\����\�\�\�U\ZTsg>\�\� ��§\�z\�Wm�ƚ\�\�RN�عM���\"�%�]o�\�\�͊3\�0�\"4���Q��x\�\�c\��qU]G\�\�==D \�VV\�:\�(0@&\0QcG��1�f0\�e�\��c��\�g\�\�=\�4vO�\�_\��k��G�8\�5���\�.ײ\���9d7�\0;��9�\�\n\�F� Ev$J�\r~ Xt`{3\�6\�7J=g[���;6rV\�4-��\'\���k2\��,��Qh�^|\��,\rvK]�\�H�[�w����wgf�о�j1��aUֻ_\��\�\��W�4\Zr[\�\�\�\�uz�yr�r>�f�\�X�\�Oō獹N���߮J\�]���f\�6�g��[.�I�o�TY1����n�qeU�y8���l�O$\�ƔP�4�<���\�c�g��\0�?\"|\�\�\�^��saA�\��\��ȍ�\��8�es+]��̗\�\rf�}~l\�N�\�Hğ8r��3Ĝx�\�c��$8\�e男����|\re1\�\��\"\"\�_\"#�]\�\�\0B~Ga\�=|\�-6��n�\�\�jvM�0Y��&E$f��c�\�\��\�\�]�:�\Z �R��V\�\�\�\�\Z\�ŕ�L\�`s*\�i暺ҺS1�a�aM�x\�f\�4�~1�\�\��6\�M\�>4\�\�ߑV꼁�\�h\�g\r\�[g-�XR�,R;�G\�1\�\��\�x�\�%���^�0\�@�\�5�hR\�8G�cM\nds\r�\�p\�&c\�\�g8s]��\�]���*�\�[/(�\��q\�\�r���\�n��K�\�UB $˝ca-\�#Ǝ\"��\�s�D&q�c� �j�x\�{�xΉ\Z����jg�*\�N�aqYw\\GLi\n;�\��&\��\�\�~�g���hu\��\��W\�شu\r\�\�v?�dH\�v\�\�Kٶ�<\�\�\�\�\�M\rF��@��p\��)5JR؂&BĨ\�\�:/^��1\�E�r��\�\�������\��ȓQ�\�\�5\��[�\�\�\�m\�\�$\�cI��l�L�_oY#-S;\�^�pO[��\������{L����u\�<ԩuj�G�o�U3\�\�%v_�Jy\�\�{e\�϶3�cDDDDETO�;ŵ�G��m\�\�<\�%D\�omﭶ��\0eƬ\�y�fOu\�Z��\�gGpCm�\��\'ؐ\�1Z8\�+\�1}V\��d{\�Eܟ[�I���\�]���SO>\�к�\�����\��-����[*\\氡�$\�<\�VQ\�e�#DDDDDDDDD^3\��q\'W8�y\�^qܪ�^4\�\�9\�\����\�6<���U�\���\�\�n\0�@��\�y%�\�`\�R���6�s�\�\�\�*\�kTv7|q\�R\��\�~ �5\��\0��k\�_�r\'\�\�\�\��\�\"�f*�95m!\� \�\�W\�V�\�\\S\���i4N�R\��\�st\�9�&q��@��#�\�d�?�n�tS\�{��K�\�\�ym��⿩�08z��:��C��>,�\�0\�5\�\�\�F�O�\�l7B\�;L\�2~��\�F��2|Hձ Ë�4DDDDDX�ܮ�p�z��\�m\�\�Z͡\�%�\�-u��n,�E>\�E+�4vA�e\\Cƒ1DPH�q�@��\�\�\��\'y�:�$����\�a%�I\�4�d\�Y\�\�\�d�]�6@�Q\�� G,�T�\��8\�\�i\�L����\�^\"|��.\�\�s�� �7\�\�#j<\�\�C�H\�w�3�+\�O8�ݴkGɬu�V�\�SXW6e[m�i\�>��/�;\����`z\�U\�\�>\�9\�؇\�-�\�7�?��\�u�h�T\\WIa\�˅>4Y!�Ls�2�J� �DDDDDDDD\\2$(\r*Q�\Z4aD�0�\0\�C\�#�1Ck�B=\�c\�9\�\�q���}K�e.��\�;��p\�\�i�=|\�\�\�F�CD�\�\\�I/5�\�E����u\r�)u�9��2HMv\�\�E�gM\�8\�V��\� �\�\�r\�:]L�q\�\�a�\�[�c.\�]�c>\�]�FS\�\�=\�m��\�\���\�-�\�P%M\�j�mGB�G��\n��\�+�!��GS}JR�i��t��2>\�\�QQkkYCCYawywa\n�����+[{[)\"�]YY] F�>\�|\�,(QBY2��@\0�W���\�\�\�_�\�\�v>\�[S/�\\\��{睪$_rG<�̙�\�5sd�L\�\�i\�3\������%�\�,/MEW>d�7|����������\�9k��{��)�6�v�\�ZU�y,5nD\�\���)Q����\�\�›\"�1 R��\�\�\�x�ۘ�y�t���ow��ϑ\�c�G\�f�N\\p\rӱ�[?\r�y�Ɨ���k\�Į&�}ER\� S\\]T\�Oa�)��˯�˰vg��3�w���k\�F\�:󂹖�,�qX]\�@���Klp�YO\�T��Eik�fVW\\V�t\��-z��۸7��Z�\�_��?]\Z��rMQe���Z\�\��V\�\�\�ɑ�m�,\nڙ��\�O>j�\�u��\�A���)�Q� \� �\"7Fe\� \�\�a\�{s���Û�\�8\�\�DDDDDU��;�s\�\�+���6c��u����t8ѭ�Ψ����eڶ(\�\�cQcm�l\'��2gJƹA�<\�ad�\0��\�`�Sޙ��\�\ZY2x����Ҹ�����-2�\�1]&,�3��\�L��\�V��.j9\"8��|\�7DDDDDDDDDDE�=\�\�\\��pF���\�\��\�^ɇ��\�dfˢ쎌X\�6�Fݸlʛh�&Z�E8�,\�6}���\�+^2y�\�wjv.\0\���څ���p�(~ E\�\�|i. {(X&\�}\�󊍶�Y ��&D��Z\��\�H��y�ع���\�\�n\�\�mB�Lο�\�//�\�N�l\�/`��#�\���\�kkdϻ\�*���v�6+�S�DDDDE\�<��N\�^RۣѤj�u�쀒\�e��j=f\�\�Fk�e�\�4�v3��-�\�q\�}\�-\�a\�\r�\0x佺H\�m���l\�\�\�0Cp�/a\�n�\�\�I��c=����n!ƿ\r\�ߜ|�m��;��\�R�Tp�\�ZI�6\�\�E�\�\r�� O�\�O_��p\���\�MV���#��G�H\�$P\�L�CHUi;\�G�\�o\�_C�M���:�]e�\'pf\�h\�d�\�z\�d\�dԍc��Ѩ��\�mU�G\�@�g�.\'=�U\nqW%�oYy�I\�=,�z_+p� Vl�C�\r�\\ꍧP�adR\�\�0նL�Y$Rl\�r �\�\�cO9�\�5w7C�AK\����߳ԭvsj��f �(� :x����8%4xs�Y�@\� �#c\�,h\�adg.[\"\"\"\"-n��\�9�O\����V\�k-S�|�e_����&�\0�\�C�5�p\�s��s�\�\�&s�w��\����(9C���%2C\�r\')q��!��\�x6ݲ��͌L1�d���\� �\�e�\�]�w��oyд\�>:\�u= Z���\�ku1\"F@\nDB[?\�\�\�?q}�켤{\���\�{�j\"\"\"\"\"\"\"\"\"\"\"*t=E�<�釕\�\�\��\��O�d\�\�n�\r��GT��\�\\[]֘�^옌ۡlr�\�\�%��aÂ�k^�\�d\��\�\�m\��\�8\���\��\��n[$[\�\Z~Ec�|J�b�~a|H0��9\�k\�\�\�\�DDDDZ��\�\�|Jw\�]��i�\�\��>�,|��U�q4nj��ve4$��?[p|�9c�\�2��\0w���tcG��t[.bŗ\�0_lf\�\��=�k�(��8\�ԑ� �c�\�a���ڮ�DDDDDDDDDDDDU��\��\�Z�\�\�J�J��\0\�9#�yC^\�n0!6M�:ٴ\�k�Jv��$j�\�V�����&yn$��\�\'�A�\�J���78�EJѿ\��ⷉ�l��Ϸ\�,c��\�\�>\�<~�\���\�\"\"\"\"\�\�\�j�ݺ�\�ZO\�!�\�.J��\�&A�Z\��<\�\� H\�]��|��~��\�M\0\�n�\'�\0�dM��\�\�s\�28b�+��3��\�C���-!2\��\�񜐌k�$DDDDDDDDDDDDPM��\�5\�\�\�\�?O��y+�5��8n~�u}F\�\�c�&��b|Ɍ�\��\0�Y\�1�\�\�\�F���\��w\Zc\�ڻ#�α�14d�5m.�H\�#[���\0�\�\�hc�XvƵ�\�DDDD^[\�Qq;�9� �\�g� W9��\0,bF�n\�߿�0�\0|{c\�\�\�\������id\��\�p\�dmv�\�<ú9\��H\�k �.��\��\n;���5\�/�\�k\�b\�\�DDDDDDDDDDDE �[�n \�ޗ\�}~��\�]��\���\0/�^/������>\�\�p\�o��}�\0\�\�\�H�~!�iФ` \�s\�Y�\�q2\���a\�\�\0�ߟv\�7\��~�Y@��������������\�у�x����$;�y���E�\�/�\'c\�!\�h�\���\�\�̗\�s\�\�\�osp\\a\�3\�u\�ghz\��oa�Lނ\�\�A\�������\��\�l+\0��\0}�nT�\�� \�a�E\n\"�\�(�ּdۖ�dc����nr\�5\�\�\\\�\�\�q�\�A7\�A�̟�lG\�*G\�\�o�\Z\�\�\�?#�q�c\��\�҉��϶1��\0|)ڢ\"\"\"\"\"\"\"\"\"\"\"(\����xJ\�i2\�g��\�\�s�\�r\�p\�#|��\0 �H\�}�\0\�\�\��Vsx7�\rW�o\��,\�\�\�Z#c�\Z҄�R\�\r\�HL�\�g\�\�\��1�ò\�7\�k�����\�\�[C�\��\���i�\0x�+���XG$�1�]˖9kl�3�Owe�,e\Z@\�π\"����\r�DDDDDDDDDDDDQ\��O\�\�\��L\�ϱ�6;I\��dk\r\�@�_a\�C�(�h\�O|W\Z;�6%�!���G\���8\�L<�\�\��1\�\�Xx�\�d|i�\�\�\"���q`j�‰�\�y�1����ǶE��nk^\�7P�����\�T\�44s���k#���Ll��!�>\���0ӟ\��7\�\��\�^\�\�w������������������\�\�o��Ρ�������v\�2�80��1\�Z\���f}�7c\���>�\r4r��^�k\�[CS]M^\�;# \�\� �{\�\�5\��\0<9\�kp\�c9\�q�\�>�\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\"�\�',4,0,'','','',''),(86,5,25,'9305bf01c3fdb20a','11789807665bf01c3fd8ccd829485384','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',80,80,2356,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \0P\0P\0�\�\0\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0*\0\0\0\0\0\0\0 \n!#\"BC�\�\0\0\0?\0\�\�8\�8\�8㚑�\��1V�wZf��\�J�\�\�!���m�e\�Mg�ܑ|\\qb�tٙ�$[Q\��\�\�|\'��J�+\�_�Οy��d\�]�~\�\�N�\�M>et-e���M�s�2T9�\�4ZO\�4\Z] W�~?;J�&\�&�u\��約�f�}v\�\�+��\�3V\�\�;j\� \�\�*stn]��.���\�\� �\�;E�\\V�\�u0�\�EW}o� H{ �^8\�9{\�\����\��E�v�ػ\n4\�+\"J\�`kg�R�+\�U��\�l\�ƽ�)͔��ь|�Po�ީ\�<�yCӺhX\',�\�;Jõ�t0\�U?-M�̦\�\�R�Jc�X����q�XJ�\�c�#-�h\�qtF�X�U+TjLMV�N���\�+0A1 ^���\�\\4,Lx\�lp�c#\�0�ai�\�m�\' N1\�9\�o\����a��t\�\�2\�X�Ig�\�\�\�ֹ\�咁d�dl72��\�! m\�ߒz\�k6\�%b`ofS �dĭ Q��e\�\�\��㭗m�rٚ�\0Ll@\�{?D[\�ӳګh\�b �E�]e|�\�\�;eB.\�O�W���kڐ�H\�$�Ͳ&a\�DK�.P\�\\Vn\�\r����\\�k�\0Vq�\�\�m�%I^S���D�\���\�\�#�;q�4�������)�\�\�p\�\�\�S���\�\�hկJ\�-&\�Mz�g\�2�T���V:󣩛 x�\���lb��Xc\�!aǍ��ѯ\�B2����8G�6�X\�\�\�E\�z\�㟥:b6H�\�g\��Y^�ǘ���[^*\��`�)Y\�ҹ*aPL\�\�#�N?�rzq\�\�}�Ie\�i�\�!�}���Yy�Q�\�i֗�!\�\�B���\�+Nr�c8\�q\�\�{�ս�\��A�{�����>�^v}�v��DXz\��$�N\�\�Ă\�s\�\Z\�I�\�$M\�k�y\"X\"��\�$��� en�\�Z\�}j\��u%�:\���N\�F�E)jr�a�ÚC\�l�\�X\�%�SL� Hx3`�i\�ȧi\�GF�Iڠ#��\�zv\�o�\�\�%\�\�\�\\\�&\"\�AJ��}Q$\�$a��\�.�����8�>*ƌޖ~�\�c����\�B\�\�m]�cȢ~ڔ�\r��S\�\�\�cO\n�4�\�E\�ܦ@ \\\�)d\0\���vcGq\�\�\�ft�O4n�\�gan�\��\0TkHe\�Y\'\��:�S�\Z69���vI�\'���AG�\��ҥ�ͩn{\�\0]_\���\04�\�v\�k9~,\�WX%c\��\r-}��\�5EJM���V���\� _��_\�2\���d��cӍ�[�W;L�w/2\r���\�V\�\��@\�RL�i\�\�ğ�\�5�cm#8�:�\\a�~B�0\�-m*tQh��_M�k�oR�Ш4�X�\�B�O���\�\���\�,l, H\�\�\�F3hdP��\�m8J�sǻy\�\n�o���:�t1�\�\��\�W-jtȬ\��\��\� e�1\�\�( \�\���wm�ɏi��֬�O�N\�³�r\�]��!\�J,} j\��Q��\�=�g\�\���Lu*\�H\�ҍ)�$-~�3Hd�k\��\�f����x\�\���8RU�%X’�\�J��{\�8\�=�g\�3�\�8\�\�sQ?-�ٯ*~j:M\�JP\�DuO@kw��ۘ�҈ ��\�D\�z\�^W(q�8ʃMv�a����\�\�Y!�\"�pm�a!!\�P\�5\�\�Tt~0X8Hp�������c\"�\�m�\0�� �D1ZhqFe�m\r�)\�\��\�?!v\�\�-�\�cc\�*���\�\�\�j\�5�\r\�0ۍa\�bgB<��u��\�ʾ�G�\�j\�`�d|h\�]\�X�i\�.\�\�v l\rs���n�\��2#җ!E�\�*2P�\�gS\�!�Z��\�뒱\�\�4��\n�uL�4A�!\�\�\�lݯ\��\0\��j��\0;zHd)��\Z\�%[��:ʑ?K\�0P��8�2E�#\0\�\0��mv��\�F��\�4>u�\��ɭG\�7\�e:ۥ\�K:@\0��\�Elv\��m{�\0� F�G\����q�\0|s>q\�s�\Z\�x\�R�ceE\'.\�\�\�-�,!M�`�\�njQq�����\�|peA�)�:�?���\�z�\�պ�0�H;��S�Qy\�C#�&���]Wa��SF[�\0RP\�l2�\�ul��\�VK�\�Sǎ8\�@\�z:W\������\�\�Ss\�o?~G`t�\�+w\�\�l\�\�W�U�\�S~�\0=��\�H� ��@\�$�g\�KM��\�d�+?�\�\�\� pv�����B�JT�W?q\�\�d1T[g\�vrsC:e\�\�\n����\�w\�\�]xvq�g⵲ҕ�\� \�;<�\�',5,0,'','','',''),(87,5,25,'9305bf01c3fdb20a','11789807665bf01c3fd8ccd829485384','2018-11-17 13:50:06','2018-11-17 13:50:06','','','Contact Photos','63436871415bf00e80dfe9c308856934-4.jpg?ts=1542459008','image/jpeg',48,48,1488,_binary '�\��\�\0JFIF\0\0H\0H\0\0�\�\0C\0��\0 \00\00\0�\�\0\Z\0\0\0\0\0\0\0\0\0\0\0\0\0 \n�\�\0(\0\0\0\0\0\0\0\0\0 \n\"1$%�\�\0\0\0?\0����8\�\�{����\0���݇\������I�\�\�]U�n\�_Pf��\�-5��a.-�\�\�5�ۮ6B]e�W����\�]�\�~�:\'jjy3 6e�5�-M�G>\��u3v��k�\�x�\Z�x�J§��=�!�&��1�y�֙\���z\�\�>��\���\�<\�\�]e\�h�\rWQ�k���Ube��b�\�\���#\�:ӳJ\�\r\'�3[d�\0 �\'�\�R\�ҽ��RZi�sӵ�w?9\�Ԩ�gt�\�أ�\�[@Z�\�Ih3�Az\�&f� �\���r ��9!@�Ew\�~\�x\'Ju�U�\�\�X�U}�Y�y\�\�b�5\ZX�ݭP�\'\�्\r3Ђ�\��* D�k\�\�\�g��\�\�� \�{�\�\�\�UX�\�y�.��&��C#gk�-�8r�\�\�B\Z\�Ts�bNs\nh&&\�)䩦V���JC\�Z\�j��MC�xbR\�\�[�WM��\�5FR�m��P8آs�b���p�\ZCY�\�X�k\�0�r�ɟ�-\�����`9\�>\��.���k��I��\� �Lɻ�yܪa���Hgp)vI� ���Ϭ���\�\�\'\�\��uY\�\"I�ۖ…�a�\��دVFZ�\�Q�)q �:��\��\�::\�[\�\�*\�{��κog\�}�-�M{�\��M�o��)\���ő(ba+(\�>��e\�\�|q�N\\u8̶\�Y\�\�\� \��\�q\�\Z�\�\�\�|+�\�\�Z���5<\�1\'G�\�9\Z������\���flpV \�(GJ~�N\rɉ�k�!���35^��k��&7#o��T\�+�v\��)֤,�Q�t�ͺi�$\�b \'J�\�\��r�\�\��\0\���m\�[�O]ൟY�>\��\�\�\�p�6S_N��ቴk��N?|MN\�4,�^ J��1� \�\�b>\�l�\�{�hU��՚�ۭ)jdvvӢ��56��)�BT���5��5\�w�\\\�&kFk\�\�\�\�B�MXV\�}\\d�t\�l|\�9\�JD\�$�\\�y\r�q\�6PG[ [�Xδ@\�2���.!\�Ԥ+\�\��\�\�\�| ���H%\�ow>p�\�\n+yq\�\���=���Ƹ�c?�d�\�I�r����\�}k\�P�c4xa� v��VZa\�BZdq\�BZe�[F0�\�i��\r�8\�P�\�)\�1�c\�o3�ڴ\�e�\�\�Ü�\\+�5{$,�\�/?DT\�Y¼��@gǖ@��\�T۬��֜�YLjǪj\�\�/\�)R\�fH)M\�m\�ZW.\��\�k\�ppA?�Է�`���k\nV~�\n\�S�p�M�G�صrj�Ku��e+R\�[N]�5k4=�?\��\�S�Qp��\0�FU��\0o�6~b�\�\Z/[S!)���\\ E��\�{-� \\���\���8ZМ�\0�BS�\�\�>�\�',6,0,'','','',''); /*!40000 ALTER TABLE `photo` ENABLE KEYS */; UNLOCK TABLES; diff --git a/images/article.gif b/images/article.gif index 91aeef000b..af9de93453 100644 Binary files a/images/article.gif and b/images/article.gif differ diff --git a/images/b_drop.gif b/images/b_drop.gif index b08c68b62d..5834b5d299 100644 Binary files a/images/b_drop.gif and b/images/b_drop.gif differ diff --git a/images/b_dropshow.gif b/images/b_dropshow.gif index b08c68b62d..5834b5d299 100644 Binary files a/images/b_dropshow.gif and b/images/b_dropshow.gif differ diff --git a/images/beer_mug.gif b/images/beer_mug.gif index 9a3e051922..10b53e47b6 100644 Binary files a/images/beer_mug.gif and b/images/beer_mug.gif differ diff --git a/images/camera-icon.gif b/images/camera-icon.gif index a4adf9adfa..2943aa0953 100644 Binary files a/images/camera-icon.gif and b/images/camera-icon.gif differ diff --git a/images/discourse.png b/images/discourse.png index 6c9ce46676..c51e26d0c9 100644 Binary files a/images/discourse.png and b/images/discourse.png differ diff --git a/images/friendica-192.jpg b/images/friendica-192.jpg new file mode 100644 index 0000000000..de6ae75ae2 Binary files /dev/null and b/images/friendica-192.jpg differ diff --git a/images/friendica-192.png b/images/friendica-192.png new file mode 100644 index 0000000000..97537c6c34 Binary files /dev/null and b/images/friendica-192.png differ diff --git a/images/friendica-404_svg_flexy-o-hare.png b/images/friendica-404_svg_flexy-o-hare.png index 36d6b5ca3d..8ba689d535 100644 Binary files a/images/friendica-404_svg_flexy-o-hare.png and b/images/friendica-404_svg_flexy-o-hare.png differ diff --git a/images/friendica-404_svg_hare-bottom-light-inside.png b/images/friendica-404_svg_hare-bottom-light-inside.png index 6c9189e4ec..f32e113400 100644 Binary files a/images/friendica-404_svg_hare-bottom-light-inside.png and b/images/friendica-404_svg_hare-bottom-light-inside.png differ diff --git a/images/friendica-512.jpg b/images/friendica-512.jpg new file mode 100644 index 0000000000..a8bb114dc2 Binary files /dev/null and b/images/friendica-512.jpg differ diff --git a/images/friendica-512.png b/images/friendica-512.png new file mode 100644 index 0000000000..e579478df6 Binary files /dev/null and b/images/friendica-512.png differ diff --git a/images/friendica.svg b/images/friendica.svg index 2105ef3175..45820959d5 100644 --- a/images/friendica.svg +++ b/images/friendica.svg @@ -1,240 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/globe.gif b/images/globe.gif index 3f17c5d322..84722efa52 100644 Binary files a/images/globe.gif and b/images/globe.gif differ diff --git a/images/humane-tech-badge.svg b/images/humane-tech-badge.svg new file mode 100644 index 0000000000..699942271a --- /dev/null +++ b/images/humane-tech-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/larrw.gif b/images/larrw.gif index 08902d772a..6076876a69 100644 Binary files a/images/larrw.gif and b/images/larrw.gif differ diff --git a/images/link-icon.gif b/images/link-icon.gif index c012d716e2..f238c63bff 100644 Binary files a/images/link-icon.gif and b/images/link-icon.gif differ diff --git a/images/lock_icon.gif b/images/lock_icon.gif index b6b1b7fed5..44a7d05001 100644 Binary files a/images/lock_icon.gif and b/images/lock_icon.gif differ diff --git a/images/pause.gif b/images/pause.gif index dc57c4c981..7006e9856b 100644 Binary files a/images/pause.gif and b/images/pause.gif differ diff --git a/images/person-300.jpg b/images/person-300.jpg index 8173082113..41803623de 100644 Binary files a/images/person-300.jpg and b/images/person-300.jpg differ diff --git a/images/play.gif b/images/play.gif index 4010f056d2..c5ae628b5c 100644 Binary files a/images/play.gif and b/images/play.gif differ diff --git a/images/rarrw.gif b/images/rarrw.gif index 849238c2dc..bf26b8a8bb 100644 Binary files a/images/rarrw.gif and b/images/rarrw.gif differ diff --git a/images/rotator.gif b/images/rotator.gif index 3797ec3e4b..6e8181ee98 100644 Binary files a/images/rotator.gif and b/images/rotator.gif differ diff --git a/images/smiley-Oo.gif b/images/smiley-Oo.gif index a15d974275..27d2690fc1 100644 Binary files a/images/smiley-Oo.gif and b/images/smiley-Oo.gif differ diff --git a/images/smiley-bangheaddesk.gif b/images/smiley-bangheaddesk.gif index 91ccb8bb4d..c28440296c 100644 Binary files a/images/smiley-bangheaddesk.gif and b/images/smiley-bangheaddesk.gif differ diff --git a/images/smiley-brokenheart.gif b/images/smiley-brokenheart.gif index 971b57fd98..f511db3e4c 100644 Binary files a/images/smiley-brokenheart.gif and b/images/smiley-brokenheart.gif differ diff --git a/images/smiley-heart.gif b/images/smiley-heart.gif index 6a11e70652..8891901cff 100644 Binary files a/images/smiley-heart.gif and b/images/smiley-heart.gif differ diff --git a/images/smiley-shaka.gif b/images/smiley-shaka.gif index 336fe3bcdc..030aeccad0 100644 Binary files a/images/smiley-shaka.gif and b/images/smiley-shaka.gif differ diff --git a/images/spencil.gif b/images/spencil.gif index 0a2551ac08..515e6b02dc 100644 Binary files a/images/spencil.gif and b/images/spencil.gif differ diff --git a/images/unlock_icon.gif b/images/unlock_icon.gif index 254ac8bfd2..9487b44e3e 100644 Binary files a/images/unlock_icon.gif and b/images/unlock_icon.gif differ diff --git a/images/youtube_icon.gif b/images/youtube_icon.gif index 987b82bfdd..11a0a982ce 100644 Binary files a/images/youtube_icon.gif and b/images/youtube_icon.gif differ diff --git a/include/api.php b/include/api.php index fdebdd48bc..5131c95741 100644 --- a/include/api.php +++ b/include/api.php @@ -43,7 +43,8 @@ use Friendica\Model\Notify; use Friendica\Model\Photo; use Friendica\Model\User; use Friendica\Model\UserItem; -use Friendica\Network\FKOAuth1; +use Friendica\Model\Verb; +use Friendica\Security\FKOAuth1; use Friendica\Network\HTTPException; use Friendica\Network\HTTPException\BadRequestException; use Friendica\Network\HTTPException\ExpectationFailedException; @@ -57,6 +58,8 @@ use Friendica\Network\HTTPException\UnauthorizedException; use Friendica\Object\Image; use Friendica\Protocol\Activity; use Friendica\Protocol\Diaspora; +use Friendica\Security\OAuth1\OAuthRequest; +use Friendica\Security\OAuth1\OAuthUtil; use Friendica\Util\DateTimeFormat; use Friendica\Util\Images; use Friendica\Util\Network; @@ -64,7 +67,6 @@ use Friendica\Util\Proxy as ProxyUtils; use Friendica\Util\Strings; use Friendica\Util\XML; -require_once __DIR__ . '/../mod/share.php'; require_once __DIR__ . '/../mod/item.php'; require_once __DIR__ . '/../mod/wall_upload.php'; @@ -263,7 +265,10 @@ function api_login(App $a) throw new UnauthorizedException("This API requires login"); } - DI::auth()->setForUser($a, $record); + // Don't refresh the login date more often than twice a day to spare database writes + $login_refresh = strcmp(DateTimeFormat::utc('now - 12 hours'), $record['login_date']) > 0; + + DI::auth()->setForUser($a, $record, false, false, $login_refresh); $_SESSION["allow_api"] = true; @@ -307,22 +312,22 @@ function api_call(App $a, App\Arguments $args = null) } $type = "json"; - if (strpos($args->getQueryString(), ".xml") > 0) { + if (strpos($args->getCommand(), ".xml") > 0) { $type = "xml"; } - if (strpos($args->getQueryString(), ".json") > 0) { + if (strpos($args->getCommand(), ".json") > 0) { $type = "json"; } - if (strpos($args->getQueryString(), ".rss") > 0) { + if (strpos($args->getCommand(), ".rss") > 0) { $type = "rss"; } - if (strpos($args->getQueryString(), ".atom") > 0) { + if (strpos($args->getCommand(), ".atom") > 0) { $type = "atom"; } try { foreach ($API as $p => $info) { - if (strpos($args->getQueryString(), $p) === 0) { + if (strpos($args->getCommand(), $p) === 0) { if (!api_check_method($info['method'])) { throw new MethodNotAllowedException(); } @@ -331,16 +336,16 @@ function api_call(App $a, App\Arguments $args = null) if (!empty($info['auth']) && api_user() === false) { api_login($a); + Logger::info(API_LOG_PREFIX . 'username {username}', ['module' => 'api', 'action' => 'call', 'username' => $a->user['username']]); } - Logger::info(API_LOG_PREFIX . 'username {username}', ['module' => 'api', 'action' => 'call', 'username' => $a->user['username']]); Logger::debug(API_LOG_PREFIX . 'parameters', ['module' => 'api', 'action' => 'call', 'parameters' => $_REQUEST]); $stamp = microtime(true); $return = call_user_func($info['func'], $type); $duration = floatval(microtime(true) - $stamp); - Logger::info(API_LOG_PREFIX . 'username {username}', ['module' => 'api', 'action' => 'call', 'username' => $a->user['username'], 'duration' => round($duration, 2)]); + Logger::info(API_LOG_PREFIX . 'duration {duration}', ['module' => 'api', 'action' => 'call', 'duration' => round($duration, 2)]); DI::profiler()->saveLog(DI::logger(), API_LOG_PREFIX . 'performance'); @@ -380,7 +385,7 @@ function api_call(App $a, App\Arguments $args = null) } Logger::warning(API_LOG_PREFIX . 'not implemented', ['module' => 'api', 'action' => 'call', 'query' => DI::args()->getQueryString()]); - throw new NotImplementedException(); + throw new NotFoundException(); } catch (HTTPException $e) { header("HTTP/1.1 {$e->getCode()} {$e->httpdesc}"); return api_error($type, $e, $args); @@ -623,7 +628,7 @@ function api_get_user(App $a, $contact_id = null) 'name' => $contact["name"], 'screen_name' => (($contact['nick']) ? $contact['nick'] : $contact['name']), 'location' => ($contact["location"] != "") ? $contact["location"] : ContactSelector::networkToName($contact['network'], $contact['url'], $contact['protocol']), - 'description' => BBCode::toPlaintext($contact["about"]), + 'description' => BBCode::toPlaintext($contact["about"] ?? ''), 'profile_image_url' => $contact["micro"], 'profile_image_url_https' => $contact["micro"], 'profile_image_url_profile_size' => $contact["thumb"], @@ -650,8 +655,8 @@ function api_get_user(App $a, $contact_id = null) 'notifications' => false, 'statusnet_profile_url' => $contact["url"], 'uid' => 0, - 'cid' => Contact::getIdForURL($contact["url"], api_user(), true), - 'pid' => Contact::getIdForURL($contact["url"], 0, true), + 'cid' => Contact::getIdForURL($contact["url"], api_user(), false), + 'pid' => Contact::getIdForURL($contact["url"], 0, false), 'self' => 0, 'network' => $contact["network"], ]; @@ -675,7 +680,7 @@ function api_get_user(App $a, $contact_id = null) $countfollowers = 0; $starred = 0; - $pcontact_id = Contact::getIdForURL($uinfo[0]['url'], 0, true); + $pcontact_id = Contact::getIdForURL($uinfo[0]['url'], 0, false); if (!empty($profile['about'])) { $description = $profile['about']; @@ -697,7 +702,7 @@ function api_get_user(App $a, $contact_id = null) 'name' => (($uinfo[0]['name']) ? $uinfo[0]['name'] : $uinfo[0]['nick']), 'screen_name' => (($uinfo[0]['nick']) ? $uinfo[0]['nick'] : $uinfo[0]['name']), 'location' => $location, - 'description' => BBCode::toPlaintext($description), + 'description' => BBCode::toPlaintext($description ?? ''), 'profile_image_url' => $uinfo[0]['micro'], 'profile_image_url_https' => $uinfo[0]['micro'], 'profile_image_url_profile_size' => $uinfo[0]["thumb"], @@ -727,7 +732,7 @@ function api_get_user(App $a, $contact_id = null) 'statusnet_profile_url' => $uinfo[0]['url'], 'uid' => intval($uinfo[0]['uid']), 'cid' => intval($uinfo[0]['cid']), - 'pid' => Contact::getIdForURL($uinfo[0]["url"], 0, true), + 'pid' => Contact::getIdForURL($uinfo[0]["url"], 0, false), 'self' => $uinfo[0]['self'], 'network' => $uinfo[0]['network'], ]; @@ -1240,7 +1245,7 @@ function api_media_upload() "image_type" => $media["type"], "friendica_preview_url" => $media["preview"]]; - Logger::log("Media uploaded: " . print_r($returndata, true), Logger::DEBUG); + Logger::info('Media uploaded', ['return' => $returndata]); return ["media" => $returndata]; } @@ -1310,7 +1315,7 @@ api_register_func('api/media/metadata/create', 'api_media_metadata_create', true /** * @param string $type Return format (atom, rss, xml, json) * @param int $item_id - * @return string + * @return array|string * @throws Exception */ function api_status_show($type, $item_id) @@ -1558,7 +1563,7 @@ function api_search($type) $params['group_by'] = ['uri-id']; } else { $condition = ["`id` > ? - " . ($exclude_replies ? " AND `id` = `parent` " : ' ') . " + " . ($exclude_replies ? " AND `gravity` = " . GRAVITY_PARENT : ' ') . " AND (`uid` = 0 OR (`uid` = ? AND NOT `global`)) AND `body` LIKE CONCAT('%',?,'%')", $since_id, api_user(), $_REQUEST['q']]; @@ -1646,7 +1651,8 @@ function api_statuses_home_timeline($type) $condition[] = $max_id; } if ($exclude_replies) { - $condition[0] .= ' AND `item`.`parent` = `item`.`id`'; + $condition[0] .= ' AND `item`.`gravity` = ?'; + $condition[] = GRAVITY_PARENT; } if ($conversation_id > 0) { $condition[0] .= " AND `item`.`parent` = ?"; @@ -2033,35 +2039,40 @@ function api_statuses_repeat($type) Logger::log('API: api_statuses_repeat: '.$id); - $fields = ['uri-id', 'body', 'title', 'attach', 'author-name', 'author-link', 'author-avatar', 'guid', 'created', 'plink']; + $fields = ['uri-id', 'network', 'body', 'title', 'author-name', 'author-link', 'author-avatar', 'guid', 'created', 'plink']; $item = Item::selectFirst($fields, ['id' => $id, 'private' => [Item::PUBLIC, Item::UNLISTED]]); if (DBA::isResult($item) && $item['body'] != "") { - if (strpos($item['body'], "[/share]") !== false) { - $pos = strpos($item['body'], "[share"); - $post = substr($item['body'], $pos); + if (in_array($item['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::TWITTER])) { + if (!Item::performActivity($id, 'announce', local_user())) { + throw new InternalServerErrorException(); + } + + $item_id = $id; } else { - $post = share_header($item['author-name'], $item['author-link'], $item['author-avatar'], $item['guid'], $item['created'], $item['plink']); + if (strpos($item['body'], "[/share]") !== false) { + $pos = strpos($item['body'], "[share"); + $post = substr($item['body'], $pos); + } else { + $post = BBCode::getShareOpeningTag($item['author-name'], $item['author-link'], $item['author-avatar'], $item['plink'], $item['created'], $item['guid']); - if (!empty($item['title'])) { - $post .= '[h3]' . $item['title'] . "[/h3]\n"; + if (!empty($item['title'])) { + $post .= '[h3]' . $item['title'] . "[/h3]\n"; + } + + $post .= $item['body']; + $post .= "[/share]"; + } + $_REQUEST['body'] = $post; + $_REQUEST['profile_uid'] = api_user(); + $_REQUEST['api_source'] = true; + + if (empty($_REQUEST['source'])) { + $_REQUEST["source"] = api_source(); } - $post .= $item['body']; - $post .= "[/share]"; + $item_id = item_post($a); } - $_REQUEST['body'] = $post; - $_REQUEST['attach'] = $item['attach']; - $_REQUEST['profile_uid'] = api_user(); - $_REQUEST['api_source'] = true; - - if (empty($_REQUEST['source'])) { - $_REQUEST["source"] = api_source(); - } - - $item_id = item_post($a); - - /// @todo Copy tags from the original post to the new one } else { throw new ForbiddenException(); } @@ -2152,10 +2163,10 @@ function api_statuses_mentions($type) // get last network messages // params - $since_id = $_REQUEST['since_id'] ?? 0; - $max_id = $_REQUEST['max_id'] ?? 0; - $count = $_REQUEST['count'] ?? 20; - $page = $_REQUEST['page'] ?? 1; + $since_id = intval($_REQUEST['since_id'] ?? 0); + $max_id = intval($_REQUEST['max_id'] ?? 0); + $count = intval($_REQUEST['count'] ?? 20); + $page = intval($_REQUEST['page'] ?? 1); $start = max(0, ($page - 1) * $count); @@ -2228,12 +2239,7 @@ function api_statuses_user_timeline($type) throw new ForbiddenException(); } - Logger::log( - "api_statuses_user_timeline: api_user: ". api_user() . - "\nuser_info: ".print_r($user_info, true) . - "\n_REQUEST: ".print_r($_REQUEST, true), - Logger::DEBUG - ); + Logger::info('api_statuses_user_timeline', ['api_user' => api_user(), 'user_info' => $user_info, '_REQUEST' => $_REQUEST]); $since_id = $_REQUEST['since_id'] ?? 0; $max_id = $_REQUEST['max_id'] ?? 0; @@ -2254,7 +2260,8 @@ function api_statuses_user_timeline($type) } if ($exclude_replies) { - $condition[0] .= ' AND `item`.`parent` = `item`.`id`'; + $condition[0] .= ' AND `item`.`gravity` = ?'; + $condition[] = GRAVITY_PARENT; } if ($conversation_id > 0) { @@ -2491,10 +2498,10 @@ function api_format_messages($item, $recipient, $sender) if ($_GET['getText'] == 'html') { $ret['text'] = BBCode::convert($item['body'], false); } elseif ($_GET['getText'] == 'plain') { - $ret['text'] = trim(HTML::toPlaintext(BBCode::convert(api_clean_plain_items($item['body']), false, 2, true), 0)); + $ret['text'] = trim(HTML::toPlaintext(BBCode::convert(api_clean_plain_items($item['body']), false, BBCode::API, true), 0)); } } else { - $ret['text'] = $item['title'] . "\n" . HTML::toPlaintext(BBCode::convert(api_clean_plain_items($item['body']), false, 2, true), 0); + $ret['text'] = $item['title'] . "\n" . HTML::toPlaintext(BBCode::convert(api_clean_plain_items($item['body']), false, BBCode::API, true), 0); } if (!empty($_GET['getUserObjects']) && $_GET['getUserObjects'] == 'false') { unset($ret['sender']); @@ -2520,7 +2527,7 @@ function api_convert_item($item) $attachments = api_get_attachments($body); // Workaround for ostatus messages where the title is identically to the body - $html = BBCode::convert(api_clean_plain_items($body), false, 2, true); + $html = BBCode::convert(api_clean_plain_items($body), false, BBCode::API, true); $statusbody = trim(HTML::toPlaintext($html, 0)); // handle data: images @@ -3027,7 +3034,7 @@ function api_format_item($item, $type = "json", $status_user = null, $author_use $retweeted_item = []; $quoted_item = []; - if ($item["id"] == $item["parent"]) { + if ($item['gravity'] == GRAVITY_PARENT) { $body = $item['body']; $retweeted_item = api_share_as_retweet($item); if ($body != $item['body']) { @@ -3304,7 +3311,8 @@ function api_lists_statuses($type) $condition[] = $max_id; } if ($exclude_replies > 0) { - $condition[0] .= ' AND `item`.`parent` = `item`.`id`'; + $condition[0] .= ' AND `item`.`gravity` = ?'; + $condition[] = GRAVITY_PARENT; } if ($conversation_id > 0) { $condition[0] .= " AND `item`.`parent` = ?"; @@ -3576,96 +3584,6 @@ function api_statusnet_version($type) api_register_func('api/gnusocial/version', 'api_statusnet_version', false); api_register_func('api/statusnet/version', 'api_statusnet_version', false); -/** - * - * @param string $type Return type (atom, rss, xml, json) - * - * @param int $rel A contact relationship constant - * @return array|string|void - * @throws BadRequestException - * @throws ForbiddenException - * @throws ImagickException - * @throws InternalServerErrorException - * @throws UnauthorizedException - * @todo use api_format_data() to return data - */ -function api_ff_ids($type, int $rel) -{ - if (!api_user()) { - throw new ForbiddenException(); - } - - $a = DI::app(); - - api_get_user($a); - - $stringify_ids = $_REQUEST['stringify_ids'] ?? false; - - $contacts = DBA::p("SELECT `pcontact`.`id` - FROM `contact` - INNER JOIN `contact` AS `pcontact` - ON `contact`.`nurl` = `pcontact`.`nurl` - AND `pcontact`.`uid` = 0 - WHERE `contact`.`uid` = ? - AND NOT `contact`.`self` - AND `contact`.`rel` IN (?, ?)", - api_user(), - $rel, - Contact::FRIEND - ); - - $ids = []; - foreach (DBA::toArray($contacts) as $contact) { - if ($stringify_ids) { - $ids[] = $contact['id']; - } else { - $ids[] = intval($contact['id']); - } - } - - return api_format_data('ids', $type, ['id' => $ids]); -} - -/** - * Returns the ID of every user the user is following. - * - * @param string $type Return type (atom, rss, xml, json) - * - * @return array|string - * @throws BadRequestException - * @throws ForbiddenException - * @throws ImagickException - * @throws InternalServerErrorException - * @throws UnauthorizedException - * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids - */ -function api_friends_ids($type) -{ - return api_ff_ids($type, Contact::SHARING); -} - -/** - * Returns the ID of every user following the user. - * - * @param string $type Return type (atom, rss, xml, json) - * - * @return array|string - * @throws BadRequestException - * @throws ForbiddenException - * @throws ImagickException - * @throws InternalServerErrorException - * @throws UnauthorizedException - * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids - */ -function api_followers_ids($type) -{ - return api_ff_ids($type, Contact::FOLLOWER); -} - -/// @TODO move to top of file or somewhere better -api_register_func('api/friends/ids', 'api_friends_ids', true); -api_register_func('api/followers/ids', 'api_followers_ids', true); - /** * Sends a new direct message. * @@ -4161,26 +4079,18 @@ function api_fr_photoalbum_delete($type) throw new BadRequestException("no albumname specified"); } // check if album is existing - $r = q( - "SELECT DISTINCT `resource-id` FROM `photo` WHERE `uid` = %d AND `album` = '%s'", - intval(api_user()), - DBA::escape($album) - ); - if (!DBA::isResult($r)) { + + $photos = DBA::selectToArray('photo', ['resource-id'], ['uid' => api_user(), 'album' => $album], ['group_by' => ['resource-id']]); + if (!DBA::isResult($photos)) { throw new BadRequestException("album not available"); } + $resourceIds = array_column($photos, 'resource-id'); + // function for setting the items to "deleted = 1" which ensures that comments, likes etc. are not shown anymore // to the user and the contacts of the users (drop_items() performs the federation of the deletion to other networks - foreach ($r as $rr) { - $condition = ['uid' => local_user(), 'resource-id' => $rr['resource-id'], 'type' => 'photo']; - $photo_item = Item::selectFirstForUser(local_user(), ['id'], $condition); - - if (!DBA::isResult($photo_item)) { - throw new InternalServerErrorException("problem with deleting items occured"); - } - Item::deleteForUser(['id' => $photo_item['id']], api_user()); - } + $condition = ['uid' => api_user(), 'resource-id' => $resourceIds, 'type' => 'photo']; + Item::deleteForUser($condition, api_user()); // now let's delete all photos from the album $result = Photo::delete(['uid' => api_user(), 'album' => $album]); @@ -4309,7 +4219,7 @@ function api_fr_photo_create_update($type) $deny_cid = $_REQUEST['deny_cid' ] ?? null; $allow_gid = $_REQUEST['allow_gid'] ?? null; $deny_gid = $_REQUEST['deny_gid' ] ?? null; - $visibility = !empty($_REQUEST['visibility']) && $_REQUEST['visibility'] !== "false"; + $visibility = !$allow_cid && !$deny_cid && !$allow_gid && !$deny_gid; // do several checks on input parameters // we do not allow calls without album string @@ -4457,19 +4367,13 @@ function api_fr_photo_delete($type) // return success of deletion or error message if ($result) { - // retrieve the id of the parent element (the photo element) - $condition = ['uid' => local_user(), 'resource-id' => $photo_id, 'type' => 'photo']; - $photo_item = Item::selectFirstForUser(local_user(), ['id'], $condition); - - if (!DBA::isResult($photo_item)) { - throw new InternalServerErrorException("problem with deleting items occured"); - } // function for setting the items to "deleted = 1" which ensures that comments, likes etc. are not shown anymore // to the user and the contacts of the users (drop_items() do all the necessary magic to avoid orphans in database and federate deletion) - Item::deleteForUser(['id' => $photo_item['id']], api_user()); + $condition = ['uid' => api_user(), 'resource-id' => $photo_id, 'type' => 'photo']; + Item::deleteForUser($condition, api_user()); - $answer = ['result' => 'deleted', 'message' => 'photo with id `' . $photo_id . '` has been deleted from server.']; - return api_format_data("photo_delete", $type, ['$result' => $answer]); + $result = ['result' => 'deleted', 'message' => 'photo with id `' . $photo_id . '` has been deleted from server.']; + return api_format_data("photo_delete", $type, ['$result' => $result]); } else { throw new InternalServerErrorException("unknown error on deleting photo from database table"); } @@ -4828,7 +4732,7 @@ function save_media_to_database($mediatype, $media, $type, $album, $allow_cid, $ Logger::log("photo upload: new profile image upload ended", Logger::DEBUG); } - if (isset($r) && $r) { + if (!empty($r)) { // create entry in 'item'-table on new uploads to enable users to comment/like/dislike the photo if ($photo_id == null && $mediatype == "photo") { post_photo_item($resource_id, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $filetype, $visibility); @@ -4861,7 +4765,6 @@ function post_photo_item($hash, $allow_cid, $deny_cid, $allow_gid, $deny_gid, $f $arr['guid'] = System::createUUID(); $arr['uid'] = intval(api_user()); $arr['uri'] = $uri; - $arr['parent-uri'] = $uri; $arr['type'] = 'photo'; $arr['wall'] = 1; $arr['resource-id'] = $hash; @@ -4975,8 +4878,8 @@ function prepare_photo_data($type, $scale, $photo_id) } // retrieve item element for getting activities (like, dislike etc.) related to photo - $condition = ['uid' => local_user(), 'resource-id' => $photo_id, 'type' => 'photo']; - $item = Item::selectFirstForUser(local_user(), ['id'], $condition); + $condition = ['uid' => api_user(), 'resource-id' => $photo_id, 'type' => 'photo']; + $item = Item::selectFirst(['id', 'uid', 'uri', 'parent', 'allow_cid', 'deny_cid', 'allow_gid', 'deny_gid'], $condition); if (!DBA::isResult($item)) { throw new NotFoundException('Photo-related item not found.'); } @@ -4985,7 +4888,7 @@ function prepare_photo_data($type, $scale, $photo_id) // retrieve comments on photo $condition = ["`parent` = ? AND `uid` = ? AND (`gravity` IN (?, ?) OR `type`='photo')", - $item[0]['parent'], api_user(), GRAVITY_PARENT, GRAVITY_COMMENT]; + $item['parent'], api_user(), GRAVITY_PARENT, GRAVITY_COMMENT]; $statuses = Item::selectForUser(api_user(), [], $condition); @@ -5005,10 +4908,10 @@ function prepare_photo_data($type, $scale, $photo_id) $data['photo']['friendica_comments'] = $comments; // include info if rights on photo and rights on item are mismatching - $rights_mismatch = $data['photo']['allow_cid'] != $item[0]['allow_cid'] || - $data['photo']['deny_cid'] != $item[0]['deny_cid'] || - $data['photo']['allow_gid'] != $item[0]['allow_gid'] || - $data['photo']['deny_cid'] != $item[0]['deny_cid']; + $rights_mismatch = $data['photo']['allow_cid'] != $item['allow_cid'] || + $data['photo']['deny_cid'] != $item['deny_cid'] || + $data['photo']['allow_gid'] != $item['allow_gid'] || + $data['photo']['deny_gid'] != $item['deny_gid']; $data['photo']['rights_mismatch'] = $rights_mismatch; return $data; @@ -5102,8 +5005,7 @@ function api_get_announce($item) } $fields = ['author-id', 'author-name', 'author-link', 'author-avatar']; - $activity = Item::activityToIndex(Activity::ANNOUNCE); - $condition = ['parent-uri' => $item['uri'], 'gravity' => GRAVITY_ACTIVITY, 'uid' => [0, $item['uid']], 'activity' => $activity]; + $condition = ['parent-uri' => $item['uri'], 'gravity' => GRAVITY_ACTIVITY, 'uid' => [0, $item['uid']], 'vid' => Verb::getID(Activity::ANNOUNCE)]; $announce = Item::selectFirstForUser($item['uid'], $fields, $condition, ['order' => ['received' => true]]); if (!DBA::isResult($announce)) { return []; @@ -5155,7 +5057,7 @@ function api_share_as_retweet(&$item) $reshared_item["share-pre-body"] = $reshared['comment']; $reshared_item["body"] = $reshared['shared']; - $reshared_item["author-id"] = Contact::getIdForURL($reshared['profile'], 0, true); + $reshared_item["author-id"] = Contact::getIdForURL($reshared['profile'], 0, false); $reshared_item["author-name"] = $reshared['author']; $reshared_item["author-link"] = $reshared['profile']; $reshared_item["author-avatar"] = $reshared['avatar']; @@ -5199,7 +5101,7 @@ function api_in_reply_to($item) $in_reply_to['user_id_str'] = null; $in_reply_to['screen_name'] = null; - if (($item['thr-parent'] != $item['uri']) && (intval($item['parent']) != intval($item['id']))) { + if (($item['thr-parent'] != $item['uri']) && ($item['gravity'] != GRAVITY_PARENT)) { $parent = Item::selectFirst(['id'], ['uid' => $item['uid'], 'uri' => $item['thr-parent']]); if (DBA::isResult($parent)) { $in_reply_to['status_id'] = intval($parent['id']); @@ -5374,7 +5276,7 @@ function api_friendica_group_show($type) // loop through all groups and retrieve all members for adding data in the user array $grps = []; foreach ($r as $rr) { - $members = Contact::getByGroupId($rr['id']); + $members = Contact\Group::getById($rr['id']); $users = []; if ($type == "xml") { @@ -5699,7 +5601,7 @@ function api_friendica_group_update($type) } // remove members - $members = Contact::getByGroupId($gid); + $members = Contact\Group::getById($gid); foreach ($members as $member) { $cid = $member['id']; foreach ($users as $user) { @@ -5813,7 +5715,7 @@ function api_friendica_activity($type) $id = $_REQUEST['id'] ?? 0; - $res = Item::performActivity($id, $verb); + $res = Item::performActivity($id, $verb, api_user()); if ($res) { if ($type == "xml") { diff --git a/include/conversation.php b/include/conversation.php index a4fe9c00e3..f681155a4e 100644 --- a/include/conversation.php +++ b/include/conversation.php @@ -28,18 +28,19 @@ use Friendica\Core\Logger; use Friendica\Core\Protocol; use Friendica\Core\Renderer; use Friendica\Core\Session; +use Friendica\Core\Theme; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\Item; use Friendica\Model\Profile; use Friendica\Model\Tag; +use Friendica\Model\Verb; use Friendica\Object\Post; use Friendica\Object\Thread; use Friendica\Protocol\Activity; use Friendica\Util\Crypto; use Friendica\Util\DateTimeFormat; -use Friendica\Util\Proxy as ProxyUtils; use Friendica\Util\Strings; use Friendica\Util\Temporal; use Friendica\Util\XML; @@ -255,7 +256,7 @@ function localize_item(&$item) // add zrl's to public images $photo_pattern = "/\[url=(.*?)\/photos\/(.*?)\/image\/(.*?)\]\[img(.*?)\]h(.*?)\[\/img\]\[\/url\]/is"; if (preg_match($photo_pattern, $item['body'])) { - $photo_replace = '[url=' . Profile::zrl('$1' . '/photos/' . '$2' . '/image/' . '$3' ,true) . '][img' . '$4' . ']h' . '$5' . '[/img][/url]'; + $photo_replace = '[url=' . Profile::zrl('$1' . '/photos/' . '$2' . '/image/' . '$3' , true) . '][img' . '$4' . ']h' . '$5' . '[/img][/url]'; $item['body'] = BBCode::pregReplaceInTag($photo_pattern, $photo_replace, 'url', $item['body']); } @@ -315,7 +316,7 @@ function conv_get_blocklist() return []; } - $str_blocked = DI::pConfig()->get(local_user(), 'system', 'blocked'); + $str_blocked = str_replace(["\n", "\r"], ",", DI::pConfig()->get(local_user(), 'system', 'blocked')); if (empty($str_blocked)) { return []; } @@ -323,8 +324,7 @@ function conv_get_blocklist() $blocklist = []; foreach (explode(',', $str_blocked) as $entry) { - // The 4th parameter guarantees that there always will be a public contact entry - $cid = Contact::getIdForURL(trim($entry), 0, true, ['url' => trim($entry)]); + $cid = Contact::getIdForURL(trim($entry), 0, false); if (!empty($cid)) { $blocklist[] = $cid; } @@ -354,6 +354,13 @@ function conv_get_blocklist() */ function conversation(App $a, array $items, $mode, $update, $preview = false, $order = 'commented', $uid = 0) { + $page = DI::page(); + + $page->registerFooterScript(Theme::getPathForFile('asset/typeahead.js/dist/typeahead.bundle.js')); + $page->registerFooterScript(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput.js')); + $page->registerStylesheet(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput.css')); + $page->registerStylesheet(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput-typeahead.css')); + $ssl_state = (local_user() ? true : false); $profile_owner = 0; @@ -376,17 +383,17 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o . "\r\n"; } @@ -435,15 +442,17 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o if (!$update) { $live_update_div = '
' . "\r\n" . "\r\n"; + . '?f=' + . (!empty($_GET['no_sharer']) ? '&no_sharer=' . rawurlencode($_GET['no_sharer']) : '') + . "'; \r\n"; } } elseif ($mode === 'contacts') { $items = conversation_add_children($items, false, $order, $uid); $profile_owner = 0; if (!$update) { - $live_update_div = '
' . "\r\n" - . "\r\n"; } } elseif ($mode === 'search') { @@ -457,7 +466,7 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o } $cb = ['items' => $items, 'mode' => $mode, 'update' => $update, 'preview' => $preview]; - Hook::callAll('conversation_start',$cb); + Hook::callAll('conversation_start', $cb); $items = $cb['items']; @@ -491,7 +500,7 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o $writable = false; } - if (in_array($mode, ['network-new', 'search', 'contact-posts'])) { + if (in_array($mode, ['filed', 'search', 'contact-posts'])) { /* * "New Item View" on network page or search page results @@ -512,10 +521,6 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o $threadsid++; - $owner_url = ''; - $owner_name = ''; - $sparkle = ''; - // prevent private email from leaking. if ($item['network'] === Protocol::MAIL && local_user() != $item['uid']) { continue; @@ -532,17 +537,17 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o 'network' => $item['author-network'], 'url' => $item['author-link']]; $profile_link = Contact::magicLinkByContact($author); + $sparkle = ''; if (strpos($profile_link, 'redir/') === 0) { $sparkle = ' sparkle'; } $locate = ['location' => $item['location'], 'coord' => $item['coord'], 'html' => '']; - Hook::callAll('render_location',$locate); - - $location = ((strlen($locate['html'])) ? $locate['html'] : render_location_dummy($locate)); + Hook::callAll('render_location', $locate); + $location_html = $locate['html'] ?: Strings::escapeHtml($locate['location'] ?: $locate['coord'] ?: ''); localize_item($item); - if ($mode === 'network-new') { + if ($mode === 'filed') { $dropping = true; } else { $dropping = false; @@ -555,21 +560,18 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o 'delete' => DI::l10n()->t('Delete'), ]; - $star = false; - $isstarred = "unstarred"; - - $lock = false; $likebuttons = [ - 'like' => null, - 'dislike' => null, - 'share' => null, + 'like' => null, + 'dislike' => null, + 'share' => null, + 'announce' => null, ]; if (DI::pConfig()->get(local_user(), 'system', 'hide_dislike')) { unset($likebuttons['dislike']); } - $body = Item::prepareBody($item, true, $preview); + $body_html = Item::prepareBody($item, true, $preview); list($categories, $folders) = DI::contentItem()->determineCategoriesTerms($item); @@ -583,18 +585,22 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o 'template' => $tpl, 'id' => ($preview ? 'P0' : $item['id']), 'guid' => ($preview ? 'Q0' : $item['guid']), + 'commented' => $item['commented'], + 'received' => $item['received'], + 'created_date' => $item['created'], + 'uriid' => $item['uri-id'], 'network' => $item['network'], 'network_name' => ContactSelector::networkToName($item['author-network'], $item['author-link'], $item['network']), 'network_icon' => ContactSelector::networkToIcon($item['network'], $item['author-link']), 'linktitle' => DI::l10n()->t('View %s\'s profile @ %s', $profile_name, $item['author-link']), 'profile_url' => $profile_link, - 'item_photo_menu' => item_photo_menu($item), + 'item_photo_menu_html' => item_photo_menu($item), 'name' => $profile_name, 'sparkle' => $sparkle, - 'lock' => $lock, - 'thumb' => DI::baseUrl()->remove(ProxyUtils::proxifyUrl($item['author-avatar'], false, ProxyUtils::SIZE_THUMB)), + 'lock' => false, + 'thumb' => DI::baseUrl()->remove($item['author-avatar']), 'title' => $title, - 'body' => $body, + 'body_html' => $body_html, 'tags' => $tags['tags'], 'hashtags' => $tags['hashtags'], 'mentions' => $tags['mentions'], @@ -605,23 +611,23 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o 'has_folders' => ((count($folders)) ? 'true' : ''), 'categories' => $categories, 'folders' => $folders, - 'text' => strip_tags($body), + 'text' => strip_tags($body_html), 'localtime' => DateTimeFormat::local($item['created'], 'r'), - 'ago' => (($item['app']) ? DI::l10n()->t('%s from %s', Temporal::getRelativeDate($item['created']),$item['app']) : Temporal::getRelativeDate($item['created'])), - 'location' => $location, + 'ago' => (($item['app']) ? DI::l10n()->t('%s from %s', Temporal::getRelativeDate($item['created']), $item['app']) : Temporal::getRelativeDate($item['created'])), + 'location_html' => $location_html, 'indent' => '', - 'owner_name' => $owner_name, - 'owner_url' => $owner_url, - 'owner_photo' => DI::baseUrl()->remove(ProxyUtils::proxifyUrl($item['owner-avatar'], false, ProxyUtils::SIZE_THUMB)), + 'owner_name' => '', + 'owner_url' => '', + 'owner_photo' => DI::baseUrl()->remove($item['owner-avatar']), 'plink' => Item::getPlink($item), 'edpost' => false, - 'isstarred' => $isstarred, - 'star' => $star, + 'isstarred' => 'unstarred', + 'star' => false, 'drop' => $drop, 'vote' => $likebuttons, - 'like' => '', - 'dislike' => '', - 'comment' => '', + 'like_html' => '', + 'dislike_html' => '', + 'comment_html' => '', 'conv' => (($preview) ? '' : ['href'=> 'display/'.$item['guid'], 'title'=> DI::l10n()->t('View in context')]), 'previewing' => $previewing, 'wait' => DI::l10n()->t('Please wait'), @@ -670,7 +676,7 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o $item['pagedrop'] = $page_dropping; - if ($item['id'] == $item['parent']) { + if ($item['gravity'] == GRAVITY_PARENT) { $item_object = new Post($item); $conv->addParent($item_object); } @@ -690,6 +696,7 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o '$live_update' => $live_update_div, '$remove' => DI::l10n()->t('remove'), '$mode' => $mode, + '$update' => $update, '$user' => $a->user, '$threads' => $threads, '$dropping' => ($page_dropping ? DI::l10n()->t('Delete Selected Items') : False), @@ -703,44 +710,88 @@ function conversation(App $a, array $items, $mode, $update, $preview = false, $o * * @param mixed $thread_items Database statement with thread posts * @param boolean $pinned Is the item pinned? + * @param array $activity Contact data of the resharer * * @return array items with parents and comments */ -function conversation_fetch_comments($thread_items, $pinned) { +function conversation_fetch_comments($thread_items, bool $pinned, array $activity) { $comments = []; - $parentlines = []; - $lineno = 0; - $actor = []; - $received = ''; while ($row = Item::fetch($thread_items)) { - if (($row['verb'] == Activity::ANNOUNCE) && !empty($row['contact-uid']) && ($row['received'] > $received) && ($row['thr-parent'] == $row['parent-uri'])) { - $actor = ['link' => $row['author-link'], 'avatar' => $row['author-avatar'], 'name' => $row['author-name']]; - $received = $row['received']; + if (!empty($activity)) { + if (($row['gravity'] == GRAVITY_PARENT)) { + $row['post-type'] = Item::PT_ANNOUNCEMENT; + $row = array_merge($row, $activity); + $contact = Contact::getById($activity['causer-id'], ['url', 'name', 'thumb']); + $row['causer-link'] = $contact['url']; + $row['causer-avatar'] = $contact['thumb']; + $row['causer-name'] = $contact['name']; + } elseif (($row['gravity'] == GRAVITY_ACTIVITY) && ($row['verb'] == Activity::ANNOUNCE) && + ($row['author-id'] == $activity['causer-id'])) { + continue; + } } - if ((($row['gravity'] == GRAVITY_PARENT) && !$row['origin'] && !in_array($row['network'], [Protocol::DIASPORA])) && - (empty($row['contact-uid']) || !in_array($row['network'], Protocol::NATIVE_SUPPORT))) { - $parentlines[] = $lineno; - } + $name = $row['causer-contact-type'] == Contact::TYPE_RELAY ? $row['causer-link'] : $row['causer-name']; + + switch ($row['post-type']) { + case Item::PT_TO: + $row['direction'] = ['direction' => 7, 'title' => DI::l10n()->t('You had been addressed (%s).', 'to')]; + break; + case Item::PT_CC: + $row['direction'] = ['direction' => 7, 'title' => DI::l10n()->t('You had been addressed (%s).', 'cc')]; + break; + case Item::PT_BTO: + $row['direction'] = ['direction' => 7, 'title' => DI::l10n()->t('You had been addressed (%s).', 'bto')]; + break; + case Item::PT_BCC: + $row['direction'] = ['direction' => 7, 'title' => DI::l10n()->t('You had been addressed (%s).', 'bcc')]; + break; + case Item::PT_FOLLOWER: + $row['direction'] = ['direction' => 6, 'title' => DI::l10n()->t('You are following %s.', $row['author-name'])]; + break; + case Item::PT_TAG: + $row['direction'] = ['direction' => 4, 'title' => DI::l10n()->t('Tagged')]; + break; + case Item::PT_ANNOUNCEMENT: + if (!empty($row['causer-id']) && DI::pConfig()->get(local_user(), 'system', 'display_resharer')) { + $row['owner-id'] = $row['causer-id']; + $row['owner-link'] = $row['causer-link']; + $row['owner-avatar'] = $row['causer-avatar']; + $row['owner-name'] = $row['causer-name']; + } + + if (($row['gravity'] == GRAVITY_PARENT) && !empty($row['causer-id'])) { + $row['reshared'] = DI::l10n()->t('%s reshared this.', '' . htmlentities($name) . ''); + } + $row['direction'] = ['direction' => 3, 'title' => (empty($row['causer-id']) ? DI::l10n()->t('Reshared') : DI::l10n()->t('Reshared by %s', $name))]; + break; + case Item::PT_COMMENT: + $row['direction'] = ['direction' => 5, 'title' => DI::l10n()->t('%s is participating in this thread.', $row['author-name'])]; + break; + case Item::PT_STORED: + $row['direction'] = ['direction' => 8, 'title' => DI::l10n()->t('Stored')]; + break; + case Item::PT_GLOBAL: + $row['direction'] = ['direction' => 9, 'title' => DI::l10n()->t('Global')]; + break; + case Item::PT_RELAY: + $row['direction'] = ['direction' => 10, 'title' => (empty($row['causer-id']) ? DI::l10n()->t('Relayed') : DI::l10n()->t('Relayed by %s.', $name))]; + break; + case Item::PT_FETCHED: + $row['direction'] = ['direction' => 2, 'title' => (empty($row['causer-id']) ? DI::l10n()->t('Fetched') : DI::l10n()->t('Fetched because of %s', $name))]; + break; + } if ($row['gravity'] == GRAVITY_PARENT) { $row['pinned'] = $pinned; } $comments[] = $row; - $lineno++; } DBA::close($thread_items); - if (!empty($actor)) { - foreach ($parentlines as $line) { - $comments[$line]['owner-link'] = $actor['link']; - $comments[$line]['owner-avatar'] = $actor['avatar']; - $comments[$line]['owner-name'] = $actor['name']; - } - } return $comments; } @@ -759,9 +810,13 @@ function conversation_fetch_comments($thread_items, $pinned) { * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ function conversation_add_children(array $parents, $block_authors, $order, $uid) { - $max_comments = DI::config()->get('system', 'max_comments', 100); + if (count($parents) > 1) { + $max_comments = DI::config()->get('system', 'max_comments', 100); + } else { + $max_comments = DI::config()->get('system', 'max_display_comments', 1000); + } - $params = ['order' => ['uid', 'commented' => true]]; + $params = ['order' => ['gravity', 'uid', 'commented' => true]]; if ($max_comments > 0) { $params['limit'] = $max_comments; @@ -770,19 +825,23 @@ function conversation_add_children(array $parents, $block_authors, $order, $uid) $items = []; foreach ($parents AS $parent) { - $condition = ["`item`.`parent-uri` = ? AND `item`.`uid` IN (0, ?) ", - $parent['uri'], $uid]; - if ($block_authors) { - $condition[0] .= "AND NOT `author`.`hidden`"; - } - - $thread_items = Item::selectForUser(local_user(), array_merge(Item::DISPLAY_FIELDLIST, ['contact-uid', 'gravity']), $condition, $params); - - $comments = conversation_fetch_comments($thread_items, $parent['pinned'] ?? false); - - if (count($comments) != 0) { - $items = array_merge($items, $comments); + if (!empty($parent['thr-parent-id']) && !empty($parent['gravity']) && ($parent['gravity'] == GRAVITY_ACTIVITY)) { + $condition = ["`item`.`parent-uri-id` = ? AND `item`.`uid` IN (0, ?) AND (`vid` != ? OR `vid` IS NULL)", + $parent['thr-parent-id'], $uid, Verb::getID(Activity::FOLLOW)]; + if (!empty($parent['author-id'])) { + $activity = ['causer-id' => $parent['author-id']]; + foreach (['commented', 'received', 'created'] as $orderfields) { + if (!empty($parent[$orderfields])) { + $activity[$orderfields] = $parent[$orderfields]; + } + } + } + } else { + $condition = ["`item`.`parent-uri` = ? AND `item`.`uid` IN (0, ?) AND (`vid` != ? OR `vid` IS NULL)", + $parent['uri'], $uid, Verb::getID(Activity::FOLLOW)]; + $activity = []; } + $items = conversation_fetch_items($parent, $items, $condition, $block_authors, $params, $activity); } foreach ($items as $index => $item) { @@ -796,6 +855,32 @@ function conversation_add_children(array $parents, $block_authors, $order, $uid) return $items; } +/** + * Fetch conversation items + * + * @param array $parent Parent Item array + * @param array $items Item array + * @param array $condition SQL condition + * @param boolean $block_authors Don't show posts from contacts that are hidden (used on the community page) + * @param array $params SQL parameters + * @param array $activity Contact data of the resharer + * @return array + */ +function conversation_fetch_items(array $parent, array $items, array $condition, bool $block_authors, array $params, array $activity) { + if ($block_authors) { + $condition[0] .= " AND NOT `author`.`hidden`"; + } + + $thread_items = Item::selectForUser(local_user(), array_merge(Item::DISPLAY_FIELDLIST, ['contact-uid', 'gravity', 'post-type']), $condition, $params); + + $comments = conversation_fetch_comments($thread_items, $parent['pinned'] ?? false, $activity); + + if (count($comments) != 0) { + $items = array_merge($items, $comments); + } + return $items; +} + function item_photo_menu($item) { $sub_link = ''; $poke_link = ''; @@ -807,7 +892,7 @@ function item_photo_menu($item) { $block_link = ''; $ignore_link = ''; - if (local_user() && local_user() == $item['uid'] && $item['parent'] == $item['id'] && !$item['self']) { + if (local_user() && local_user() == $item['uid'] && $item['gravity'] == GRAVITY_PARENT && !$item['self']) { $sub_link = 'javascript:dosubthread(' . $item['id'] . '); return false;'; } @@ -817,7 +902,7 @@ function item_photo_menu($item) { $sparkle = (strpos($profile_link, 'redir/') === 0); $cid = 0; - $pcid = Contact::getIdForURL($item['author-link'], 0, true); + $pcid = Contact::getIdForURL($item['author-link'], 0, false); $network = ''; $rel = 0; $condition = ['uid' => local_user(), 'nurl' => Strings::normaliseLink($item['author-link'])]; @@ -864,13 +949,17 @@ function item_photo_menu($item) { DI::l10n()->t('Ignore') => $ignore_link ]; + if (!empty($item['language'])) { + $menu[DI::l10n()->t('Languages')] = 'javascript:alert(\'' . Item::getLanguageMessage($item) . '\');'; + } + if ($network == Protocol::DFRN) { $menu[DI::l10n()->t("Poke")] = $poke_link; } if ((($cid == 0) || ($rel == Contact::FOLLOWER)) && in_array($item['network'], Protocol::FEDERATED)) { - $menu[DI::l10n()->t('Connect/Follow')] = 'follow?url=' . urlencode($item['author-link']); + $menu[DI::l10n()->t('Connect/Follow')] = 'follow?url=' . urlencode($item['author-link']) . '&auto=1'; } } else { $menu = [DI::l10n()->t('View Profile') => $item['author-link']]; @@ -899,13 +988,14 @@ function item_photo_menu($item) { * * Increments the count of each matching activity and adds a link to the author as needed. * - * @param array $item + * @param array $activity * @param array &$conv_responses (already created with builtin activity structure) * @return void * @throws ImagickException * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ -function builtin_activity_puller($item, &$conv_responses) { +function builtin_activity_puller(array $activity, array &$conv_responses) +{ foreach ($conv_responses as $mode => $v) { $sparkle = ''; @@ -932,41 +1022,45 @@ function builtin_activity_puller($item, &$conv_responses) { return; } - if (!empty($item['verb']) && DI::activity()->match($item['verb'], $verb) && ($item['id'] != $item['parent'])) { - $author = ['uid' => 0, 'id' => $item['author-id'], - 'network' => $item['author-network'], 'url' => $item['author-link']]; + if (!empty($activity['verb']) && DI::activity()->match($activity['verb'], $verb) && ($activity['gravity'] != GRAVITY_PARENT)) { + $author = [ + 'uid' => 0, + 'id' => $activity['author-id'], + 'network' => $activity['author-network'], + 'url' => $activity['author-link'] + ]; $url = Contact::magicLinkByContact($author); if (strpos($url, 'redir/') === 0) { $sparkle = ' class="sparkle" '; } - $url = '' . htmlentities($item['author-name']) . ''; + $link = '' . htmlentities($activity['author-name']) . ''; - if (empty($item['thr-parent'])) { - $item['thr-parent'] = $item['parent-uri']; + if (empty($activity['thr-parent'])) { + $activity['thr-parent'] = $activity['parent-uri']; } - if (!(isset($conv_responses[$mode][$item['thr-parent'] . '-l']) - && is_array($conv_responses[$mode][$item['thr-parent'] . '-l']))) { - $conv_responses[$mode][$item['thr-parent'] . '-l'] = []; - } - - // only list each unique author once - if (in_array($url,$conv_responses[$mode][$item['thr-parent'] . '-l'])) { + // Skip when the causer of the parent is the same than the author of the announce + if (($verb == Activity::ANNOUNCE) && Item::exists(['uri' => $activity['thr-parent'], + 'uid' => $activity['uid'], 'causer-id' => $activity['author-id'], 'gravity' => GRAVITY_PARENT])) { continue; } - if (!isset($conv_responses[$mode][$item['thr-parent']])) { - $conv_responses[$mode][$item['thr-parent']] = 1; - } else { - $conv_responses[$mode][$item['thr-parent']] ++; + if (!isset($conv_responses[$mode][$activity['thr-parent']])) { + $conv_responses[$mode][$activity['thr-parent']] = [ + 'links' => [], + 'self' => 0, + ]; + } elseif (in_array($link, $conv_responses[$mode][$activity['thr-parent']]['links'])) { + // only list each unique author once + continue; } - if (public_contact() == $item['author-id']) { - $conv_responses[$mode][$item['thr-parent'] . '-self'] = 1; + if (public_contact() == $activity['author-id']) { + $conv_responses[$mode][$activity['thr-parent']]['self'] = 1; } - $conv_responses[$mode][$item['thr-parent'] . '-l'][] = $url; + $conv_responses[$mode][$activity['thr-parent']]['links'][] = $link; // there can only be one activity verb per item so if we found anything, we can stop looking return; @@ -975,26 +1069,26 @@ function builtin_activity_puller($item, &$conv_responses) { } /** - * Format the vote text for a profile item + * Format the activity text for an item/photo/video * - * @param int $cnt = number of people who vote the item - * @param array $arr = array of pre-linked names of likers/dislikers - * @param string $type = one of 'like, 'dislike', 'attendyes', 'attendno', 'attendmaybe' - * @param int $id = item id + * @param array $links = array of pre-linked names of actors + * @param string $verb = one of 'like, 'dislike', 'attendyes', 'attendno', 'attendmaybe' + * @param int $id = item id * @return string formatted text * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ -function format_like($cnt, array $arr, $type, $id) { +function format_activity(array $links, $verb, $id) { $o = ''; $expanded = ''; $phrase = ''; - if ($cnt == 1) { - $likers = $arr[0]; + $total = count($links); + if ($total == 1) { + $likers = $links[0]; // Phrase if there is only one liker. In other cases it will be uses for the expanded // list which show all likers - switch ($type) { + switch ($verb) { case 'like' : $phrase = DI::l10n()->t('%s likes this.', $likers); break; @@ -1014,56 +1108,51 @@ function format_like($cnt, array $arr, $type, $id) { $phrase = DI::l10n()->t('%s reshared this.', $likers); break; } - } - - if ($cnt > 1) { - $total = count($arr); + } elseif ($total > 1) { if ($total < MAX_LIKERS) { - $last = DI::l10n()->t('and') . ' ' . $arr[count($arr)-1]; - $arr2 = array_slice($arr, 0, -1); - $likers = implode(', ', $arr2) . ' ' . $last; + $likers = implode(', ', array_slice($links, 0, -1)); + $likers .= ' ' . DI::l10n()->t('and') . ' ' . $links[count($links)-1]; } else { - $arr = array_slice($arr, 0, MAX_LIKERS - 1); - $likers = implode(', ', $arr); - $likers .= DI::l10n()->t('and %d other people', $total - MAX_LIKERS); + $likers = implode(', ', array_slice($links, 0, MAX_LIKERS - 1)); + $likers .= ' ' . DI::l10n()->t('and %d other people', $total - MAX_LIKERS); } - $spanatts = "class=\"fakelink\" onclick=\"openClose('{$type}list-$id');\""; + $spanatts = "class=\"fakelink\" onclick=\"openClose('{$verb}list-$id');\""; $explikers = ''; - switch ($type) { + switch ($verb) { case 'like': - $phrase = DI::l10n()->t('%2$d people like this', $spanatts, $cnt); + $phrase = DI::l10n()->t('%2$d people like this', $spanatts, $total); $explikers = DI::l10n()->t('%s like this.', $likers); break; case 'dislike': - $phrase = DI::l10n()->t('%2$d people don\'t like this', $spanatts, $cnt); + $phrase = DI::l10n()->t('%2$d people don\'t like this', $spanatts, $total); $explikers = DI::l10n()->t('%s don\'t like this.', $likers); break; case 'attendyes': - $phrase = DI::l10n()->t('%2$d people attend', $spanatts, $cnt); + $phrase = DI::l10n()->t('%2$d people attend', $spanatts, $total); $explikers = DI::l10n()->t('%s attend.', $likers); break; case 'attendno': - $phrase = DI::l10n()->t('%2$d people don\'t attend', $spanatts, $cnt); + $phrase = DI::l10n()->t('%2$d people don\'t attend', $spanatts, $total); $explikers = DI::l10n()->t('%s don\'t attend.', $likers); break; case 'attendmaybe': - $phrase = DI::l10n()->t('%2$d people attend maybe', $spanatts, $cnt); + $phrase = DI::l10n()->t('%2$d people attend maybe', $spanatts, $total); $explikers = DI::l10n()->t('%s attend maybe.', $likers); break; case 'announce': - $phrase = DI::l10n()->t('%2$d people reshared this', $spanatts, $cnt); + $phrase = DI::l10n()->t('%2$d people reshared this', $spanatts, $total); $explikers = DI::l10n()->t('%s reshared this.', $likers); break; } - $expanded .= "\t" . ''; + $expanded .= "\t" . ''; } $o .= Renderer::replaceMacros(Renderer::getMarkupTemplate('voting_fakelink.tpl'), [ '$phrase' => $phrase, - '$type' => $type, + '$type' => $verb, '$id' => $id ]); $o .= $expanded; @@ -1088,40 +1177,18 @@ function status_editor(App $a, $x, $notes_cid = 0, $popup = false) '$term' => DI::l10n()->t('Tag term:'), '$fileas' => DI::l10n()->t('Save to Folder:'), '$whereareu' => DI::l10n()->t('Where are you right now?'), - '$delitems' => DI::l10n()->t("Delete item\x28s\x29?") + '$delitems' => DI::l10n()->t("Delete item\x28s\x29?"), + '$is_mobile' => DI::mode()->isMobile(), ]); $jotplugins = ''; Hook::callAll('jot_tool', $jotplugins); - // Private/public post links for the non-JS ACL form - $private_post = 1; - if (!empty($_REQUEST['public'])) { - $private_post = 0; - } - - $query_str = DI::args()->getQueryString(); - if (strpos($query_str, 'public=1') !== false) { - $query_str = str_replace(['?public=1', '&public=1'], ['', ''], $query_str); - } - - /* - * I think $a->query_string may never have ? in it, but I could be wrong - * It looks like it's from the index.php?q=[etc] rewrite that the web - * server does, which converts any ? to &, e.g. suggest&ignore=61 for suggest?ignore=61 - */ - if (strpos($query_str, '?') === false) { - $public_post_link = '?public=1'; - } else { - $public_post_link = '&public=1'; - } - - // $tpl = Renderer::replaceMacros($tpl,array('$jotplugins' => $jotplugins)); $tpl = Renderer::getMarkupTemplate("jot.tpl"); - $o .= Renderer::replaceMacros($tpl,[ + $o .= Renderer::replaceMacros($tpl, [ '$new_post' => DI::l10n()->t('New Post'), - '$return_path' => $query_str, + '$return_path' => DI::args()->getQueryString(), '$action' => 'item', '$share' => ($x['button'] ?? '') ?: DI::l10n()->t('Share'), '$loading' => DI::l10n()->t('Loading...'), @@ -1147,7 +1214,7 @@ function status_editor(App $a, $x, $notes_cid = 0, $popup = false) '$placeholdercategory' => Feature::isEnabled(local_user(), 'categories') ? DI::l10n()->t("Categories \x28comma-separated list\x29") : '', '$wait' => DI::l10n()->t('Please wait'), '$permset' => DI::l10n()->t('Permission settings'), - '$shortpermset' => DI::l10n()->t('permissions'), + '$shortpermset' => DI::l10n()->t('Permissions'), '$wall' => $notes_cid ? 0 : 1, '$posttype' => $notes_cid ? Item::PT_PERSONAL_NOTE : Item::PT_ARTICLE, '$content' => $x['content'] ?? '', @@ -1169,11 +1236,6 @@ function status_editor(App $a, $x, $notes_cid = 0, $popup = false) // ACL permissions box '$acl' => $x['acl'], - '$group_perms' => DI::l10n()->t('Post to Groups'), - '$contact_perms' => DI::l10n()->t('Post to Contacts'), - '$private' => DI::l10n()->t('Private post'), - '$is_private' => $private_post, - '$public_link' => $public_post_link, //jot nav tab (used in some themes) '$message' => DI::l10n()->t('Message'), @@ -1202,7 +1264,7 @@ function get_item_children(array &$item_list, array $parent, $recursive = true) { $children = []; foreach ($item_list as $i => $item) { - if ($item['id'] != $item['parent']) { + if ($item['gravity'] != GRAVITY_PARENT) { if ($recursive) { // Fallback to parent-uri if thr-parent is not set $thr_parent = $item['thr-parent']; @@ -1350,7 +1412,7 @@ function conv_sort(array $item_list, $order) // Extract the top level items foreach ($item_array as $item) { - if ($item['id'] == $item['parent']) { + if ($item['gravity'] == GRAVITY_PARENT) { $parents[] = $item; } } @@ -1447,13 +1509,3 @@ function sort_thr_commented(array $a, array $b) { return strcmp($b['commented'], $a['commented']); } - -function render_location_dummy(array $item) { - if (!empty($item['location']) && !empty($item['location'])) { - return $item['location']; - } - - if (!empty($item['coord']) && !empty($item['coord'])) { - return $item['coord']; - } -} diff --git a/include/enotify.php b/include/enotify.php index ae2e2e7fef..f09cbf29ad 100644 --- a/include/enotify.php +++ b/include/enotify.php @@ -26,6 +26,7 @@ use Friendica\Core\Renderer; use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Model\Contact; use Friendica\Model\Item; use Friendica\Model\ItemContent; use Friendica\Model\Notify; @@ -37,10 +38,10 @@ use Friendica\Protocol\Activity; * Creates a notification entry and possibly sends a mail * * @param array $params Array with the elements: - * uid, item, parent, type, otype, verb, event, - * link, subject, body, to_name, to_email, source_name, - * source_link, activity, preamble, notify_flags, - * language, show_in_notification_page + * type, event, otype, activity, verb, uid, cid, origin_cid, item, link, + * source_name, source_mail, source_nick, source_link, source_photo, + * show_in_notification_page + * * @return bool * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ @@ -55,7 +56,7 @@ function notification($params) } // Ensure that the important fields are set at any time - $fields = ['notify-flags', 'language', 'username', 'email']; + $fields = ['nickname', 'page-flags', 'notify-flags', 'language', 'username', 'email']; $user = DBA::selectFirst('user', $fields, ['uid' => $params['uid']]); if (!DBA::isResult($user)) { @@ -63,14 +64,39 @@ function notification($params) return false; } - $params['notify_flags'] = ($params['notify_flags'] ?? '') ?: $user['notify-flags']; - $params['language'] = ($params['language'] ?? '') ?: $user['language']; - $params['to_name'] = ($params['to_name'] ?? '') ?: $user['username']; - $params['to_email'] = ($params['to_email'] ?? '') ?: $user['email']; + // There is no need to create notifications for forum accounts + if (in_array($user['page-flags'], [User::PAGE_FLAGS_COMMUNITY, User::PAGE_FLAGS_PRVGROUP])) { + return false; + } + + $nickname = $user['nickname']; + + $params['notify_flags'] = $user['notify-flags']; + $params['language'] = $user['language']; + $params['to_name'] = $user['username']; + $params['to_email'] = $user['email']; // from here on everything is in the recipients language $l10n = DI::l10n()->withLang($params['language']); + if (!empty($params['cid'])) { + $contact = Contact::getById($params['cid'], ['url', 'name', 'photo']); + if (DBA::isResult($contact)) { + $params['source_link'] = $contact['url']; + $params['source_name'] = $contact['name']; + $params['source_photo'] = $contact['photo']; + } + } + + if (!empty($params['origin_cid'])) { + $contact = Contact::getById($params['origin_cid'], ['url', 'name', 'photo']); + if (DBA::isResult($contact)) { + $params['origin_link'] = $contact['url']; + $params['origin_name'] = $contact['name']; + $params['origin_photo'] = $contact['photo']; + } + } + $siteurl = DI::baseUrl()->get(true); $sitename = DI::config()->get('config', 'sitename'); @@ -79,51 +105,23 @@ function notification($params) $hostname = substr($hostname, 0, strpos($hostname, ':')); } - $user = User::getById($params['uid'], ['nickname', 'page-flags']); - - // There is no need to create notifications for forum accounts - if (!DBA::isResult($user) || in_array($user["page-flags"], [User::PAGE_FLAGS_COMMUNITY, User::PAGE_FLAGS_PRVGROUP])) { - return false; - } - $nickname = $user["nickname"]; + // Creates a new email builder for the notification email + $emailBuilder = DI::emailer()->newNotifyMail(); // with $params['show_in_notification_page'] == false, the notification isn't inserted into // the database, and an email is sent if applicable. // default, if not specified: true $show_in_notification_page = isset($params['show_in_notification_page']) ? $params['show_in_notification_page'] : true; - $additional_mail_header = "X-Friendica-Account: <".$nickname."@".$hostname.">\n"; + $emailBuilder->setHeader('X-Friendica-Account', '<' . $nickname . '@' . $hostname . '>'); - if (array_key_exists('item', $params)) { - $title = $params['item']['title']; - $body = $params['item']['body']; - } else { - $title = $body = ''; - } + $title = $params['item']['title'] ?? ''; + $body = $params['item']['body'] ?? ''; - if (isset($params['item']['id'])) { - $item_id = $params['item']['id']; - } else { - $item_id = 0; - } - - if (isset($params['item']['uri-id'])) { - $uri_id = $params['item']['uri-id']; - } else { - $uri_id = 0; - } - - if (isset($params['parent'])) { - $parent_id = $params['parent']; - } else { - $parent_id = 0; - } - - if (isset($params['item']['parent-uri-id'])) { - $parent_uri_id = $params['item']['parent-uri-id']; - } else { - $parent_uri_id = 0; - } + $item_id = $params['item']['id'] ?? 0; + $uri_id = $params['item']['uri-id'] ?? 0; + $parent_id = $params['item']['parent'] ?? 0; + $parent_uri_id = $params['item']['parent-uri-id'] ?? 0; $epreamble = ''; $preamble = ''; @@ -134,8 +132,7 @@ function notification($params) $itemlink = ''; if ($params['type'] == Notify\Type::MAIL) { - $itemlink = $siteurl.'/message/'.$params['item']['id']; - $params["link"] = $itemlink; + $itemlink = $params['link']; $subject = $l10n->t('%s New mail received at %s', $subjectPrefix, $sitename); @@ -143,8 +140,11 @@ function notification($params) $epreamble = $l10n->t('%1$s sent you %2$s.', '[url='.$params['source_link'].']'.$params['source_name'].'[/url]', '[url=' . $itemlink . ']' . $l10n->t('a private message').'[/url]'); $sitelink = $l10n->t('Please visit %s to view and/or reply to your private messages.'); - $tsitelink = sprintf($sitelink, $siteurl.'/message/'.$params['item']['id']); - $hsitelink = sprintf($sitelink, ''.$sitename.''); + $tsitelink = sprintf($sitelink, $itemlink); + $hsitelink = sprintf($sitelink, '' . $sitename . ''); + + // Mail notifications aren't using the "notify" table entry + $show_in_notification_page = false; } if ($params['type'] == Notify\Type::COMMENT || $params['type'] == Notify\Type::TAG_SELF) { @@ -259,13 +259,23 @@ function notification($params) } if ($params['type'] == Notify\Type::SHARE) { - $subject = $l10n->t('%s %s shared a new post', $subjectPrefix, $params['source_name']); + if ($params['origin_link'] == $params['source_link']) { + $subject = $l10n->t('%s %s shared a new post', $subjectPrefix, $params['source_name']); - $preamble = $l10n->t('%1$s shared a new post at %2$s', $params['source_name'], $sitename); - $epreamble = $l10n->t('%1$s [url=%2$s]shared a post[/url].', - '[url='.$params['source_link'].']'.$params['source_name'].'[/url]', - $params['link'] - ); + $preamble = $l10n->t('%1$s shared a new post at %2$s', $params['source_name'], $sitename); + $epreamble = $l10n->t('%1$s [url=%2$s]shared a post[/url].', + '[url='.$params['source_link'].']'.$params['source_name'].'[/url]', + $params['link'] + ); + } else { + $subject = $l10n->t('%s %s shared a post from %s', $subjectPrefix, $params['source_name'], $params['origin_name']); + + $preamble = $l10n->t('%1$s shared a post from %2$s at %3$s', $params['source_name'], $params['origin_name'], $sitename); + $epreamble = $l10n->t('%1$s [url=%2$s]shared a post[/url] from %3$s.', + '[url='.$params['source_link'].']'.$params['source_name'].'[/url]', + $params['link'], '[url='.$params['origin_link'].']'.$params['origin_name'].'[/url]' + ); + } $sitelink = $l10n->t('Please visit %s to view and/or reply to the conversation.'); $tsitelink = sprintf($sitelink, $siteurl); @@ -463,21 +473,35 @@ function notification($params) $notify_id = 0; if ($show_in_notification_page) { - $notification = DI::notify()->insert([ + $fields = [ 'name' => $params['source_name'] ?? '', - 'name_cache' => substr(strip_tags(BBCode::convert($params['source_name'] ?? '')), 0, 255), + 'name_cache' => substr(strip_tags(BBCode::convert($params['source_name'])), 0, 255), 'url' => $params['source_link'] ?? '', 'photo' => $params['source_photo'] ?? '', 'link' => $itemlink ?? '', 'uid' => $params['uid'] ?? 0, - 'iid' => $item_id, - 'uri-id' => $uri_id, - 'parent' => $parent_id, - 'parent-uri-id' => $parent_uri_id, 'type' => $params['type'] ?? '', 'verb' => $params['verb'] ?? '', 'otype' => $params['otype'] ?? '', - ]); + ]; + if (!empty($item_id)) { + $fields['iid'] = $item_id; + } + if (!empty($uri_id)) { + $fields['uri-id'] = $uri_id; + } + if (!empty($parent_id)) { + $fields['parent'] = $parent_id; + } + if (!empty($parent_uri_id)) { + $fields['parent-uri-id'] = $parent_uri_id; + } + $notification = DI::notify()->insert($fields); + + // Notification insertion can be intercepted by an addon registering the 'enotify_store' hook + if (!$notification) { + return false; + } $notification->msg = Renderer::replaceMacros($epreamble, ['$itemlink' => $notification->link]); @@ -494,7 +518,8 @@ function notification($params) Logger::log('sending notification email'); if (isset($params['parent']) && (intval($params['parent']) != 0)) { - $id_for_parent = $params['parent'] . "@" . $hostname; + $parent = Item::selectFirst(['guid'], ['id' => $params['parent']]); + $message_id = "<" . $parent['guid'] . "@" . gethostname() . ">"; // Is this the first email notification for this parent item and user? if (!DBA::exists('notify-threads', ['master-parent-item' => $params['parent'], 'receiver-uid' => $params['uid']])) { @@ -505,13 +530,14 @@ function notification($params) 'receiver-uid' => $params['uid'], 'parent-item' => 0]; DBA::insert('notify-threads', $fields); - $additional_mail_header .= "Message-ID: <${id_for_parent}>\n"; + $emailBuilder->setHeader('Message-ID', $message_id); $log_msg = "include/enotify: No previous notification found for this parent:\n" . " parent: ${params['parent']}\n" . " uid : ${params['uid']}\n"; Logger::log($log_msg, Logger::DEBUG); } else { // If not, just "follow" the thread. - $additional_mail_header .= "References: <${id_for_parent}>\nIn-Reply-To: <${id_for_parent}>\n"; + $emailBuilder->setHeader('References', $message_id); + $emailBuilder->setHeader('In-Reply-To', $message_id); Logger::log("There's already a notification for this parent.", Logger::DEBUG); } } @@ -530,14 +556,13 @@ function notification($params) 'title' => $title, 'body' => $body, 'subject' => $subject, - 'headers' => $additional_mail_header, + 'headers' => $emailBuilder->getHeaders(), ]; Hook::callAll('enotify_mail', $datarray); - $builder = DI::emailer() - ->newNotifyMail() - ->addHeaders($datarray['headers']) + $emailBuilder + ->withHeaders($datarray['headers']) ->withRecipient($params['to_email']) ->forUser([ 'uid' => $datarray['uid'], @@ -549,13 +574,13 @@ function notification($params) // If a photo is present, add it to the email if (!empty($datarray['source_photo'])) { - $builder->withPhoto( + $emailBuilder->withPhoto( $datarray['source_photo'], $datarray['source_link'] ?? $sitelink, $datarray['source_name'] ?? $sitename); } - $email = $builder->build(); + $email = $emailBuilder->build(); // use the Emailer class to send the message return DI::emailer()->send($email); @@ -589,10 +614,10 @@ function check_user_notification($itemid) { * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ function check_item_notification($itemid, $uid, $notification_type) { - $fields = ['id', 'uri-id', 'mention', 'parent', 'parent-uri-id', 'title', 'body', - 'author-link', 'author-name', 'author-avatar', 'author-id', - 'guid', 'parent-uri', 'uri', 'contact-id', 'network']; - $condition = ['id' => $itemid, 'gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT], 'deleted' => false]; + $fields = ['id', 'uri-id', 'mention', 'parent', 'parent-uri-id', 'thr-parent-id', + 'title', 'body', 'author-link', 'author-name', 'author-avatar', 'author-id', + 'gravity', 'guid', 'parent-uri', 'uri', 'contact-id', 'network']; + $condition = ['id' => $itemid, 'deleted' => false]; $item = Item::selectFirstForUser($uid, $fields, $condition); if (!DBA::isResult($item)) { return false; @@ -600,14 +625,11 @@ function check_item_notification($itemid, $uid, $notification_type) { // Generate the notification array $params = []; + $params['otype'] = Notify\ObjectType::ITEM; $params['uid'] = $uid; + $params['origin_cid'] = $params['cid'] = $item['author-id']; $params['item'] = $item; - $params['parent'] = $item['parent']; $params['link'] = DI::baseUrl() . '/display/' . urlencode($item['guid']); - $params['otype'] = 'item'; - $params['source_name'] = $item['author-name']; - $params['source_link'] = $item['author-link']; - $params['source_photo'] = $item['author-avatar']; // Set the activity flags $params['activity']['explicit_tagged'] = ($notification_type & UserItem::NOTIF_EXPLICIT_TAGGED); @@ -625,6 +647,20 @@ function check_item_notification($itemid, $uid, $notification_type) { if ($notification_type & UserItem::NOTIF_SHARED) { $params['type'] = Notify\Type::SHARE; $params['verb'] = Activity::POST; + + // Special treatment for posts that had been shared via "announce" + if ($item['gravity'] == GRAVITY_ACTIVITY) { + $parent_item = Item::selectFirst($fields, ['uri-id' => $item['thr-parent-id'], 'uid' => [$uid, 0]]); + if (DBA::isResult($parent_item)) { + // Don't notify on own entries + if (User::getIdForURL($parent_item['author-link']) == $uid) { + return false; + } + + $params['origin_cid'] = $parent_item['author-id']; + $params['item'] = $parent_item; + } + } } elseif ($notification_type & UserItem::NOTIF_EXPLICIT_TAGGED) { $params['type'] = Notify\Type::TAG_SELF; $params['verb'] = Activity::TAG; diff --git a/include/items.php b/include/items.php deleted file mode 100644 index 38f4a58fba..0000000000 --- a/include/items.php +++ /dev/null @@ -1,448 +0,0 @@ -. - * - */ - -use Friendica\Core\Hook; -use Friendica\Core\Logger; -use Friendica\Core\Protocol; -use Friendica\Core\Renderer; -use Friendica\Core\Session; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Model\Item; -use Friendica\Protocol\DFRN; -use Friendica\Protocol\Feed; -use Friendica\Protocol\OStatus; -use Friendica\Util\Network; -use Friendica\Util\ParseUrl; -use Friendica\Util\Strings; - -require_once __DIR__ . '/../mod/share.php'; - -function add_page_info_data(array $data, $no_photos = false) -{ - Hook::callAll('page_info_data', $data); - - if (empty($data['type'])) { - return ''; - } - - // It maybe is a rich content, but if it does have everything that a link has, - // then treat it that way - if (($data["type"] == "rich") && is_string($data["title"]) && - is_string($data["text"]) && !empty($data["images"])) { - $data["type"] = "link"; - } - - $data["title"] = $data["title"] ?? ''; - - if ((($data["type"] != "link") && ($data["type"] != "video") && ($data["type"] != "photo")) || ($data["title"] == $data["url"])) { - return ""; - } - - if ($no_photos && ($data["type"] == "photo")) { - return ""; - } - - // Escape some bad characters - $data["url"] = str_replace(["[", "]"], ["[", "]"], htmlentities($data["url"], ENT_QUOTES, 'UTF-8', false)); - $data["title"] = str_replace(["[", "]"], ["[", "]"], htmlentities($data["title"], ENT_QUOTES, 'UTF-8', false)); - - $text = "[attachment type='".$data["type"]."'"; - - if (empty($data["text"])) { - $data["text"] = $data["title"]; - } - - if (empty($data["text"])) { - $data["text"] = $data["url"]; - } - - if (!empty($data["url"])) { - $text .= " url='".$data["url"]."'"; - } - - if (!empty($data["title"])) { - $text .= " title='".$data["title"]."'"; - } - - // Only embedd a picture link when it seems to be a valid picture ("width" is set) - if (!empty($data["images"]) && !empty($data["images"][0]["width"])) { - $preview = str_replace(["[", "]"], ["[", "]"], htmlentities($data["images"][0]["src"], ENT_QUOTES, 'UTF-8', false)); - // if the preview picture is larger than 500 pixels then show it in a larger mode - // But only, if the picture isn't higher than large (To prevent huge posts) - if (!DI::config()->get('system', 'always_show_preview') && ($data["images"][0]["width"] >= 500) - && ($data["images"][0]["width"] >= $data["images"][0]["height"])) { - $text .= " image='".$preview."'"; - } else { - $text .= " preview='".$preview."'"; - } - } - - $text .= "]".$data["text"]."[/attachment]"; - - $hashtags = ""; - if (isset($data["keywords"]) && count($data["keywords"])) { - $hashtags = "\n"; - foreach ($data["keywords"] as $keyword) { - /// @TODO make a positive list of allowed characters - $hashtag = str_replace([' ', '+', '/', '.', '#', '@', "'", '"', '’', '`', '(', ')', '„', '“'], '', $keyword); - $hashtags .= "#[url=" . DI::baseUrl() . "/search?tag=" . $hashtag . "]" . $hashtag . "[/url] "; - } - } - - return "\n".$text.$hashtags; -} - -function query_page_info($url, $photo = "", $keywords = false, $keyword_blacklist = "") -{ - $data = ParseUrl::getSiteinfoCached($url, true); - - if ($photo != "") { - $data["images"][0]["src"] = $photo; - } - - Logger::log('fetch page info for ' . $url . ' ' . print_r($data, true), Logger::DEBUG); - - if (!$keywords && isset($data["keywords"])) { - unset($data["keywords"]); - } - - if (($keyword_blacklist != "") && isset($data["keywords"])) { - $list = explode(", ", $keyword_blacklist); - - foreach ($list as $keyword) { - $keyword = trim($keyword); - - $index = array_search($keyword, $data["keywords"]); - if ($index !== false) { - unset($data["keywords"][$index]); - } - } - } - - return $data; -} - -function get_page_keywords($url, $photo = "", $keywords = false, $keyword_blacklist = "") -{ - $data = query_page_info($url, $photo, $keywords, $keyword_blacklist); - if (empty($data["keywords"]) || !is_array($data["keywords"])) { - return []; - } - - $taglist = []; - foreach ($data['keywords'] as $keyword) { - $hashtag = str_replace([" ", "+", "/", ".", "#", "'"], - ["", "", "", "", "", ""], $keyword); - - $taglist[] = $hashtag; - } - - return $taglist; -} - -function add_page_info($url, $no_photos = false, $photo = "", $keywords = false, $keyword_blacklist = "") -{ - $data = query_page_info($url, $photo, $keywords, $keyword_blacklist); - - $text = ''; - - if (is_array($data)) { - $text = add_page_info_data($data, $no_photos); - } - - return $text; -} - -function add_page_info_to_body($body, $texturl = false, $no_photos = false) -{ - Logger::log('add_page_info_to_body: fetch page info for body ' . $body, Logger::DEBUG); - - $URLSearchString = "^\[\]"; - - // Fix for Mastodon where the mentions are in a different format - $body = preg_replace("/\[url\=([$URLSearchString]*)\]([#!@])(.*?)\[\/url\]/ism", - '$2[url=$1]$3[/url]', $body); - - // Adding these spaces is a quick hack due to my problems with regular expressions :) - preg_match("/[^!#@]\[url\]([$URLSearchString]*)\[\/url\]/ism", " " . $body, $matches); - - if (!$matches) { - preg_match("/[^!#@]\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", " " . $body, $matches); - } - - // Convert urls without bbcode elements - if (!$matches && $texturl) { - preg_match("/([^\]\='".'"'."]|^)(https?\:\/\/[a-zA-Z0-9\:\/\-\?\&\;\.\=\_\~\#\%\$\!\+\,]+)/ism", " ".$body, $matches); - - // Yeah, a hack. I really hate regular expressions :) - if ($matches) { - $matches[1] = $matches[2]; - } - } - - if ($matches) { - $footer = add_page_info($matches[1], $no_photos); - } - - // Remove the link from the body if the link is attached at the end of the post - if (isset($footer) && (trim($footer) != "") && (strpos($footer, $matches[1]))) { - $removedlink = trim(str_replace($matches[1], "", $body)); - if (($removedlink == "") || strstr($body, $removedlink)) { - $body = $removedlink; - } - - $removedlink = preg_replace("/\[url\=" . preg_quote($matches[1], '/') . "\](.*?)\[\/url\]/ism", '', $body); - if (($removedlink == "") || strstr($body, $removedlink)) { - $body = $removedlink; - } - } - - // Add the page information to the bottom - if (isset($footer) && (trim($footer) != "")) { - $body .= $footer; - } - - return $body; -} - -/** - * - * consume_feed - process atom feed and update anything/everything we might need to update - * - * $xml = the (atom) feed to consume - RSS isn't as fully supported but may work for simple feeds. - * - * $importer = the contact_record (joined to user_record) of the local user who owns this relationship. - * It is this person's stuff that is going to be updated. - * $contact = the person who is sending us stuff. If not set, we MAY be processing a "follow" activity - * from an external network and MAY create an appropriate contact record. Otherwise, we MUST - * have a contact record. - * $hub = should we find a hub declation in the feed, pass it back to our calling process, who might (or - * might not) try and subscribe to it. - * $datedir sorts in reverse order - * $pass - by default ($pass = 0) we cannot guarantee that a parent item has been - * imported prior to its children being seen in the stream unless we are certain - * of how the feed is arranged/ordered. - * With $pass = 1, we only pull parent items out of the stream. - * With $pass = 2, we only pull children (comments/likes). - * - * So running this twice, first with pass 1 and then with pass 2 will do the right - * thing regardless of feed ordering. This won't be adequate in a fully-threaded - * model where comments can have sub-threads. That would require some massive sorting - * to get all the feed items into a mostly linear ordering, and might still require - * recursion. - * - * @param $xml - * @param array $importer - * @param array $contact - * @param $hub - * @throws ImagickException - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ -function consume_feed($xml, array $importer, array $contact, &$hub) -{ - if ($contact['network'] === Protocol::OSTATUS) { - Logger::log("Consume OStatus messages ", Logger::DEBUG); - OStatus::import($xml, $importer, $contact, $hub); - - return; - } - - if ($contact['network'] === Protocol::FEED) { - Logger::log("Consume feeds", Logger::DEBUG); - Feed::import($xml, $importer, $contact); - - return; - } - - if ($contact['network'] === Protocol::DFRN) { - Logger::log("Consume DFRN messages", Logger::DEBUG); - $dfrn_importer = DFRN::getImporter($contact["id"], $importer["uid"]); - if (!empty($dfrn_importer)) { - Logger::log("Now import the DFRN feed"); - DFRN::import($xml, $dfrn_importer, true); - return; - } - } -} - -function subscribe_to_hub($url, array $importer, array $contact, $hubmode = 'subscribe') -{ - /* - * Diaspora has different message-ids in feeds than they do - * through the direct Diaspora protocol. If we try and use - * the feed, we'll get duplicates. So don't. - */ - if ($contact['network'] === Protocol::DIASPORA) { - return; - } - - // Without an importer we don't have a user id - so we quit - if (empty($importer)) { - return; - } - - $user = DBA::selectFirst('user', ['nickname'], ['uid' => $importer['uid']]); - - // No user, no nickname, we quit - if (!DBA::isResult($user)) { - return; - } - - $push_url = DI::baseUrl() . '/pubsub/' . $user['nickname'] . '/' . $contact['id']; - - // Use a single verify token, even if multiple hubs - $verify_token = ((strlen($contact['hub-verify'])) ? $contact['hub-verify'] : Strings::getRandomHex()); - - $params= 'hub.mode=' . $hubmode . '&hub.callback=' . urlencode($push_url) . '&hub.topic=' . urlencode($contact['poll']) . '&hub.verify=async&hub.verify_token=' . $verify_token; - - Logger::log('subscribe_to_hub: ' . $hubmode . ' ' . $contact['name'] . ' to hub ' . $url . ' endpoint: ' . $push_url . ' with verifier ' . $verify_token); - - if (!strlen($contact['hub-verify']) || ($contact['hub-verify'] != $verify_token)) { - DBA::update('contact', ['hub-verify' => $verify_token], ['id' => $contact['id']]); - } - - $postResult = Network::post($url, $params); - - Logger::log('subscribe_to_hub: returns: ' . $postResult->getReturnCode(), Logger::DEBUG); - - return; - -} - -function drop_items(array $items) -{ - $uid = 0; - - if (!Session::isAuthenticated()) { - return; - } - - if (!empty($items)) { - foreach ($items as $item) { - $owner = Item::deleteForUser(['id' => $item], local_user()); - - if ($owner && !$uid) { - $uid = $owner; - } - } - } -} - -function drop_item($id, $return = '') -{ - $a = DI::app(); - - // locate item to be deleted - - $fields = ['id', 'uid', 'guid', 'contact-id', 'deleted', 'gravity', 'parent']; - $item = Item::selectFirstForUser(local_user(), $fields, ['id' => $id]); - - if (!DBA::isResult($item)) { - notice(DI::l10n()->t('Item not found.') . EOL); - DI::baseUrl()->redirect('network'); - } - - if ($item['deleted']) { - return 0; - } - - $contact_id = 0; - - // check if logged in user is either the author or owner of this item - if (Session::getRemoteContactID($item['uid']) == $item['contact-id']) { - $contact_id = $item['contact-id']; - } - - if ((local_user() == $item['uid']) || $contact_id) { - // Check if we should do HTML-based delete confirmation - if (!empty($_REQUEST['confirm'])) { - //
can't take arguments in its "action" parameter - // so add any arguments as hidden inputs - $query = explode_querystring(DI::args()->getQueryString()); - $inputs = []; - - foreach ($query['args'] as $arg) { - if (strpos($arg, 'confirm=') === false) { - $arg_parts = explode('=', $arg); - $inputs[] = ['name' => $arg_parts[0], 'value' => $arg_parts[1]]; - } - } - - return Renderer::replaceMacros(Renderer::getMarkupTemplate('confirm.tpl'), [ - '$method' => 'get', - '$message' => DI::l10n()->t('Do you really want to delete this item?'), - '$extra_inputs' => $inputs, - '$confirm' => DI::l10n()->t('Yes'), - '$confirm_url' => $query['base'], - '$confirm_name' => 'confirmed', - '$cancel' => DI::l10n()->t('Cancel'), - ]); - } - // Now check how the user responded to the confirmation query - if (!empty($_REQUEST['canceled'])) { - DI::baseUrl()->redirect('display/' . $item['guid']); - } - - $is_comment = ($item['gravity'] == GRAVITY_COMMENT) ? true : false; - $parentitem = null; - if (!empty($item['parent'])){ - $fields = ['guid']; - $parentitem = Item::selectFirstForUser(local_user(), $fields, ['id' => $item['parent']]); - } - - // delete the item - Item::deleteForUser(['id' => $item['id']], local_user()); - - $return_url = hex2bin($return); - - // removes update_* from return_url to ignore Ajax refresh - $return_url = str_replace("update_", "", $return_url); - - // Check if delete a comment - if ($is_comment) { - // Return to parent guid - if (!empty($parentitem)) { - DI::baseUrl()->redirect('display/' . $parentitem['guid']); - //NOTREACHED - } - // In case something goes wrong - else { - DI::baseUrl()->redirect('network'); - //NOTREACHED - } - } - else { - // if unknown location or deleting top level post called from display - if (empty($return_url) || strpos($return_url, 'display') !== false) { - DI::baseUrl()->redirect('network'); - //NOTREACHED - } else { - DI::baseUrl()->redirect($return_url); - //NOTREACHED - } - } - } else { - notice(DI::l10n()->t('Permission denied.') . EOL); - DI::baseUrl()->redirect('display/' . $item['guid']); - //NOTREACHED - } -} diff --git a/index.php b/index.php index 4857b1f129..baa6818b09 100644 --- a/index.php +++ b/index.php @@ -21,6 +21,8 @@ use Dice\Dice; +$start_time = microtime(true); + if (!file_exists(__DIR__ . '/vendor/autoload.php')) { die('Vendor path not found. Please execute "bin/composer.phar --no-dev install" on the command line in the web root.'); } @@ -34,10 +36,13 @@ $dice = $dice->addRule(Friendica\App\Mode::class, ['call' => [['determineRunMode $a = \Friendica\DI::app(); +\Friendica\DI::mode()->setExecutor(\Friendica\App\Mode::INDEX); + $a->runFrontend( $dice->create(\Friendica\App\Module::class), $dice->create(\Friendica\App\Router::class), $dice->create(\Friendica\Core\PConfig\IPConfig::class), - $dice->create(\Friendica\App\Authentication::class), - $dice->create(\Friendica\App\Page::class) + $dice->create(\Friendica\Security\Authentication::class), + $dice->create(\Friendica\App\Page::class), + $start_time ); diff --git a/library/ASNValue.class.php b/library/ASNValue.class.php deleted file mode 100644 index 7c17d10b40..0000000000 --- a/library/ASNValue.class.php +++ /dev/null @@ -1,169 +0,0 @@ -Tag = $Tag; - $this->Value = $Value; - } - - function Encode() - { - //Write type - $result = chr($this->Tag); - - //Write size - $size = strlen($this->Value); - if ($size < 127) { - //Write size as is - $result .= chr($size); - } - else { - //Prepare length sequence - $sizeBuf = self::IntToBin($size); - - //Write length sequence - $firstByte = 0x80 + strlen($sizeBuf); - $result .= chr($firstByte) . $sizeBuf; - } - - //Write value - $result .= $this->Value; - - return $result; - } - - function Decode(&$Buffer) - { - //Read type - $this->Tag = self::ReadByte($Buffer); - - //Read first byte - $firstByte = self::ReadByte($Buffer); - - if ($firstByte < 127) { - $size = $firstByte; - } - else if ($firstByte > 127) { - $sizeLen = $firstByte - 0x80; - //Read length sequence - $size = self::BinToInt(self::ReadBytes($Buffer, $sizeLen)); - } - else { - throw new Exception("Invalid ASN length value"); - } - - $this->Value = self::ReadBytes($Buffer, $size); - } - - protected static function ReadBytes(&$Buffer, $Length) - { - $result = substr($Buffer, 0, $Length); - $Buffer = substr($Buffer, $Length); - - return $result; - } - - protected static function ReadByte(&$Buffer) - { - return ord(self::ReadBytes($Buffer, 1)); - } - - protected static function BinToInt($Bin) - { - $len = strlen($Bin); - $result = 0; - for ($i=0; $i<$len; $i++) { - $curByte = self::ReadByte($Bin); - $result += $curByte << (($len-$i-1)*8); - } - - return $result; - } - - protected static function IntToBin($Int) - { - $result = ''; - do { - $curByte = $Int % 256; - $result .= chr($curByte); - - $Int = ($Int - $curByte) / 256; - } while ($Int > 0); - - $result = strrev($result); - - return $result; - } - - function SetIntBuffer($Value) - { - if (strlen($Value) > 1) { - $firstByte = ord($Value[0]); - if ($firstByte & 0x80) { //first bit set - $Value = chr(0x00) . $Value; - } - } - - $this->Value = $Value; - } - - function GetIntBuffer() - { - $result = $this->Value; - if (ord($result[0]) == 0x00) { - $result = substr($result, 1); - } - - return $result; - } - - function SetInt($Value) - { - $Value = self::IntToBin($Value); - - $this->SetIntBuffer($Value); - } - - function GetInt() - { - $result = $this->GetIntBuffer(); - $result = self::BinToInt($result); - - return $result; - } - - function SetSequence($Values) - { - $result = ''; - foreach ($Values as $item) { - $result .= $item->Encode(); - } - - $this->Value = $result; - } - - function GetSequence() - { - $result = array(); - $seq = $this->Value; - while (strlen($seq)) { - $val = new ASNValue(); - $val->Decode($seq); - $result[] = $val; - } - - return $result; - } -} diff --git a/library/OAuth1.php b/library/OAuth1.php deleted file mode 100644 index 813234b67b..0000000000 --- a/library/OAuth1.php +++ /dev/null @@ -1,1043 +0,0 @@ -key = $key; - $this->secret = $secret; - $this->callback_url = $callback_url; - } - - function __toString() - { - return "OAuthConsumer[key=$this->key,secret=$this->secret]"; - } -} - -class OAuthToken -{ - // access tokens and request tokens - public $key; - public $secret; - - public $expires; - public $scope; - public $uid; - - /** - * key = the token - * secret = the token secret - * - * @param $key - * @param $secret - */ - function __construct($key, $secret) - { - $this->key = $key; - $this->secret = $secret; - } - - /** - * generates the basic string serialization of a token that a server - * would respond to request_token and access_token calls with - */ - function to_string() - { - return "oauth_token=" . - OAuthUtil::urlencode_rfc3986($this->key) . - "&oauth_token_secret=" . - OAuthUtil::urlencode_rfc3986($this->secret); - } - - function __toString() - { - return $this->to_string(); - } -} - -/** - * A class for implementing a Signature Method - * See section 9 ("Signing Requests") in the spec - */ -abstract class OAuthSignatureMethod -{ - /** - * Needs to return the name of the Signature Method (ie HMAC-SHA1) - * - * @return string - */ - abstract public function get_name(); - - /** - * Build up the signature - * NOTE: The output of this function MUST NOT be urlencoded. - * the encoding is handled in OAuthRequest when the final - * request is serialized - * - * @param OAuthRequest $request - * @param OAuthConsumer $consumer - * @param OAuthToken $token - * @return string - */ - abstract public function build_signature(OAuthRequest $request, OAuthConsumer $consumer, OAuthToken $token = null); - - /** - * Verifies that a given signature is correct - * - * @param OAuthRequest $request - * @param OAuthConsumer $consumer - * @param OAuthToken $token - * @param string $signature - * @return bool - */ - public function check_signature(OAuthRequest $request, OAuthConsumer $consumer, $signature, OAuthToken $token = null) - { - $built = $this->build_signature($request, $consumer, $token); - return ($built == $signature); - } -} - -/** - * The HMAC-SHA1 signature method uses the HMAC-SHA1 signature algorithm as defined in [RFC2104] - * where the Signature Base String is the text and the key is the concatenated values (each first - * encoded per Parameter Encoding) of the Consumer Secret and Token Secret, separated by an '&' - * character (ASCII code 38) even if empty. - * - Chapter 9.2 ("HMAC-SHA1") - */ -class OAuthSignatureMethod_HMAC_SHA1 extends OAuthSignatureMethod -{ - function get_name() - { - return "HMAC-SHA1"; - } - - /** - * @param OAuthRequest $request - * @param OAuthConsumer $consumer - * @param OAuthToken $token - * @return string - */ - public function build_signature(OAuthRequest $request, OAuthConsumer $consumer, OAuthToken $token = null) - { - $base_string = $request->get_signature_base_string(); - $request->base_string = $base_string; - - $key_parts = array( - $consumer->secret, - ($token) ? $token->secret : "" - ); - - $key_parts = OAuthUtil::urlencode_rfc3986($key_parts); - $key = implode('&', $key_parts); - - - $r = base64_encode(hash_hmac('sha1', $base_string, $key, true)); - return $r; - } -} - -/** - * The PLAINTEXT method does not provide any security protection and SHOULD only be used - * over a secure channel such as HTTPS. It does not use the Signature Base String. - * - Chapter 9.4 ("PLAINTEXT") - */ -class OAuthSignatureMethod_PLAINTEXT extends OAuthSignatureMethod -{ - public function get_name() - { - return "PLAINTEXT"; - } - - /** - * oauth_signature is set to the concatenated encoded values of the Consumer Secret and - * Token Secret, separated by a '&' character (ASCII code 38), even if either secret is - * empty. The result MUST be encoded again. - * - Chapter 9.4.1 ("Generating Signatures") - * - * Please note that the second encoding MUST NOT happen in the SignatureMethod, as - * OAuthRequest handles this! - * - * @param $request - * @param $consumer - * @param $token - * @return string - */ - public function build_signature(OAuthRequest $request, OAuthConsumer $consumer, OAuthToken $token = null) - { - $key_parts = array( - $consumer->secret, - ($token) ? $token->secret : "" - ); - - $key_parts = OAuthUtil::urlencode_rfc3986($key_parts); - $key = implode('&', $key_parts); - $request->base_string = $key; - - return $key; - } -} - -/** - * The RSA-SHA1 signature method uses the RSASSA-PKCS1-v1_5 signature algorithm as defined in - * [RFC3447] section 8.2 (more simply known as PKCS#1), using SHA-1 as the hash function for - * EMSA-PKCS1-v1_5. It is assumed that the Consumer has provided its RSA public key in a - * verified way to the Service Provider, in a manner which is beyond the scope of this - * specification. - * - Chapter 9.3 ("RSA-SHA1") - */ -abstract class OAuthSignatureMethod_RSA_SHA1 extends OAuthSignatureMethod -{ - public function get_name() - { - return "RSA-SHA1"; - } - - // Up to the SP to implement this lookup of keys. Possible ideas are: - // (1) do a lookup in a table of trusted certs keyed off of consumer - // (2) fetch via http using a url provided by the requester - // (3) some sort of specific discovery code based on request - // - // Either way should return a string representation of the certificate - protected abstract function fetch_public_cert(&$request); - - // Up to the SP to implement this lookup of keys. Possible ideas are: - // (1) do a lookup in a table of trusted certs keyed off of consumer - // - // Either way should return a string representation of the certificate - protected abstract function fetch_private_cert(&$request); - - public function build_signature(OAuthRequest $request, OAuthConsumer $consumer, OAuthToken $token = null) - { - $base_string = $request->get_signature_base_string(); - $request->base_string = $base_string; - - // Fetch the private key cert based on the request - $cert = $this->fetch_private_cert($request); - - // Pull the private key ID from the certificate - $privatekeyid = openssl_get_privatekey($cert); - - // Sign using the key - openssl_sign($base_string, $signature, $privatekeyid); - - // Release the key resource - openssl_free_key($privatekeyid); - - return base64_encode($signature); - } - - public function check_signature(OAuthRequest $request, OAuthConsumer $consumer, $signature, OAuthToken $token = null) - { - $decoded_sig = base64_decode($signature); - - $base_string = $request->get_signature_base_string(); - - // Fetch the public key cert based on the request - $cert = $this->fetch_public_cert($request); - - // Pull the public key ID from the certificate - $publickeyid = openssl_get_publickey($cert); - - // Check the computed signature against the one passed in the query - $ok = openssl_verify($base_string, $decoded_sig, $publickeyid); - - // Release the key resource - openssl_free_key($publickeyid); - - return $ok == 1; - } -} - -class OAuthRequest -{ - private $parameters; - private $http_method; - private $http_url; - // for debug purposes - public $base_string; - public static $version = '1.0'; - public static $POST_INPUT = 'php://input'; - - function __construct($http_method, $http_url, $parameters = NULL) - { - @$parameters or $parameters = array(); - $parameters = array_merge(OAuthUtil::parse_parameters(parse_url($http_url, PHP_URL_QUERY)), $parameters); - $this->parameters = $parameters; - $this->http_method = $http_method; - $this->http_url = $http_url; - } - - - /** - * attempt to build up a request from what was passed to the server - * - * @param string|null $http_method - * @param string|null $http_url - * @param string|null $parameters - * @return OAuthRequest - */ - public static function from_request($http_method = NULL, $http_url = NULL, $parameters = NULL) - { - $scheme = (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] != "on") - ? 'http' - : 'https'; - @$http_url or $http_url = $scheme . - '://' . $_SERVER['HTTP_HOST'] . - ':' . - $_SERVER['SERVER_PORT'] . - $_SERVER['REQUEST_URI']; - @$http_method or $http_method = $_SERVER['REQUEST_METHOD']; - - // We weren't handed any parameters, so let's find the ones relevant to - // this request. - // If you run XML-RPC or similar you should use this to provide your own - // parsed parameter-list - if (!$parameters) { - // Find request headers - $request_headers = OAuthUtil::get_headers(); - - // Parse the query-string to find GET parameters - $parameters = OAuthUtil::parse_parameters($_SERVER['QUERY_STRING']); - - // It's a POST request of the proper content-type, so parse POST - // parameters and add those overriding any duplicates from GET - if ( - $http_method == "POST" - && @strstr( - $request_headers["Content-Type"], - "application/x-www-form-urlencoded" - ) - ) { - $post_data = OAuthUtil::parse_parameters( - file_get_contents(self::$POST_INPUT) - ); - $parameters = array_merge($parameters, $post_data); - } - - // We have a Authorization-header with OAuth data. Parse the header - // and add those overriding any duplicates from GET or POST - if (@substr($request_headers['Authorization'], 0, 6) == "OAuth ") { - $header_parameters = OAuthUtil::split_header( - $request_headers['Authorization'] - ); - $parameters = array_merge($parameters, $header_parameters); - } - } - // fix for friendica redirect system - - $http_url = substr($http_url, 0, strpos($http_url, $parameters['pagename']) + strlen($parameters['pagename'])); - unset($parameters['pagename']); - - return new OAuthRequest($http_method, $http_url, $parameters); - } - - /** - * pretty much a helper function to set up the request - * - * @param OAuthConsumer $consumer - * @param OAuthToken $token - * @param string $http_method - * @param string $http_url - * @param array|null $parameters - * @return OAuthRequest - */ - public static function from_consumer_and_token(OAuthConsumer $consumer, $http_method, $http_url, array $parameters = null, OAuthToken $token = null) - { - @$parameters or $parameters = array(); - $defaults = array( - "oauth_version" => OAuthRequest::$version, - "oauth_nonce" => OAuthRequest::generate_nonce(), - "oauth_timestamp" => OAuthRequest::generate_timestamp(), - "oauth_consumer_key" => $consumer->key - ); - if ($token) - $defaults['oauth_token'] = $token->key; - - $parameters = array_merge($defaults, $parameters); - - return new OAuthRequest($http_method, $http_url, $parameters); - } - - public function set_parameter($name, $value, $allow_duplicates = true) - { - if ($allow_duplicates && isset($this->parameters[$name])) { - // We have already added parameter(s) with this name, so add to the list - if (is_scalar($this->parameters[$name])) { - // This is the first duplicate, so transform scalar (string) - // into an array so we can add the duplicates - $this->parameters[$name] = array($this->parameters[$name]); - } - - $this->parameters[$name][] = $value; - } else { - $this->parameters[$name] = $value; - } - } - - public function get_parameter($name) - { - return isset($this->parameters[$name]) ? $this->parameters[$name] : null; - } - - public function get_parameters() - { - return $this->parameters; - } - - public function unset_parameter($name) - { - unset($this->parameters[$name]); - } - - /** - * The request parameters, sorted and concatenated into a normalized string. - * - * @return string - */ - public function get_signable_parameters() - { - // Grab all parameters - $params = $this->parameters; - - // Remove oauth_signature if present - // Ref: Spec: 9.1.1 ("The oauth_signature parameter MUST be excluded.") - if (isset($params['oauth_signature'])) { - unset($params['oauth_signature']); - } - - return OAuthUtil::build_http_query($params); - } - - /** - * Returns the base string of this request - * - * The base string defined as the method, the url - * and the parameters (normalized), each urlencoded - * and the concated with &. - */ - public function get_signature_base_string() - { - $parts = array( - $this->get_normalized_http_method(), - $this->get_normalized_http_url(), - $this->get_signable_parameters() - ); - - $parts = OAuthUtil::urlencode_rfc3986($parts); - - return implode('&', $parts); - } - - /** - * just uppercases the http method - */ - public function get_normalized_http_method() - { - return strtoupper($this->http_method); - } - - /** - * parses the url and rebuilds it to be - * scheme://host/path - */ - public function get_normalized_http_url() - { - $parts = parse_url($this->http_url); - - $port = @$parts['port']; - $scheme = $parts['scheme']; - $host = $parts['host']; - $path = @$parts['path']; - - $port or $port = ($scheme == 'https') ? '443' : '80'; - - if (($scheme == 'https' && $port != '443') - || ($scheme == 'http' && $port != '80') - ) { - $host = "$host:$port"; - } - return "$scheme://$host$path"; - } - - /** - * builds a url usable for a GET request - */ - public function to_url() - { - $post_data = $this->to_postdata(); - $out = $this->get_normalized_http_url(); - if ($post_data) { - $out .= '?' . $post_data; - } - return $out; - } - - /** - * builds the data one would send in a POST request - * - * @param bool $raw - * @return array|string - */ - public function to_postdata(bool $raw = false) - { - if ($raw) - return $this->parameters; - else - return OAuthUtil::build_http_query($this->parameters); - } - - /** - * builds the Authorization: header - * - * @param string|null $realm - * @return string - * @throws OAuthException - */ - public function to_header($realm = null) - { - $first = true; - if ($realm) { - $out = 'Authorization: OAuth realm="' . OAuthUtil::urlencode_rfc3986($realm) . '"'; - $first = false; - } else - $out = 'Authorization: OAuth'; - - foreach ($this->parameters as $k => $v) { - if (substr($k, 0, 5) != "oauth") continue; - if (is_array($v)) { - throw new OAuthException('Arrays not supported in headers'); - } - $out .= ($first) ? ' ' : ','; - $out .= OAuthUtil::urlencode_rfc3986($k) . - '="' . - OAuthUtil::urlencode_rfc3986($v) . - '"'; - $first = false; - } - return $out; - } - - public function __toString() - { - return $this->to_url(); - } - - - public function sign_request(OAuthSignatureMethod $signature_method, $consumer, $token) - { - $this->set_parameter( - "oauth_signature_method", - $signature_method->get_name(), - false - ); - $signature = $this->build_signature($signature_method, $consumer, $token); - $this->set_parameter("oauth_signature", $signature, false); - } - - public function build_signature(OAuthSignatureMethod $signature_method, $consumer, $token) - { - $signature = $signature_method->build_signature($this, $consumer, $token); - return $signature; - } - - /** - * util function: current timestamp - */ - private static function generate_timestamp() - { - return time(); - } - - /** - * util function: current nonce - */ - private static function generate_nonce() - { - return Friendica\Util\Strings::getRandomHex(32); - } -} - -class OAuthServer -{ - protected $timestamp_threshold = 300; // in seconds, five minutes - protected $version = '1.0'; // hi blaine - /** @var OAuthSignatureMethod[] */ - protected $signature_methods = array(); - - /** @var FKOAuthDataStore */ - protected $data_store; - - function __construct(FKOAuthDataStore $data_store) - { - $this->data_store = $data_store; - } - - public function add_signature_method(OAuthSignatureMethod $signature_method) - { - $this->signature_methods[$signature_method->get_name()] = - $signature_method; - } - - // high level functions - - /** - * process a request_token request - * returns the request token on success - * - * @param OAuthRequest $request - * @return OAuthToken|null - * @throws OAuthException - */ - public function fetch_request_token(OAuthRequest $request) - { - $this->get_version($request); - - $consumer = $this->get_consumer($request); - - // no token required for the initial token request - $token = NULL; - - $this->check_signature($request, $consumer, $token); - - // Rev A change - $callback = $request->get_parameter('oauth_callback'); - $new_token = $this->data_store->new_request_token($consumer, $callback); - - return $new_token; - } - - /** - * process an access_token request - * returns the access token on success - * - * @param OAuthRequest $request - * @return object - * @throws OAuthException - */ - public function fetch_access_token(OAuthRequest $request) - { - $this->get_version($request); - - $consumer = $this->get_consumer($request); - - // requires authorized request token - $token = $this->get_token($request, $consumer, "request"); - - $this->check_signature($request, $consumer, $token); - - // Rev A change - $verifier = $request->get_parameter('oauth_verifier'); - $new_token = $this->data_store->new_access_token($token, $consumer, $verifier); - - return $new_token; - } - - /** - * verify an api call, checks all the parameters - * - * @param OAuthRequest $request - * @return array - * @throws OAuthException - */ - public function verify_request(OAuthRequest $request) - { - $this->get_version($request); - $consumer = $this->get_consumer($request); - $token = $this->get_token($request, $consumer, "access"); - $this->check_signature($request, $consumer, $token); - return [$consumer, $token]; - } - - // Internals from here - - /** - * version 1 - * - * @param OAuthRequest $request - * @return string - * @throws OAuthException - */ - private function get_version(OAuthRequest $request) - { - $version = $request->get_parameter("oauth_version"); - if (!$version) { - // Service Providers MUST assume the protocol version to be 1.0 if this parameter is not present. - // Chapter 7.0 ("Accessing Protected Ressources") - $version = '1.0'; - } - if ($version !== $this->version) { - throw new OAuthException("OAuth version '$version' not supported"); - } - return $version; - } - - /** - * figure out the signature with some defaults - * - * @param OAuthRequest $request - * @return OAuthSignatureMethod - * @throws OAuthException - */ - private function get_signature_method(OAuthRequest $request) - { - $signature_method = - @$request->get_parameter("oauth_signature_method"); - - if (!$signature_method) { - // According to chapter 7 ("Accessing Protected Ressources") the signature-method - // parameter is required, and we can't just fallback to PLAINTEXT - throw new OAuthException('No signature method parameter. This parameter is required'); - } - - if (!in_array( - $signature_method, - array_keys($this->signature_methods) - )) { - throw new OAuthException( - "Signature method '$signature_method' not supported " . - "try one of the following: " . - implode(", ", array_keys($this->signature_methods)) - ); - } - return $this->signature_methods[$signature_method]; - } - - /** - * try to find the consumer for the provided request's consumer key - * - * @param OAuthRequest $request - * @return OAuthConsumer - * @throws OAuthException - */ - private function get_consumer(OAuthRequest $request) - { - $consumer_key = @$request->get_parameter("oauth_consumer_key"); - if (!$consumer_key) { - throw new OAuthException("Invalid consumer key"); - } - - $consumer = $this->data_store->lookup_consumer($consumer_key); - if (!$consumer) { - throw new OAuthException("Invalid consumer"); - } - - return $consumer; - } - - /** - * try to find the token for the provided request's token key - * - * @param OAuthRequest $request - * @param $consumer - * @param string $token_type - * @return OAuthToken|null - * @throws OAuthException - */ - private function get_token(OAuthRequest &$request, $consumer, $token_type = "access") - { - $token_field = @$request->get_parameter('oauth_token'); - $token = $this->data_store->lookup_token( - $consumer, - $token_type, - $token_field - ); - if (!$token) { - throw new OAuthException("Invalid $token_type token: $token_field"); - } - return $token; - } - - /** - * all-in-one function to check the signature on a request - * should guess the signature method appropriately - * - * @param OAuthRequest $request - * @param OAuthConsumer $consumer - * @param OAuthToken|null $token - * @throws OAuthException - */ - private function check_signature(OAuthRequest $request, OAuthConsumer $consumer, OAuthToken $token = null) - { - // this should probably be in a different method - $timestamp = @$request->get_parameter('oauth_timestamp'); - $nonce = @$request->get_parameter('oauth_nonce'); - - $this->check_timestamp($timestamp); - $this->check_nonce($consumer, $token, $nonce, $timestamp); - - $signature_method = $this->get_signature_method($request); - - $signature = $request->get_parameter('oauth_signature'); - $valid_sig = $signature_method->check_signature( - $request, - $consumer, - $signature, - $token - ); - - if (!$valid_sig) { - throw new OAuthException("Invalid signature"); - } - } - - /** - * check that the timestamp is new enough - * - * @param int $timestamp - * @throws OAuthException - */ - private function check_timestamp($timestamp) - { - if (!$timestamp) - throw new OAuthException( - 'Missing timestamp parameter. The parameter is required' - ); - - // verify that timestamp is recentish - $now = time(); - if (abs($now - $timestamp) > $this->timestamp_threshold) { - throw new OAuthException( - "Expired timestamp, yours $timestamp, ours $now" - ); - } - } - - /** - * check that the nonce is not repeated - * - * @param OAuthConsumer $consumer - * @param OAuthToken $token - * @param string $nonce - * @param int $timestamp - * @throws OAuthException - */ - private function check_nonce(OAuthConsumer $consumer, OAuthToken $token, $nonce, int $timestamp) - { - if (!$nonce) - throw new OAuthException( - 'Missing nonce parameter. The parameter is required' - ); - - // verify that the nonce is uniqueish - $found = $this->data_store->lookup_nonce( - $consumer, - $token, - $nonce, - $timestamp - ); - if ($found) { - throw new OAuthException("Nonce already used: $nonce"); - } - } -} - -class OAuthDataStore -{ - function lookup_consumer($consumer_key) - { - // implement me - } - - function lookup_token(OAuthConsumer $consumer, $token_type, $token_id) - { - // implement me - } - - function lookup_nonce(OAuthConsumer $consumer, OAuthToken $token, $nonce, int $timestamp) - { - // implement me - } - - function new_request_token(OAuthConsumer $consumer, $callback = null) - { - // return a new token attached to this consumer - } - - function new_access_token(OAuthToken $token, OAuthConsumer $consumer, $verifier = null) - { - // return a new access token attached to this consumer - // for the user associated with this token if the request token - // is authorized - // should also invalidate the request token - } -} - -class OAuthUtil -{ - public static function urlencode_rfc3986($input) - { - if (is_array($input)) { - return array_map(['OAuthUtil', 'urlencode_rfc3986'], $input); - } else if (is_scalar($input)) { - return str_replace( - '+', - ' ', - str_replace('%7E', '~', rawurlencode($input)) - ); - } else { - return ''; - } - } - - - // This decode function isn't taking into consideration the above - // modifications to the encoding process. However, this method doesn't - // seem to be used anywhere so leaving it as is. - public static function urldecode_rfc3986($string) - { - return urldecode($string); - } - - // Utility function for turning the Authorization: header into - // parameters, has to do some unescaping - // Can filter out any non-oauth parameters if needed (default behaviour) - public static function split_header($header, $only_allow_oauth_parameters = true) - { - $pattern = '/(([-_a-z]*)=("([^"]*)"|([^,]*)),?)/'; - $offset = 0; - $params = []; - while (preg_match($pattern, $header, $matches, PREG_OFFSET_CAPTURE, $offset) > 0) { - $match = $matches[0]; - $header_name = $matches[2][0]; - $header_content = (isset($matches[5])) ? $matches[5][0] : $matches[4][0]; - if (preg_match('/^oauth_/', $header_name) || !$only_allow_oauth_parameters) { - $params[$header_name] = OAuthUtil::urldecode_rfc3986($header_content); - } - $offset = $match[1] + strlen($match[0]); - } - - if (isset($params['realm'])) { - unset($params['realm']); - } - - return $params; - } - - // helper to try to sort out headers for people who aren't running apache - public static function get_headers() - { - if (function_exists('apache_request_headers')) { - // we need this to get the actual Authorization: header - // because apache tends to tell us it doesn't exist - $headers = apache_request_headers(); - - // sanitize the output of apache_request_headers because - // we always want the keys to be Cased-Like-This and arh() - // returns the headers in the same case as they are in the - // request - $out = []; - foreach ($headers as $key => $value) { - $key = str_replace( - " ", - "-", - ucwords(strtolower(str_replace("-", " ", $key))) - ); - $out[$key] = $value; - } - } else { - // otherwise we don't have apache and are just going to have to hope - // that $_SERVER actually contains what we need - $out = []; - if (isset($_SERVER['CONTENT_TYPE'])) - $out['Content-Type'] = $_SERVER['CONTENT_TYPE']; - if (isset($_ENV['CONTENT_TYPE'])) - $out['Content-Type'] = $_ENV['CONTENT_TYPE']; - - foreach ($_SERVER as $key => $value) { - if (substr($key, 0, 5) == "HTTP_") { - // this is chaos, basically it is just there to capitalize the first - // letter of every word that is not an initial HTTP and strip HTTP - // code from przemek - $key = str_replace( - " ", - "-", - ucwords(strtolower(str_replace("_", " ", substr($key, 5)))) - ); - $out[$key] = $value; - } - } - } - return $out; - } - - // This function takes a input like a=b&a=c&d=e and returns the parsed - // parameters like this - // array('a' => array('b','c'), 'd' => 'e') - public static function parse_parameters($input) - { - if (!isset($input) || !$input) return array(); - - $pairs = explode('&', $input); - - $parsed_parameters = []; - foreach ($pairs as $pair) { - $split = explode('=', $pair, 2); - $parameter = OAuthUtil::urldecode_rfc3986($split[0]); - $value = isset($split[1]) ? OAuthUtil::urldecode_rfc3986($split[1]) : ''; - - if (isset($parsed_parameters[$parameter])) { - // We have already recieved parameter(s) with this name, so add to the list - // of parameters with this name - - if (is_scalar($parsed_parameters[$parameter])) { - // This is the first duplicate, so transform scalar (string) into an array - // so we can add the duplicates - $parsed_parameters[$parameter] = [$parsed_parameters[$parameter]]; - } - - $parsed_parameters[$parameter][] = $value; - } else { - $parsed_parameters[$parameter] = $value; - } - } - return $parsed_parameters; - } - - public static function build_http_query($params) - { - if (!$params) return ''; - - // Urlencode both keys and values - $keys = OAuthUtil::urlencode_rfc3986(array_keys($params)); - $values = OAuthUtil::urlencode_rfc3986(array_values($params)); - $params = array_combine($keys, $values); - - // Parameters are sorted by name, using lexicographical byte value ordering. - // Ref: Spec: 9.1.1 (1) - uksort($params, 'strcmp'); - - $pairs = []; - foreach ($params as $parameter => $value) { - if (is_array($value)) { - // If two or more parameters share the same name, they are sorted by their value - // Ref: Spec: 9.1.1 (1) - natsort($value); - foreach ($value as $duplicate_value) { - $pairs[] = $parameter . '=' . $duplicate_value; - } - } else { - $pairs[] = $parameter . '=' . $value; - } - } - // For each parameter, the name is separated from the corresponding value by an '=' character (ASCII code 61) - // Each name-value pair is separated by an '&' character (ASCII code 38) - return implode('&', $pairs); - } -} diff --git a/library/asn1.php b/library/asn1.php deleted file mode 100644 index cda96b6c8a..0000000000 --- a/library/asn1.php +++ /dev/null @@ -1,292 +0,0 @@ - 0x00, - 'ASN_APPLICATION' => 0x40, - 'ASN_CONTEXT' => 0x80, - 'ASN_PRIVATE' => 0xC0, - - 'ASN_PRIMITIVE' => 0x00, - 'ASN_CONSTRUCTOR' => 0x20, - - 'ASN_LONG_LEN' => 0x80, - 'ASN_EXTENSION_ID' => 0x1F, - 'ASN_BIT' => 0x80, - ); - - public static $ASN_TYPES = array( - 1 => 'ASN_BOOLEAN', - 2 => 'ASN_INTEGER', - 3 => 'ASN_BIT_STR', - 4 => 'ASN_OCTET_STR', - 5 => 'ASN_NULL', - 6 => 'ASN_OBJECT_ID', - 9 => 'ASN_REAL', - 10 => 'ASN_ENUMERATED', - 13 => 'ASN_RELATIVE_OID', - 48 => 'ASN_SEQUENCE', - 49 => 'ASN_SET', - 19 => 'ASN_PRINT_STR', - 22 => 'ASN_IA5_STR', - 23 => 'ASN_UTC_TIME', - 24 => 'ASN_GENERAL_TIME', - ); - - function __construct($v = false) - { - if (false !== $v) { - $this->asnData = $v; - if (is_array($this->asnData)) { - foreach ($this->asnData as $key => $value) { - if (is_object($value)) { - $this->asnData[$key]->setParent($this); - } - } - } else { - if (is_object($this->asnData)) { - $this->asnData->setParent($this); - } - } - } - } - - public function setParent($parent) - { - if (false !== $parent) { - $this->parent = $parent; - } - } - - /** - * This function will take the markers and types arrays and - * dynamically generate classes that extend this class for each one, - * and also define constants for them. - */ - public static function generateSubclasses() - { - define('ASN_BASE', 0); - foreach (self::$ASN_MARKERS as $name => $bit) - self::makeSubclass($name, $bit); - foreach (self::$ASN_TYPES as $bit => $name) - self::makeSubclass($name, $bit); - } - - /** - * Helper function for generateSubclasses() - */ - public static function makeSubclass($name, $bit) - { - define($name, $bit); - eval("class ".$name." extends ASN_BASE {}"); - } - - /** - * This function reset's the internal cursor used for value iteration. - */ - public function reset() - { - $this->cursor = 0; - } - - /** - * This function catches calls to get the value for the type, typeName, value, values, and data - * from the object. For type calls we just return the class name or the value of the constant that - * is named the same as the class. - */ - public function __get($name) - { - if ('type' == $name) { - // int flag of the data type - return constant(get_class($this)); - } elseif ('typeName' == $name) { - // name of the data type - return get_class($this); - } elseif ('value' == $name) { - // will always return one value and can be iterated over with: - // while ($v = $obj->value) { ... - // because $this->asnData["invalid key"] will return false - return is_array($this->asnData) ? $this->asnData[$this->cursor++] : $this->asnData; - } elseif ('values' == $name) { - // will always return an array - return is_array($this->asnData) ? $this->asnData : array($this->asnData); - } elseif ('data' == $name) { - // will always return the raw data - return $this->asnData; - } - } - - /** - * Parse an ASN.1 binary string. - * - * This function takes a binary ASN.1 string and parses it into it's respective - * pieces and returns it. It can optionally stop at any depth. - * - * @param string $string The binary ASN.1 String - * @param int $level The current parsing depth level - * @param int $maxLevel The max parsing depth level - * @return ASN_BASE The array representation of the ASN.1 data contained in $string - */ - public static function parseASNString($string=false, $level=1, $maxLevels=false){ - if (!class_exists('ASN_UNIVERSAL')) - self::generateSubclasses(); - if ($level>$maxLevels && $maxLevels) - return array(new ASN_BASE($string)); - $parsed = array(); - $endLength = strlen($string); - $bigLength = $length = $type = $dtype = $p = 0; - while ($p<$endLength){ - $type = ord($string[$p++]); - $dtype = ($type & 192) >> 6; - if ($type==0){ // if we are type 0, just continue - } else { - $length = ord($string[$p++]); - if (($length & ASN_LONG_LEN)==ASN_LONG_LEN){ - $tempLength = 0; - for ($x=0; $x<($length & (ASN_LONG_LEN-1)); $x++){ - $tempLength = @ord($string[$p++]) + ($tempLength * 256); - } - $length = $tempLength; - } - $data = substr($string, $p, intval($length)); - $parsed[] = self::parseASNData($type, $data, $level, $maxLevels); - $p = $p + $length; - } - } - return $parsed; - } - - /** - * Parse an ASN.1 field value. - * - * This function takes a binary ASN.1 value and parses it according to it's specified type - * - * @param int $type The type of data being provided - * @param string $data The raw binary data string - * @param int $level The current parsing depth - * @param int $maxLevels The max parsing depth - * @return mixed The data that was parsed from the raw binary data string - */ - public static function parseASNData($type, $data, $level, $maxLevels){ - $type = $type%50; // strip out context - switch ($type){ - default: - return new ASN_BASE($data); - case ASN_BOOLEAN: - return new ASN_BOOLEAN((bool)$data); - case ASN_INTEGER: - return new ASN_INTEGER(strtr(base64_encode($data),'+/','-_')); - case ASN_BIT_STR: - return new ASN_BIT_STR(self::parseASNString($data, $level+1, $maxLevels)); - case ASN_OCTET_STR: - return new ASN_OCTET_STR($data); - case ASN_NULL: - return new ASN_NULL(null); - case ASN_REAL: - return new ASN_REAL($data); - case ASN_ENUMERATED: - return new ASN_ENUMERATED(self::parseASNString($data, $level+1, $maxLevels)); - case ASN_RELATIVE_OID: // I don't really know how this works and don't have an example :-) - // so, lets just return it ... - return new ASN_RELATIVE_OID($data); - case ASN_SEQUENCE: - return new ASN_SEQUENCE(self::parseASNString($data, $level+1, $maxLevels)); - case ASN_SET: - return new ASN_SET(self::parseASNString($data, $level+1, $maxLevels)); - case ASN_PRINT_STR: - return new ASN_PRINT_STR($data); - case ASN_IA5_STR: - return new ASN_IA5_STR($data); - case ASN_UTC_TIME: - return new ASN_UTC_TIME($data); - case ASN_GENERAL_TIME: - return new ASN_GENERAL_TIME($data); - case ASN_OBJECT_ID: - return new ASN_OBJECT_ID(self::parseOID($data)); - } - } - - /** - * Parse an ASN.1 OID value. - * - * This takes the raw binary string that represents an OID value and parses it into its - * dot notation form. example - 1.2.840.113549.1.1.5 - * look up OID's here: http://www.oid-info.com/ - * (the multi-byte OID section can be done in a more efficient way, I will fix it later) - * - * @param string $data The raw binary data string - * @return string The OID contained in $data - */ - public static function parseOID($string){ - $ret = floor(ord($string[0])/40)."."; - $ret .= (ord($string[0]) % 40); - $build = array(); - $cs = 0; - - for ($i=1; $i127){ - $build[] = ord($string[$i])-ASN_BIT; - } elseif ($build){ - // do the build here for multibyte values - $build[] = ord($string[$i])-ASN_BIT; - // you know, it seems there should be a better way to do this... - $build = array_reverse($build); - $num = 0; - for ($x=0; $x> $x)) * $mult; - } else { - $value = ((($build[$x] & (ASN_BIT-1)) >> $x) ^ ($build[$x+1] << (7 - $x) & 255)) * $mult; - } - $num += $value; - } - $ret .= ".".$num; - $build = array(); // start over - } else { - $ret .= ".".$v; - $build = array(); - } - } - return $ret; - } - - public static function printASN($x, $indent=''){ - if (is_object($x)) { - echo $indent.$x->typeName."\n"; - if (ASN_NULL == $x->type) return; - if (is_array($x->data)) { - while ($d = $x->value) { - echo self::printASN($d, $indent.'. '); - } - $x->reset(); - } else { - echo self::printASN($x->data, $indent.'. '); - } - } elseif (is_array($x)) { - foreach ($x as $d) { - echo self::printASN($d, $indent); - } - } else { - if (preg_match('/[^[:print:]]/', $x)) // if we have non-printable characters that would - $x = base64_encode($x); // mess up the console, then print the base64 of them... - echo $indent.$x."\n"; - } - } - - -} - diff --git a/mod/api.php b/mod/api.php index 47a809497e..c7dfe7965d 100644 --- a/mod/api.php +++ b/mod/api.php @@ -24,6 +24,8 @@ use Friendica\Core\Renderer; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Module\Security\Login; +use Friendica\Security\OAuth1\OAuthRequest; +use Friendica\Security\OAuth1\OAuthUtil; require_once __DIR__ . '/../include/api.php'; @@ -47,12 +49,12 @@ function oauth_get_client(OAuthRequest $request) function api_post(App $a) { if (!local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } if (count($a->user) && !empty($a->user['uid']) && $a->user['uid'] != local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } } @@ -107,7 +109,7 @@ function api_content(App $a) if (!local_user()) { /// @TODO We need login form to redirect to this page - notice(DI::l10n()->t('Please login to continue.') . EOL); + notice(DI::l10n()->t('Please login to continue.')); return Login::form(DI::args()->getQueryString(), false, $request->get_parameters()); } //FKOAuth1::loginUser(4); diff --git a/mod/cal.php b/mod/cal.php index edcbaa7e8f..2992f29e5c 100644 --- a/mod/cal.php +++ b/mod/cal.php @@ -24,7 +24,6 @@ */ use Friendica\App; -use Friendica\Content\Feature; use Friendica\Content\Nav; use Friendica\Content\Text\BBCode; use Friendica\Content\Widget; @@ -37,17 +36,18 @@ use Friendica\Model\Event; use Friendica\Model\Item; use Friendica\Model\Profile; use Friendica\Module\BaseProfile; +use Friendica\Network\HTTPException; use Friendica\Util\DateTimeFormat; use Friendica\Util\Temporal; function cal_init(App $a) { if (DI::config()->get('system', 'block_public') && !Session::isAuthenticated()) { - throw new \Friendica\Network\HTTPException\ForbiddenException(DI::l10n()->t('Access denied.')); + throw new HTTPException\ForbiddenException(DI::l10n()->t('Access denied.')); } if ($a->argc < 2) { - throw new \Friendica\Network\HTTPException\ForbiddenException(DI::l10n()->t('Access denied.')); + throw new HTTPException\ForbiddenException(DI::l10n()->t('Access denied.')); } Nav::setSelected('events'); @@ -55,7 +55,7 @@ function cal_init(App $a) $nick = $a->argv[1]; $user = DBA::selectFirst('user', [], ['nickname' => $nick, 'blocked' => false]); if (!DBA::isResult($user)) { - throw new \Friendica\Network\HTTPException\NotFoundException(); + throw new HTTPException\NotFoundException(); } $a->data['user'] = $user; @@ -67,18 +67,21 @@ function cal_init(App $a) return; } - $profile = Profile::getByNickname($nick, $a->profile_uid); + $a->profile = Profile::getByNickname($nick, $a->profile_uid); + if (empty($a->profile)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found.')); + } - $account_type = Contact::getAccountType($profile); + $account_type = Contact::getAccountType($a->profile); $tpl = Renderer::getMarkupTemplate('widget/vcard.tpl'); $vcard_widget = Renderer::replaceMacros($tpl, [ - '$name' => $profile['name'], - '$photo' => $profile['photo'], - '$addr' => $profile['addr'] ?: '', + '$name' => $a->profile['name'], + '$photo' => $a->profile['photo'], + '$addr' => $a->profile['addr'] ?: '', '$account_type' => $account_type, - '$about' => BBCode::convert($profile['about'] ?: ''), + '$about' => BBCode::convert($a->profile['about']), ]); $cal_widget = Widget\CalendarExport::getHTML(); @@ -100,6 +103,11 @@ function cal_content(App $a) // get the translation strings for the callendar $i18n = Event::getStrings(); + DI::page()->registerStylesheet('view/asset/fullcalendar/dist/fullcalendar.min.css'); + DI::page()->registerStylesheet('view/asset/fullcalendar/dist/fullcalendar.print.min.css', 'print'); + DI::page()->registerFooterScript('view/asset/moment/min/moment-with-locales.min.js'); + DI::page()->registerFooterScript('view/asset/fullcalendar/dist/fullcalendar.min.js'); + $htpl = Renderer::getMarkupTemplate('event_head.tpl'); DI::page()['htmlhead'] .= Renderer::replaceMacros($htpl, [ '$module_url' => '/cal/' . $a->data['user']['nickname'], @@ -121,6 +129,9 @@ function cal_content(App $a) // Setup permissions structures $owner_uid = intval($a->data['user']['uid']); $nick = $a->data['user']['nickname']; + if (empty($a->profile)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found.')); + } $contact_id = Session::getRemoteContactID($a->profile['uid']); @@ -129,7 +140,7 @@ function cal_content(App $a) $is_owner = local_user() == $a->profile['uid']; if ($a->profile['hidewall'] && !$is_owner && !$remote_contact) { - notice(DI::l10n()->t('Access to this profile has been restricted.') . EOL); + notice(DI::l10n()->t('Access to this profile has been restricted.')); return; } @@ -287,13 +298,6 @@ function cal_content(App $a) return; } - // Test permissions - // Respect the export feature setting for all other /cal pages if it's not the own profile - if ((local_user() !== $owner_uid) && !Feature::isEnabled($owner_uid, "export_calendar")) { - notice(DI::l10n()->t('Permission denied.') . EOL); - DI::baseUrl()->redirect('cal/' . $nick); - } - // Get the export data by uid $evexport = Event::exportListByUserId($owner_uid, $format); diff --git a/mod/common.php b/mod/common.php deleted file mode 100644 index 0ccad42387..0000000000 --- a/mod/common.php +++ /dev/null @@ -1,170 +0,0 @@ -. - * - */ - -use Friendica\App; -use Friendica\Content\ContactSelector; -use Friendica\Content\Pager; -use Friendica\Core\Renderer; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Model; -use Friendica\Module; -use Friendica\Util\Proxy as ProxyUtils; -use Friendica\Util\Strings; - -function common_content(App $a) -{ - $o = ''; - - $cmd = $a->argv[1]; - $uid = intval($a->argv[2]); - $cid = intval($a->argv[3]); - $zcid = 0; - - if (!local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); - return; - } - - if ($cmd !== 'loc' && $cmd != 'rem') { - return; - } - - if (!$uid) { - return; - } - - if ($cmd === 'loc' && $cid) { - $contact = DBA::selectFirst('contact', ['name', 'url', 'photo', 'uid', 'id'], ['id' => $cid, 'uid' => $uid]); - - if (DBA::isResult($contact)) { - DI::page()['aside'] = ""; - Model\Profile::load($a, "", Model\Contact::getDetailsByURL($contact["url"])); - } - } else { - $contact = DBA::selectFirst('contact', ['name', 'url', 'photo', 'uid', 'id'], ['self' => true, 'uid' => $uid]); - - if (DBA::isResult($contact)) { - $vcard_widget = Renderer::replaceMacros(Renderer::getMarkupTemplate('widget/vcard.tpl'), [ - '$name' => $contact['name'], - '$photo' => $contact['photo'], - 'url' => 'contact/' . $cid - ]); - - if (empty(DI::page()['aside'])) { - DI::page()['aside'] = ''; - } - DI::page()['aside'] .= $vcard_widget; - } - } - - if (!DBA::isResult($contact)) { - return; - } - - if (!$cid && Model\Profile::getMyURL()) { - $contact = DBA::selectFirst('contact', ['id'], ['nurl' => Strings::normaliseLink(Model\Profile::getMyURL()), 'uid' => $uid]); - if (DBA::isResult($contact)) { - $cid = $contact['id']; - } else { - $gcontact = DBA::selectFirst('gcontact', ['id'], ['nurl' => Strings::normaliseLink(Model\Profile::getMyURL())]); - if (DBA::isResult($gcontact)) { - $zcid = $gcontact['id']; - } - } - } - - if ($cid == 0 && $zcid == 0) { - return; - } - - if ($cid) { - $total = Model\GContact::countCommonFriends($uid, $cid); - } else { - $total = Model\GContact::countCommonFriendsZcid($uid, $zcid); - } - - if ($total < 1) { - notice(DI::l10n()->t('No contacts in common.') . EOL); - return $o; - } - - $pager = new Pager(DI::l10n(), DI::args()->getQueryString()); - - if ($cid) { - $common_friends = Model\GContact::commonFriends($uid, $cid, $pager->getStart(), $pager->getItemsPerPage()); - } else { - $common_friends = Model\GContact::commonFriendsZcid($uid, $zcid, $pager->getStart(), $pager->getItemsPerPage()); - } - - if (!DBA::isResult($common_friends)) { - return $o; - } - - $id = 0; - - $entries = []; - foreach ($common_friends as $common_friend) { - //get further details of the contact - $contact_details = Model\Contact::getDetailsByURL($common_friend['url'], $uid); - - // $rr['id'] is needed to use contact_photo_menu() - /// @TODO Adding '/" here avoids E_NOTICE on missing constants - $common_friend['id'] = $common_friend['cid']; - - $photo_menu = Model\Contact::photoMenu($common_friend); - - $entry = [ - 'url' => Model\Contact::magicLink($common_friend['url']), - 'itemurl' => ($contact_details['addr'] ?? '') ?: $common_friend['url'], - 'name' => $contact_details['name'], - 'thumb' => ProxyUtils::proxifyUrl($contact_details['thumb'], false, ProxyUtils::SIZE_THUMB), - 'img_hover' => $contact_details['name'], - 'details' => $contact_details['location'], - 'tags' => $contact_details['keywords'], - 'about' => $contact_details['about'], - 'account_type' => Model\Contact::getAccountType($contact_details), - 'network' => ContactSelector::networkToName($contact_details['network'], $contact_details['url']), - 'photo_menu' => $photo_menu, - 'id' => ++$id, - ]; - $entries[] = $entry; - } - - $title = ''; - $tab_str = ''; - if ($cmd === 'loc' && $cid && local_user() == $uid) { - $tab_str = Module\Contact::getTabsHTML($a, $contact, 5); - } else { - $title = DI::l10n()->t('Common Friends'); - } - - $tpl = Renderer::getMarkupTemplate('viewcontact_template.tpl'); - - $o .= Renderer::replaceMacros($tpl, [ - '$title' => $title, - '$tab_str' => $tab_str, - '$contacts' => $entries, - '$paginate' => $pager->renderFull($total), - ]); - - return $o; -} diff --git a/mod/dfrn_confirm.php b/mod/dfrn_confirm.php index c191d3e170..acdf922815 100644 --- a/mod/dfrn_confirm.php +++ b/mod/dfrn_confirm.php @@ -27,9 +27,9 @@ * 2. We may be the target or other side of the conversation to scenario 1, and will * interact with that process on our own user's behalf. * - * @see PDF with dfrn specs: https://github.com/friendica/friendica/blob/master/spec/dfrn2.pdf + * @see PDF with dfrn specs: https://github.com/friendica/friendica/blob/stable/spec/dfrn2.pdf * You also find a graphic which describes the confirmation process at - * https://github.com/friendica/friendica/blob/master/spec/dfrn2_contact_confirmation.png + * https://github.com/friendica/friendica/blob/stable/spec/dfrn2_contact_confirmation.png */ use Friendica\App; @@ -40,12 +40,12 @@ use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\Group; +use Friendica\Model\Notify; use Friendica\Model\Notify\Type; use Friendica\Model\User; use Friendica\Protocol\Activity; use Friendica\Util\Crypto; use Friendica\Util\DateTimeFormat; -use Friendica\Util\Network; use Friendica\Util\Strings; use Friendica\Util\XML; @@ -76,13 +76,13 @@ function dfrn_confirm_post(App $a, $handsfree = null) if (empty($_POST['source_url'])) { $uid = ($handsfree['uid'] ?? 0) ?: local_user(); if (!$uid) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } $user = DBA::selectFirst('user', [], ['uid' => $uid]); if (!DBA::isResult($user)) { - notice(DI::l10n()->t('Profile not found.') . EOL); + notice(DI::l10n()->t('Profile not found.')); return; } @@ -137,8 +137,8 @@ function dfrn_confirm_post(App $a, $handsfree = null) ); if (!DBA::isResult($r)) { Logger::log('Contact not found in DB.'); - notice(DI::l10n()->t('Contact not found.') . EOL); - notice(DI::l10n()->t('This may occasionally happen if contact was requested by both persons and it has already been approved.') . EOL); + notice(DI::l10n()->t('Contact not found.')); + notice(DI::l10n()->t('This may occasionally happen if contact was requested by both persons and it has already been approved.')); return; } @@ -214,7 +214,7 @@ function dfrn_confirm_post(App $a, $handsfree = null) $params['page'] = 2; } - Logger::log('Confirm: posting data to ' . $dfrn_confirm . ': ' . print_r($params, true), Logger::DATA); + Logger::debug('Confirm: posting data', ['confirm' => $dfrn_confirm, 'parameter' => $params]); /* * @@ -224,7 +224,7 @@ function dfrn_confirm_post(App $a, $handsfree = null) * */ - $res = Network::post($dfrn_confirm, $params, [], 120)->getBody(); + $res = DI::httpRequest()->post($dfrn_confirm, $params, [], 120)->getBody(); Logger::log(' Confirm: received data: ' . $res, Logger::DATA); @@ -239,20 +239,20 @@ function dfrn_confirm_post(App $a, $handsfree = null) // We shouldn't proceed, because the xml parser might choke, // and $status is going to be zero, which indicates success. // We can hardly call this a success. - notice(DI::l10n()->t('Response from remote site was not understood.') . EOL); + notice(DI::l10n()->t('Response from remote site was not understood.')); return; } if (strlen($leading_junk) && DI::config()->get('system', 'debugging')) { // This might be more common. Mixed error text and some XML. // If we're configured for debugging, show the text. Proceed in either case. - notice(DI::l10n()->t('Unexpected response from remote site: ') . EOL . $leading_junk . EOL); + notice(DI::l10n()->t('Unexpected response from remote site: ') . $leading_junk); } if (stristr($res, "t('Unexpected response from remote site: ') . EOL . htmlspecialchars($res) . EOL); + notice(DI::l10n()->t('Unexpected response from remote site: ') . EOL . htmlspecialchars($res)); return; } @@ -261,7 +261,7 @@ function dfrn_confirm_post(App $a, $handsfree = null) $message = XML::unescape($xml->message); // human readable text of what may have gone wrong. switch ($status) { case 0: - info(DI::l10n()->t("Confirmation completed successfully.") . EOL); + info(DI::l10n()->t("Confirmation completed successfully.")); break; case 1: // birthday paradox - generate new dfrn-id and fall through. @@ -273,15 +273,15 @@ function dfrn_confirm_post(App $a, $handsfree = null) ); case 2: - notice(DI::l10n()->t("Temporary failure. Please wait and try again.") . EOL); + notice(DI::l10n()->t("Temporary failure. Please wait and try again.")); break; case 3: - notice(DI::l10n()->t("Introduction failed or was revoked.") . EOL); + notice(DI::l10n()->t("Introduction failed or was revoked.")); break; } if (strlen($message)) { - notice(DI::l10n()->t('Remote site reported: ') . $message . EOL); + notice(DI::l10n()->t('Remote site reported: ') . $message); } if (($status == 0) && $intro_id) { @@ -305,7 +305,7 @@ function dfrn_confirm_post(App $a, $handsfree = null) * * We will also update the contact record with the nature and scope of the relationship. */ - Contact::updateAvatar($contact['photo'], $uid, $contact_id); + Contact::updateAvatar($contact_id, $contact['photo']); Logger::log('dfrn_confirm: confirm - imported photos'); @@ -372,9 +372,9 @@ function dfrn_confirm_post(App $a, $handsfree = null) $forum = (($page == 1) ? 1 : 0); $prv = (($page == 2) ? 1 : 0); - Logger::log('dfrn_confirm: requestee contacted: ' . $node); + Logger::notice('requestee contacted', ['node' => $node]); - Logger::log('dfrn_confirm: request: POST=' . print_r($_POST, true), Logger::DATA); + Logger::debug('request', ['POST' => $_POST]); // If $aes_key is set, both of these items require unpacking from the hex transport encoding. @@ -482,10 +482,10 @@ function dfrn_confirm_post(App $a, $handsfree = null) if (DBA::isResult($contact)) { $photo = $contact['photo']; } else { - $photo = DI::baseUrl() . '/images/person-300.jpg'; + $photo = DI::baseUrl() . Contact::DEFAULT_AVATAR_PHOTO; } - Contact::updateAvatar($photo, $local_uid, $dfrn_record); + Contact::updateAvatar($dfrn_record, $photo); Logger::log('dfrn_confirm: request - photos imported'); @@ -543,18 +543,12 @@ function dfrn_confirm_post(App $a, $handsfree = null) if ($combined['notify-flags'] & Type::CONFIRM) { $mutual = ($new_relation == Contact::FRIEND); notification([ - 'type' => Type::CONFIRM, - 'notify_flags' => $combined['notify-flags'], - 'language' => $combined['language'], - 'to_name' => $combined['username'], - 'to_email' => $combined['email'], - 'uid' => $combined['uid'], - 'link' => DI::baseUrl() . '/contact/' . $dfrn_record, - 'source_name' => ((strlen(stripslashes($combined['name']))) ? stripslashes($combined['name']) : DI::l10n()->t('[Name Withheld]')), - 'source_link' => $combined['url'], - 'source_photo' => $combined['photo'], - 'verb' => ($mutual ? Activity::FRIEND : Activity::FOLLOW), - 'otype' => 'intro' + 'type' => Type::CONFIRM, + 'otype' => Notify\ObjectType::INTRO, + 'verb' => ($mutual ? Activity::FRIEND : Activity::FOLLOW), + 'uid' => $combined['uid'], + 'cid' => $combined['id'], + 'link' => DI::baseUrl() . '/contact/' . $dfrn_record, ]); } } diff --git a/mod/dfrn_notify.php b/mod/dfrn_notify.php index 2e1f51a11c..3f38eccd30 100644 --- a/mod/dfrn_notify.php +++ b/mod/dfrn_notify.php @@ -19,7 +19,7 @@ * * The dfrn notify endpoint * - * @see PDF with dfrn specs: https://github.com/friendica/friendica/blob/master/spec/dfrn2.pdf + * @see PDF with dfrn specs: https://github.com/friendica/friendica/blob/stable/spec/dfrn2.pdf */ use Friendica\App; @@ -28,6 +28,7 @@ use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; +use Friendica\Model\Conversation; use Friendica\Model\User; use Friendica\Protocol\DFRN; use Friendica\Protocol\Diaspora; @@ -35,8 +36,6 @@ use Friendica\Util\Network; use Friendica\Util\Strings; function dfrn_notify_post(App $a) { - Logger::log(__function__, Logger::TRACE); - $postdata = Network::postdata(); if (empty($_POST) || !empty($postdata)) { @@ -192,7 +191,7 @@ function dfrn_notify_post(App $a) { Logger::log('Importing post from ' . $importer['addr'] . ' to ' . $importer['nickname'] . ' with the RINO ' . $rino_remote . ' encryption.', Logger::DEBUG); - $ret = DFRN::import($data, $importer); + $ret = DFRN::import($data, $importer, Conversation::PARCEL_LEGACY_DFRN, Conversation::PUSH); System::xmlExit($ret, 'Processed'); // NOTREACHED @@ -224,7 +223,7 @@ function dfrn_dispatch_public($postdata) Logger::log('Importing post from ' . $msg['author'] . ' with the public envelope.', Logger::DEBUG); // Now we should be able to import it - $ret = DFRN::import($msg['message'], $importer); + $ret = DFRN::import($msg['message'], $importer, Conversation::PARCEL_DIASPORA_DFRN, Conversation::RELAY); System::xmlExit($ret, 'Done'); } @@ -257,7 +256,7 @@ function dfrn_dispatch_private($user, $postdata) Logger::log('Importing post from ' . $msg['author'] . ' to ' . $user['nickname'] . ' with the private envelope.', Logger::DEBUG); // Now we should be able to import it - $ret = DFRN::import($msg['message'], $importer); + $ret = DFRN::import($msg['message'], $importer, Conversation::PARCEL_DIASPORA_DFRN, Conversation::PUSH); System::xmlExit($ret, 'Done'); } diff --git a/mod/dfrn_poll.php b/mod/dfrn_poll.php index 14221c7e6e..4ad8e0f499 100644 --- a/mod/dfrn_poll.php +++ b/mod/dfrn_poll.php @@ -21,13 +21,12 @@ use Friendica\App; use Friendica\Core\Logger; -use Friendica\Core\System; use Friendica\Core\Session; +use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Protocol\DFRN; use Friendica\Protocol\OStatus; -use Friendica\Util\Network; use Friendica\Util\Strings; use Friendica\Util\XML; @@ -115,7 +114,7 @@ function dfrn_poll_init(App $a) ); if (DBA::isResult($r)) { - $s = Network::fetchUrl($r[0]['poll'] . '?dfrn_id=' . $my_id . '&type=profile-check'); + $s = DI::httpRequest()->fetch($r[0]['poll'] . '?dfrn_id=' . $my_id . '&type=profile-check'); Logger::log("dfrn_poll: old profile returns " . $s, Logger::DATA); @@ -133,7 +132,7 @@ function dfrn_poll_init(App $a) Session::setVisitorsContacts(); if (!$quiet) { - info(DI::l10n()->t('%1$s welcomes %2$s', $r[0]['username'], $r[0]['name']) . EOL); + info(DI::l10n()->t('%1$s welcomes %2$s', $r[0]['username'], $r[0]['name'])); } // Visitors get 1 day session. @@ -240,7 +239,6 @@ function dfrn_poll_post(App $a) { $dfrn_id = $_POST['dfrn_id'] ?? ''; $challenge = $_POST['challenge'] ?? ''; - $url = $_POST['url'] ?? ''; $sec = $_POST['sec'] ?? ''; $ptype = $_POST['type'] ?? ''; $perm = ($_POST['perm'] ?? '') ?: 'r'; @@ -320,7 +318,6 @@ function dfrn_poll_post(App $a) exit(); } - $type = $r[0]['type']; $last_update = $r[0]['last_update']; DBA::delete('challenge', ['dfrn-id' => $dfrn_id, 'challenge' => $challenge]); @@ -347,59 +344,29 @@ function dfrn_poll_post(App $a) } $contact = $r[0]; - $owner_uid = $r[0]['uid']; $contact_id = $r[0]['id']; - if ($type === 'reputation' && strlen($url)) { - $r = q("SELECT * FROM `contact` WHERE `url` = '%s' AND `uid` = %d LIMIT 1", - DBA::escape($url), - intval($owner_uid) - ); - $reputation = 0; - $text = ''; - - if (DBA::isResult($r)) { - $reputation = $r[0]['rating']; - $text = $r[0]['reason']; - - if ($r[0]['id'] == $contact_id) { // inquiring about own reputation not allowed - $reputation = 0; - $text = ''; - } + // Update the writable flag if it changed + Logger::debug('post request feed', ['post' => $_POST]); + if ($dfrn_version >= 2.21) { + if ($perm === 'rw') { + $writable = 1; + } else { + $writable = 0; } - echo " - - $url - $reputation - $text - - "; - exit(); - // NOTREACHED - } else { - // Update the writable flag if it changed - Logger::log('dfrn_poll: post request feed: ' . print_r($_POST, true), Logger::DATA); - if ($dfrn_version >= 2.21) { - if ($perm === 'rw') { - $writable = 1; - } else { - $writable = 0; - } - - if ($writable != $contact['writable']) { - q("UPDATE `contact` SET `writable` = %d WHERE `id` = %d", - intval($writable), - intval($contact_id) - ); - } + if ($writable != $contact['writable']) { + q("UPDATE `contact` SET `writable` = %d WHERE `id` = %d", + intval($writable), + intval($contact_id) + ); } - - header("Content-type: application/atom+xml"); - $o = DFRN::feed($dfrn_id, $a->argv[1], $last_update, $direction); - echo $o; - exit(); } + + header("Content-type: application/atom+xml"); + $o = DFRN::feed($dfrn_id, $a->argv[1], $last_update, $direction); + echo $o; + exit(); } function dfrn_poll_content(App $a) @@ -499,20 +466,20 @@ function dfrn_poll_content(App $a) // URL reply if ($dfrn_version < 2.2) { - $s = Network::fetchUrl($r[0]['poll'] - . '?dfrn_id=' . $encrypted_id - . '&type=profile-check' - . '&dfrn_version=' . DFRN_PROTOCOL_VERSION - . '&challenge=' . $challenge - . '&sec=' . $sec + $s = DI::httpRequest()->fetch($r[0]['poll'] + . '?dfrn_id=' . $encrypted_id + . '&type=profile-check' + . '&dfrn_version=' . DFRN_PROTOCOL_VERSION + . '&challenge=' . $challenge + . '&sec=' . $sec ); } else { - $s = Network::post($r[0]['poll'], [ - 'dfrn_id' => $encrypted_id, - 'type' => 'profile-check', + $s = DI::httpRequest()->post($r[0]['poll'], [ + 'dfrn_id' => $encrypted_id, + 'type' => 'profile-check', 'dfrn_version' => DFRN_PROTOCOL_VERSION, - 'challenge' => $challenge, - 'sec' => $sec + 'challenge' => $challenge, + 'sec' => $sec ])->getBody(); } @@ -521,7 +488,7 @@ function dfrn_poll_content(App $a) if (strlen($s) && strstr($s, ' $xml]); Logger::log('dfrn_poll: secure profile: challenge: ' . $xml->challenge . ' expecting ' . $hash); Logger::log('dfrn_poll: secure profile: sec: ' . $xml->sec . ' expecting ' . $sec); @@ -536,7 +503,7 @@ function dfrn_poll_content(App $a) Session::setVisitorsContacts(); if (!$quiet) { - info(DI::l10n()->t('%1$s welcomes %2$s', $r[0]['username'], $r[0]['name']) . EOL); + info(DI::l10n()->t('%1$s welcomes %2$s', $r[0]['username'], $r[0]['name'])); } // Visitors get 1 day session. diff --git a/mod/dfrn_request.php b/mod/dfrn_request.php index 0be8403c2b..47478d3848 100644 --- a/mod/dfrn_request.php +++ b/mod/dfrn_request.php @@ -19,9 +19,9 @@ * *Handles communication associated with the issuance of friend requests. * - * @see PDF with dfrn specs: https://github.com/friendica/friendica/blob/master/spec/dfrn2.pdf + * @see PDF with dfrn specs: https://github.com/friendica/friendica/blob/stable/spec/dfrn2.pdf * You also find a graphic which describes the confirmation process at - * https://github.com/friendica/friendica/blob/master/spec/dfrn2_contact_request.png + * https://github.com/friendica/friendica/blob/stable/spec/dfrn2_contact_request.png */ use Friendica\App; @@ -29,12 +29,13 @@ use Friendica\Core\Logger; use Friendica\Core\Protocol; use Friendica\Core\Renderer; use Friendica\Core\Search; -use Friendica\Core\System; use Friendica\Core\Session; +use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\Group; +use Friendica\Model\Notify; use Friendica\Model\Notify\Type; use Friendica\Model\Profile; use Friendica\Model\User; @@ -110,7 +111,7 @@ function dfrn_request_post(App $a) if (DBA::isResult($r)) { if (strlen($r[0]['dfrn-id'])) { // We don't need to be here. It has already happened. - notice(DI::l10n()->t("This introduction has already been accepted.") . EOL); + notice(DI::l10n()->t("This introduction has already been accepted.")); return; } else { $contact_record = $r[0]; @@ -128,18 +129,18 @@ function dfrn_request_post(App $a) $parms = Probe::profile($dfrn_url); if (!count($parms)) { - notice(DI::l10n()->t('Profile location is not valid or does not contain profile information.') . EOL); + notice(DI::l10n()->t('Profile location is not valid or does not contain profile information.')); return; } else { if (empty($parms['fn'])) { - notice(DI::l10n()->t('Warning: profile location has no identifiable owner name.') . EOL); + notice(DI::l10n()->t('Warning: profile location has no identifiable owner name.')); } if (empty($parms['photo'])) { - notice(DI::l10n()->t('Warning: profile location has no profile photo.') . EOL); + notice(DI::l10n()->t('Warning: profile location has no profile photo.')); } $invalid = Probe::validDfrn($parms); if ($invalid) { - notice(DI::l10n()->tt("%d required parameter was not found at the given location", "%d required parameters were not found at the given location", $invalid) . EOL); + notice(DI::l10n()->tt("%d required parameter was not found at the given location", "%d required parameters were not found at the given location", $invalid)); return; } } @@ -177,7 +178,7 @@ function dfrn_request_post(App $a) } if ($r) { - info(DI::l10n()->t("Introduction complete.") . EOL); + info(DI::l10n()->t("Introduction complete.")); } $r = q("SELECT `id`, `network` FROM `contact` WHERE `uid` = %d AND `url` = '%s' AND `site-pubkey` = '%s' LIMIT 1", @@ -189,7 +190,7 @@ function dfrn_request_post(App $a) Group::addMember(User::getDefaultGroup(local_user(), $r[0]["network"]), $r[0]['id']); if (isset($photo)) { - Contact::updateAvatar($photo, local_user(), $r[0]["id"], true); + Contact::updateAvatar($r[0]["id"], $photo, true); } $forward_path = "contact/" . $r[0]['id']; @@ -203,7 +204,7 @@ function dfrn_request_post(App $a) } if (!empty($dfrn_request) && strlen($confirm_key)) { - Network::fetchUrl($dfrn_request . '?confirm_key=' . $confirm_key); + DI::httpRequest()->fetch($dfrn_request . '?confirm_key=' . $confirm_key); } // (ignore reply, nothing we can do it failed) @@ -213,7 +214,7 @@ function dfrn_request_post(App $a) } // invalid/bogus request - notice(DI::l10n()->t('Unrecoverable protocol error.') . EOL); + notice(DI::l10n()->t('Unrecoverable protocol error.')); DI::baseUrl()->redirect(); return; // NOTREACHED } @@ -240,7 +241,7 @@ function dfrn_request_post(App $a) * */ if (empty($a->profile['uid'])) { - notice(DI::l10n()->t('Profile unavailable.') . EOL); + notice(DI::l10n()->t('Profile unavailable.')); return; } @@ -261,9 +262,9 @@ function dfrn_request_post(App $a) intval($uid) ); if (DBA::isResult($r) && count($r) > $maxreq) { - notice(DI::l10n()->t('%s has received too many connection requests today.', $a->profile['name']) . EOL); - notice(DI::l10n()->t('Spam protection measures have been invoked.') . EOL); - notice(DI::l10n()->t('Friends are advised to please try again in 24 hours.') . EOL); + notice(DI::l10n()->t('%s has received too many connection requests today.', $a->profile['name'])); + notice(DI::l10n()->t('Spam protection measures have been invoked.')); + notice(DI::l10n()->t('Friends are advised to please try again in 24 hours.')); return; } } @@ -287,18 +288,18 @@ function dfrn_request_post(App $a) $url = trim($_POST['dfrn_url']); if (!strlen($url)) { - notice(DI::l10n()->t("Invalid locator") . EOL); + notice(DI::l10n()->t("Invalid locator")); return; } $hcard = ''; // Detect the network - $data = Probe::uri($url); + $data = Contact::getByURL($url); $network = $data["network"]; - // Canonicalise email-style profile locator - $url = Probe::webfingerDfrn($url, $hcard); + // Canonicalize email-style profile locator + $url = Probe::webfingerDfrn($data['url'] ?? $url, $hcard); if (substr($url, 0, 5) === 'stat:') { // Every time we detect the remote subscription we define this as OStatus. @@ -323,10 +324,10 @@ function dfrn_request_post(App $a) if (DBA::isResult($ret)) { if (strlen($ret[0]['issued-id'])) { - notice(DI::l10n()->t('You have already introduced yourself here.') . EOL); + notice(DI::l10n()->t('You have already introduced yourself here.')); return; } elseif ($ret[0]['rel'] == Contact::FRIEND) { - notice(DI::l10n()->t('Apparently you are already friends with %s.', $a->profile['name']) . EOL); + notice(DI::l10n()->t('Apparently you are already friends with %s.', $a->profile['name'])); return; } else { $contact_record = $ret[0]; @@ -346,19 +347,19 @@ function dfrn_request_post(App $a) } else { $url = Network::isUrlValid($url); if (!$url) { - notice(DI::l10n()->t('Invalid profile URL.') . EOL); + notice(DI::l10n()->t('Invalid profile URL.')); DI::baseUrl()->redirect(DI::args()->getCommand()); return; // NOTREACHED } if (!Network::isUrlAllowed($url)) { - notice(DI::l10n()->t('Disallowed profile URL.') . EOL); + notice(DI::l10n()->t('Disallowed profile URL.')); DI::baseUrl()->redirect(DI::args()->getCommand()); return; // NOTREACHED } if (Network::isUrlBlocked($url)) { - notice(DI::l10n()->t('Blocked domain') . EOL); + notice(DI::l10n()->t('Blocked domain')); DI::baseUrl()->redirect(DI::args()->getCommand()); return; // NOTREACHED } @@ -366,18 +367,18 @@ function dfrn_request_post(App $a) $parms = Probe::profile(($hcard) ? $hcard : $url); if (!count($parms)) { - notice(DI::l10n()->t('Profile location is not valid or does not contain profile information.') . EOL); + notice(DI::l10n()->t('Profile location is not valid or does not contain profile information.')); DI::baseUrl()->redirect(DI::args()->getCommand()); } else { if (empty($parms['fn'])) { - notice(DI::l10n()->t('Warning: profile location has no identifiable owner name.') . EOL); + notice(DI::l10n()->t('Warning: profile location has no identifiable owner name.')); } if (empty($parms['photo'])) { - notice(DI::l10n()->t('Warning: profile location has no profile photo.') . EOL); + notice(DI::l10n()->t('Warning: profile location has no profile photo.')); } $invalid = Probe::validDfrn($parms); if ($invalid) { - notice(DI::l10n()->tt("%d required parameter was not found at the given location", "%d required parameters were not found at the given location", $invalid) . EOL); + notice(DI::l10n()->tt("%d required parameter was not found at the given location", "%d required parameters were not found at the given location", $invalid)); return; } @@ -420,12 +421,12 @@ function dfrn_request_post(App $a) ); if (DBA::isResult($r)) { $contact_record = $r[0]; - Contact::updateAvatar($photo, $uid, $contact_record["id"], true); + Contact::updateAvatar($contact_record["id"], $photo, true); } } } if ($r === false) { - notice(DI::l10n()->t('Failed to update contact record.') . EOL); + notice(DI::l10n()->t('Failed to update contact record.')); return; } @@ -445,7 +446,7 @@ function dfrn_request_post(App $a) // This notice will only be seen by the requestor if the requestor and requestee are on the same server. if (!$failed) { - info(DI::l10n()->t('Your introduction has been sent.') . EOL); + info(DI::l10n()->t('Your introduction has been sent.')); } // "Homecoming" - send the requestor back to their site to record the introduction. @@ -477,7 +478,7 @@ function dfrn_request_post(App $a) // NOTREACHED // END $network != Protocol::PHANTOM } else { - notice(DI::l10n()->t("Remote subscription can't be done for your network. Please subscribe directly on your system.") . EOL); + notice(DI::l10n()->t("Remote subscription can't be done for your network. Please subscribe directly on your system.")); return; } } return; @@ -493,7 +494,7 @@ function dfrn_request_content(App $a) // to send us to the post section to record the introduction. if (!empty($_GET['dfrn_url'])) { if (!local_user()) { - info(DI::l10n()->t("Please login to confirm introduction.") . EOL); + info(DI::l10n()->t("Please login to confirm introduction.")); /* setup the return URL to come back to this page if they use openid */ return Login::form(); } @@ -501,7 +502,7 @@ function dfrn_request_content(App $a) // Edge case, but can easily happen in the wild. This person is authenticated, // but not as the person who needs to deal with this request. if ($a->user['nickname'] != $a->argv[1]) { - notice(DI::l10n()->t("Incorrect identity currently logged in. Please login to this profile.") . EOL); + notice(DI::l10n()->t("Incorrect identity currently logged in. Please login to this profile.")); return Login::form(); } @@ -559,18 +560,12 @@ function dfrn_request_content(App $a) if (!$auto_confirm) { notification([ - 'type' => Type::INTRO, - 'notify_flags' => $r[0]['notify-flags'], - 'language' => $r[0]['language'], - 'to_name' => $r[0]['username'], - 'to_email' => $r[0]['email'], - 'uid' => $r[0]['uid'], - 'link' => DI::baseUrl() . '/notifications/intros', - 'source_name' => ((strlen(stripslashes($r[0]['name']))) ? stripslashes($r[0]['name']) : DI::l10n()->t('[Name Withheld]')), - 'source_link' => $r[0]['url'], - 'source_photo' => $r[0]['photo'], - 'verb' => Activity::REQ_FRIEND, - 'otype' => 'intro' + 'type' => Type::INTRO, + 'otype' => Notify\ObjectType::INTRO, + 'verb' => Activity::REQ_FRIEND, + 'uid' => $r[0]['uid'], + 'cid' => $r[0]['id'], + 'link' => DI::baseUrl() . '/notifications/intros', ]); } @@ -603,7 +598,7 @@ function dfrn_request_content(App $a) // Normal web request. Display our user's introduction form. if (DI::config()->get('system', 'block_public') && !Session::isAuthenticated()) { if (!DI::config()->get('system', 'local_block')) { - notice(DI::l10n()->t('Public access denied.') . EOL); + notice(DI::l10n()->t('Public access denied.')); return; } } diff --git a/mod/display.php b/mod/display.php index a06d8a7239..81dce59e2f 100644 --- a/mod/display.php +++ b/mod/display.php @@ -54,7 +54,7 @@ function display_init(App $a) $item = null; $item_user = local_user(); - $fields = ['id', 'parent', 'author-id', 'body', 'uid', 'guid']; + $fields = ['id', 'parent', 'author-id', 'body', 'uid', 'guid', 'gravity']; // If there is only one parameter, then check if this parameter could be a guid if ($a->argc == 2) { @@ -101,12 +101,12 @@ function display_init(App $a) } if (!empty($_SERVER['HTTP_ACCEPT']) && strstr($_SERVER['HTTP_ACCEPT'], 'application/atom+xml')) { - Logger::log('Directly serving XML for id '.$item["id"], Logger::DEBUG); - displayShowFeed($item["id"], false); + Logger::log('Directly serving XML for id '.$item['id'], Logger::DEBUG); + displayShowFeed($item['id'], false); } - if ($item["id"] != $item["parent"]) { - $parent = Item::selectFirstForUser($item_user, $fields, ['id' => $item["parent"]]); + if ($item['gravity'] != GRAVITY_PARENT) { + $parent = Item::selectFirstForUser($item_user, $fields, ['id' => $item['parent']]); $item = $parent ?: $item; } @@ -164,7 +164,7 @@ function display_fetchauthor($a, $item) $profiledata["about"] = ""; } - $profiledata = Contact::getDetailsByURL($profiledata["url"], local_user(), $profiledata); + $profiledata = Contact::getByURLForUser($profiledata["url"], local_user()) ?: $profiledata; if (!empty($profiledata["photo"])) { $profiledata["photo"] = DI::baseUrl()->remove($profiledata["photo"]); @@ -183,9 +183,11 @@ function display_content(App $a, $update = false, $update_uid = 0) $item = null; + $force = (bool)($_REQUEST['force'] ?? false); + if ($update) { $item_id = $_REQUEST['item_id']; - $item = Item::selectFirst(['uid', 'parent', 'parent-uri'], ['id' => $item_id]); + $item = Item::selectFirst(['uid', 'parent', 'parent-uri', 'parent-uri-id'], ['id' => $item_id]); if ($item['uid'] != 0) { $a->profile = ['uid' => intval($item['uid'])]; } else { @@ -199,14 +201,14 @@ function display_content(App $a, $update = false, $update_uid = 0) if ($a->argc == 2) { $item_parent = 0; - $fields = ['id', 'parent', 'parent-uri', 'uid']; + $fields = ['id', 'parent', 'parent-uri', 'parent-uri-id', 'uid']; if (local_user()) { $condition = ['guid' => $a->argv[1], 'uid' => local_user()]; $item = Item::selectFirstForUser(local_user(), $fields, $condition); if (DBA::isResult($item)) { - $item_id = $item["id"]; - $item_parent = $item["parent"]; + $item_id = $item['id']; + $item_parent = $item['parent']; $item_parent_uri = $item['parent-uri']; } } @@ -214,8 +216,8 @@ function display_content(App $a, $update = false, $update_uid = 0) if (($item_parent == 0) && remote_user()) { $item = Item::selectFirst($fields, ['guid' => $a->argv[1], 'private' => Item::PRIVATE, 'origin' => true]); if (DBA::isResult($item) && Contact::isFollower(remote_user(), $item['uid'])) { - $item_id = $item["id"]; - $item_parent = $item["parent"]; + $item_id = $item['id']; + $item_parent = $item['parent']; $item_parent_uri = $item['parent-uri']; } } @@ -224,8 +226,8 @@ function display_content(App $a, $update = false, $update_uid = 0) $condition = ['private' => [Item::PUBLIC, Item::UNLISTED], 'guid' => $a->argv[1], 'uid' => 0]; $item = Item::selectFirstForUser(local_user(), $fields, $condition); if (DBA::isResult($item)) { - $item_id = $item["id"]; - $item_parent = $item["parent"]; + $item_id = $item['id']; + $item_parent = $item['parent']; $item_parent_uri = $item['parent-uri']; } } @@ -236,6 +238,10 @@ function display_content(App $a, $update = false, $update_uid = 0) throw new HTTPException\NotFoundException(DI::l10n()->t('The requested item doesn\'t exist or has been deleted.')); } + if (!DI::pConfig()->get(local_user(), 'system', 'detailed_notif')) { + DBA::update('notify', ['seen' => true], ['parent-uri-id' => $item['parent-uri-id'], 'uid' => local_user()]); + } + // We are displaying an "alternate" link if that post was public. See issue 2864 $is_public = Item::exists(['id' => $item_id, 'private' => [Item::PUBLIC, Item::UNLISTED]]); if ($is_public) { @@ -281,7 +287,7 @@ function display_content(App $a, $update = false, $update_uid = 0) } // We need the editor here to be able to reshare an item. - if ($is_owner) { + if ($is_owner && !$update) { $x = [ 'is_owner' => true, 'allow_location' => $a->user['allow_location'], @@ -304,7 +310,7 @@ function display_content(App $a, $update = false, $update_uid = 0) $unseen = false; } - if ($update && !$unseen) { + if ($update && !$unseen && !$force) { return ''; } diff --git a/mod/editpost.php b/mod/editpost.php index 7cccfdb2d4..ff2d0f5552 100644 --- a/mod/editpost.php +++ b/mod/editpost.php @@ -35,14 +35,14 @@ function editpost_content(App $a) $o = ''; if (!local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } $post_id = (($a->argc > 1) ? intval($a->argv[1]) : 0); if (!$post_id) { - notice(DI::l10n()->t('Item not found') . EOL); + notice(DI::l10n()->t('Item not found')); return; } @@ -52,7 +52,7 @@ function editpost_content(App $a) $item = Item::selectFirstForUser(local_user(), $fields, ['id' => $post_id, 'uid' => local_user()]); if (!DBA::isResult($item)) { - notice(DI::l10n()->t('Item not found') . EOL); + notice(DI::l10n()->t('Item not found')); return; } @@ -66,7 +66,8 @@ function editpost_content(App $a) DI::page()['htmlhead'] .= Renderer::replaceMacros($tpl, [ '$ispublic' => ' ', // DI::l10n()->t('Visible to everybody'), '$geotag' => $geotag, - '$nickname' => $a->user['nickname'] + '$nickname' => $a->user['nickname'], + '$is_mobile' => DI::mode()->isMobile(), ]); if (strlen($item['allow_cid']) || strlen($item['allow_gid']) || strlen($item['deny_cid']) || strlen($item['deny_gid'])) { @@ -131,7 +132,7 @@ function editpost_content(App $a) //jot nav tab (used in some themes) '$message' => DI::l10n()->t('Message'), '$browser' => DI::l10n()->t('Browser'), - '$shortpermset' => DI::l10n()->t('permissions'), + '$shortpermset' => DI::l10n()->t('Permissions'), '$compose_link_title' => DI::l10n()->t('Open Compose page'), ]); @@ -145,7 +146,7 @@ function undo_post_tagging($s) { if ($cnt) { foreach ($matches as $mtch) { if (in_array($mtch[1], ['!', '@'])) { - $contact = Contact::getDetailsByURL($mtch[2]); + $contact = Contact::getByURL($mtch[2], false, ['addr']); $mtch[3] = empty($contact['addr']) ? $mtch[2] : $contact['addr']; } $s = str_replace($mtch[0], $mtch[1] . $mtch[3],$s); diff --git a/mod/events.php b/mod/events.php index 6c5c274ea3..04a88e98bb 100644 --- a/mod/events.php +++ b/mod/events.php @@ -25,11 +25,13 @@ use Friendica\Content\Nav; use Friendica\Content\Widget\CalendarExport; use Friendica\Core\ACL; use Friendica\Core\Logger; +use Friendica\Core\Protocol; use Friendica\Core\Renderer; use Friendica\Core\Theme; use Friendica\Core\Worker; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Model\Conversation; use Friendica\Model\Event; use Friendica\Model\Item; use Friendica\Model\User; @@ -65,9 +67,7 @@ function events_init(App $a) function events_post(App $a) { - - Logger::log('post: ' . print_r($_REQUEST, true), Logger::DATA); - + Logger::debug('post', ['request' => $_REQUEST]); if (!local_user()) { return; } @@ -82,6 +82,8 @@ function events_post(App $a) $adjust = intval($_POST['adjust'] ?? 0); $nofinish = intval($_POST['nofinish'] ?? 0); + $share = intval($_POST['share'] ?? 0); + // The default setting for the `private` field in event_store() is false, so mirror that $private_event = false; @@ -129,10 +131,10 @@ function events_post(App $a) ]; $action = ($event_id == '') ? 'new' : 'event/' . $event_id; - $onerror_path = 'events/' . $action . '?' . http_build_query($params, null, null, PHP_QUERY_RFC3986); + $onerror_path = 'events/' . $action . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986); if (strcmp($finish, $start) < 0 && !$nofinish) { - notice(DI::l10n()->t('Event can not end before it has started.') . EOL); + notice(DI::l10n()->t('Event can not end before it has started.')); if (intval($_REQUEST['preview'])) { echo DI::l10n()->t('Event can not end before it has started.'); exit(); @@ -141,7 +143,7 @@ function events_post(App $a) } if (!$summary || ($start === DBA::NULL_DATETIME)) { - notice(DI::l10n()->t('Event title and start time are required.') . EOL); + notice(DI::l10n()->t('Event title and start time are required.')); if (intval($_REQUEST['preview'])) { echo DI::l10n()->t('Event title and start time are required.'); exit(); @@ -149,45 +151,42 @@ function events_post(App $a) DI::baseUrl()->redirect($onerror_path); } - $share = intval($_POST['share'] ?? 0); - - $c = q("SELECT `id` FROM `contact` WHERE `uid` = %d AND `self` LIMIT 1", - intval(local_user()) - ); - - if (DBA::isResult($c)) { - $self = $c[0]['id']; - } else { - $self = 0; - } + $self = \Friendica\Model\Contact::getPublicIdByUserId($uid); + $aclFormatter = DI::aclFormatter(); if ($share) { - - $aclFormatter = DI::aclFormatter(); - - $str_group_allow = $aclFormatter->toString($_POST['group_allow'] ?? ''); - $str_contact_allow = $aclFormatter->toString($_POST['contact_allow'] ?? ''); - $str_group_deny = $aclFormatter->toString($_POST['group_deny'] ?? ''); - $str_contact_deny = $aclFormatter->toString($_POST['contact_deny'] ?? ''); - - // Undo the pseudo-contact of self, since there are real contacts now - if (strpos($str_contact_allow, '<' . $self . '>') !== false) { - $str_contact_allow = str_replace('<' . $self . '>', '', $str_contact_allow); + $user = User::getById($uid, ['allow_cid', 'allow_gid', 'deny_cid', 'deny_gid']); + if (!DBA::isResult($user)) { + return; } - // Make sure to set the `private` field as true. This is necessary to - // have the posts show up correctly in Diaspora if an event is created - // as visible only to self at first, but then edited to display to others. - if (strlen($str_group_allow) || strlen($str_contact_allow) || strlen($str_group_deny) || strlen($str_contact_deny)) { - $private_event = true; + + $str_contact_allow = isset($_REQUEST['contact_allow']) ? $aclFormatter->toString($_REQUEST['contact_allow']) : $user['allow_cid'] ?? ''; + $str_group_allow = isset($_REQUEST['group_allow']) ? $aclFormatter->toString($_REQUEST['group_allow']) : $user['allow_gid'] ?? ''; + $str_contact_deny = isset($_REQUEST['contact_deny']) ? $aclFormatter->toString($_REQUEST['contact_deny']) : $user['deny_cid'] ?? ''; + $str_group_deny = isset($_REQUEST['group_deny']) ? $aclFormatter->toString($_REQUEST['group_deny']) : $user['deny_gid'] ?? ''; + + $visibility = $_REQUEST['visibility'] ?? ''; + if ($visibility === 'public') { + // The ACL selector introduced in version 2019.12 sends ACL input data even when the Public visibility is selected + $str_contact_allow = $str_group_allow = $str_contact_deny = $str_group_deny = ''; + } else if ($visibility === 'custom') { + // Since we know from the visibility parameter the item should be private, we have to prevent the empty ACL + // case that would make it public. So we always append the author's contact id to the allowed contacts. + // See https://github.com/friendica/friendica/issues/9672 + $str_contact_allow .= $aclFormatter->toString($self); } } else { - // Note: do not set `private` field for self-only events. It will - // keep even you from seeing them! - $str_contact_allow = '<' . $self . '>'; + $str_contact_allow = $aclFormatter->toString($self); $str_group_allow = $str_contact_deny = $str_group_deny = ''; } + // Make sure to set the `private` field as true. This is necessary to + // have the posts show up correctly in Diaspora if an event is created + // as visible only to self at first, but then edited to display to others. + if (strlen($str_group_allow) || strlen($str_contact_allow) || strlen($str_group_deny) || strlen($str_contact_deny)) { + $private_event = true; + } $datarray = []; $datarray['start'] = $start; @@ -206,6 +205,9 @@ function events_post(App $a) $datarray['deny_gid'] = $str_group_deny; $datarray['private'] = $private_event; $datarray['id'] = $event_id; + $datarray['network'] = Protocol::DFRN; + $datarray['protocol'] = Conversation::PARCEL_DIRECT; + $datarray['direction'] = Conversation::PUSH; if (intval($_REQUEST['preview'])) { $html = Event::getHTML($datarray); @@ -225,7 +227,7 @@ function events_post(App $a) function events_content(App $a) { if (!local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return Login::form(); } @@ -256,6 +258,11 @@ function events_content(App $a) // get the translation strings for the callendar $i18n = Event::getStrings(); + DI::page()->registerStylesheet('view/asset/fullcalendar/dist/fullcalendar.min.css'); + DI::page()->registerStylesheet('view/asset/fullcalendar/dist/fullcalendar.print.min.css', 'print'); + DI::page()->registerFooterScript('view/asset/moment/min/moment-with-locales.min.js'); + DI::page()->registerFooterScript('view/asset/fullcalendar/dist/fullcalendar.min.js'); + $htpl = Renderer::getMarkupTemplate('event_head.tpl'); DI::page()['htmlhead'] .= Renderer::replaceMacros($htpl, [ '$module_url' => '/events', @@ -469,16 +476,16 @@ function events_content(App $a) $t_orig = $orig_event['summary'] ?? ''; $d_orig = $orig_event['desc'] ?? ''; $l_orig = $orig_event['location'] ?? ''; - $eid = !empty($orig_event) ? $orig_event['id'] : 0; - $cid = !empty($orig_event) ? $orig_event['cid'] : 0; - $uri = !empty($orig_event) ? $orig_event['uri'] : ''; + $eid = $orig_event['id'] ?? 0; + $cid = $orig_event['cid'] ?? 0; + $uri = $orig_event['uri'] ?? ''; if ($cid || $mode === 'edit') { $share_disabled = 'disabled="disabled"'; } - $sdt = !empty($orig_event) ? $orig_event['start'] : 'now'; - $fdt = !empty($orig_event) ? $orig_event['finish'] : 'now'; + $sdt = $orig_event['start'] ?? 'now'; + $fdt = $orig_event['finish'] ?? 'now'; $tz = date_default_timezone_get(); if (!empty($orig_event)) { @@ -583,9 +590,7 @@ function events_content(App $a) } if (Item::exists(['id' => $ev[0]['itemid']])) { - notice(DI::l10n()->t('Failed to remove event') . EOL); - } else { - info(DI::l10n()->t('Event removed') . EOL); + notice(DI::l10n()->t('Failed to remove event')); } DI::baseUrl()->redirect('events'); diff --git a/mod/fbrowser.php b/mod/fbrowser.php index 984747bcd8..14141d4004 100644 --- a/mod/fbrowser.php +++ b/mod/fbrowser.php @@ -9,6 +9,7 @@ use Friendica\App; use Friendica\Core\Renderer; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Model\Photo; use Friendica\Util\Images; use Friendica\Util\Strings; @@ -47,8 +48,8 @@ function fbrowser_content(App $a) if ($a->argc==2) { $photos = q("SELECT distinct(`album`) AS `album` FROM `photo` WHERE `uid` = %d AND `album` != '%s' AND `album` != '%s' ", intval(local_user()), - DBA::escape('Contact Photos'), - DBA::escape(DI::l10n()->t('Contact Photos')) + DBA::escape(Photo::CONTACT_PHOTOS), + DBA::escape(DI::l10n()->t(Photo::CONTACT_PHOTOS)) ); $albums = array_column($photos, 'album'); @@ -66,8 +67,8 @@ function fbrowser_content(App $a) FROM `photo` WHERE `uid` = %d $sql_extra AND `album` != '%s' AND `album` != '%s' GROUP BY `resource-id` $sql_extra2", intval(local_user()), - DBA::escape('Contact Photos'), - DBA::escape(DI::l10n()->t('Contact Photos')) + DBA::escape(Photo::CONTACT_PHOTOS), + DBA::escape(DI::l10n()->t(Photo::CONTACT_PHOTOS)) ); function _map_files1($rr) diff --git a/mod/follow.php b/mod/follow.php index 58419dfd3b..b01395874f 100644 --- a/mod/follow.php +++ b/mod/follow.php @@ -28,6 +28,7 @@ use Friendica\Model\Profile; use Friendica\Model\Item; use Friendica\Network\Probe; use Friendica\Database\DBA; +use Friendica\Model\User; use Friendica\Util\Strings; function follow_post(App $a) @@ -40,32 +41,9 @@ function follow_post(App $a) DI::baseUrl()->redirect('contact'); } - $uid = local_user(); $url = Probe::cleanURI($_REQUEST['url']); - $return_path = 'follow?url=' . urlencode($url); - // Makes the connection request for friendica contacts easier - // This is just a precaution if maybe this page is called somewhere directly via POST - $_SESSION['fastlane'] = $url; - - $result = Contact::createFromProbe($uid, $url, true); - - if ($result['success'] == false) { - // Possibly it is a remote item and not an account - follow_remote_item($url); - - if ($result['message']) { - notice($result['message']); - } - DI::baseUrl()->redirect($return_path); - } elseif ($result['cid']) { - DI::baseUrl()->redirect('contact/' . $result['cid']); - } - - info(DI::l10n()->t('The contact could not be added.')); - - DI::baseUrl()->redirect($return_path); - // NOTREACHED + follow_process($a, $url); } function follow_content(App $a) @@ -95,88 +73,73 @@ function follow_content(App $a) $submit = DI::l10n()->t('Submit Request'); // Don't try to add a pending contact - $r = q("SELECT `pending` FROM `contact` WHERE `uid` = %d AND ((`rel` != %d) OR (`network` = '%s')) AND - (`nurl` = '%s' OR `alias` = '%s' OR `alias` = '%s') AND - `network` != '%s' LIMIT 1", - intval(local_user()), DBA::escape(Contact::FOLLOWER), DBA::escape(Protocol::DFRN), DBA::escape(Strings::normaliseLink($url)), - DBA::escape(Strings::normaliseLink($url)), DBA::escape($url), DBA::escape(Protocol::STATUSNET)); + $user_contact = DBA::selectFirst('contact', ['pending'], ["`uid` = ? AND ((`rel` != ?) OR (`network` = ?)) AND + (`nurl` = ? OR `alias` = ? OR `alias` = ?) AND `network` != ?", + $uid, Contact::FOLLOWER, Protocol::DFRN, Strings::normaliseLink($url), + Strings::normaliseLink($url), $url, Protocol::STATUSNET]); - if ($r) { - if ($r[0]['pending']) { + if (DBA::isResult($user_contact)) { + if ($user_contact['pending']) { notice(DI::l10n()->t('You already added this contact.')); $submit = ''; - //$a->internalRedirect($_SESSION['return_path']); - // NOTREACHED } } - $ret = Probe::uri($url); + $contact = Contact::getByURL($url, true); - $protocol = Contact::getProtocol($ret['url'], $ret['network']); - - if (($protocol == Protocol::DIASPORA) && !DI::config()->get('system', 'diaspora_enabled')) { - notice(DI::l10n()->t("Diaspora support isn't enabled. Contact can't be added.")); - $submit = ''; - //$a->internalRedirect($_SESSION['return_path']); - // NOTREACHED + // Possibly it is a mail contact + if (empty($contact)) { + $contact = Probe::uri($url, Protocol::MAIL, $uid); } - if (($protocol == Protocol::OSTATUS) && DI::config()->get('system', 'ostatus_disabled')) { - notice(DI::l10n()->t("OStatus support is disabled. Contact can't be added.")); - $submit = ''; - //$a->internalRedirect($_SESSION['return_path']); - // NOTREACHED - } - - if ($protocol == Protocol::PHANTOM) { + if (empty($contact) || ($contact['network'] == Protocol::PHANTOM)) { // Possibly it is a remote item and not an account follow_remote_item($url); notice(DI::l10n()->t("The network type couldn't be detected. Contact can't be added.")); $submit = ''; - //$a->internalRedirect($_SESSION['return_path']); - // NOTREACHED + $contact = ['url' => $url, 'network' => Protocol::PHANTOM, 'name' => $url, 'keywords' => '']; + } + + $protocol = Contact::getProtocol($contact['url'], $contact['network']); + + if (($protocol == Protocol::DIASPORA) && !DI::config()->get('system', 'diaspora_enabled')) { + notice(DI::l10n()->t("Diaspora support isn't enabled. Contact can't be added.")); + $submit = ''; + } + + if (($protocol == Protocol::OSTATUS) && DI::config()->get('system', 'ostatus_disabled')) { + notice(DI::l10n()->t("OStatus support is disabled. Contact can't be added.")); + $submit = ''; } if ($protocol == Protocol::MAIL) { - $ret['url'] = $ret['addr']; + $contact['url'] = $contact['addr']; } - if (($protocol === Protocol::DFRN) && !DBA::isResult($r)) { - $request = $ret['request']; + if (($protocol === Protocol::DFRN) && !DBA::isResult($contact)) { + $request = $contact['request']; $tpl = Renderer::getMarkupTemplate('dfrn_request.tpl'); } else { + if (!empty($_REQUEST['auto'])) { + follow_process($a, $contact['url']); + } + $request = DI::baseUrl() . '/follow'; $tpl = Renderer::getMarkupTemplate('auto_request.tpl'); } - $r = q("SELECT `url` FROM `contact` WHERE `uid` = %d AND `self` LIMIT 1", intval($uid)); - - if (!$r) { + $owner = User::getOwnerDataById($uid); + if (empty($owner)) { notice(DI::l10n()->t('Permission denied.')); DI::baseUrl()->redirect($return_path); // NOTREACHED } - $myaddr = $r[0]['url']; - $gcontact_id = 0; + $myaddr = $owner['url']; // Makes the connection request for friendica contacts easier - $_SESSION['fastlane'] = $ret['url']; - - $r = q("SELECT `id`, `location`, `about`, `keywords` FROM `gcontact` WHERE `nurl` = '%s'", - Strings::normaliseLink($ret['url'])); - - if (!$r) { - $r = [['location' => '', 'about' => '', 'keywords' => '']]; - } else { - $gcontact_id = $r[0]['id']; - } - - if ($protocol === Protocol::DIASPORA) { - $r[0]['location'] = ''; - $r[0]['about'] = ''; - } + $_SESSION['fastlane'] = $contact['url']; $o = Renderer::replaceMacros($tpl, [ '$header' => DI::l10n()->t('Connect/Follow'), @@ -188,35 +151,59 @@ function follow_content(App $a) '$cancel' => DI::l10n()->t('Cancel'), '$request' => $request, - '$name' => $ret['name'], - '$url' => $ret['url'], - '$zrl' => Profile::zrl($ret['url']), + '$name' => $contact['name'], + '$url' => $contact['url'], + '$zrl' => Profile::zrl($contact['url']), '$myaddr' => $myaddr, - '$keywords' => $r[0]['keywords'], + '$keywords' => $contact['keywords'], - '$does_know_you' => ['knowyou', DI::l10n()->t('%s knows you', $ret['name'])], + '$does_know_you' => ['knowyou', DI::l10n()->t('%s knows you', $contact['name'])], '$addnote_field' => ['dfrn-request-message', DI::l10n()->t('Add a personal note:')], ]); DI::page()['aside'] = ''; - $profiledata = Contact::getDetailsByURL($ret['url']); - if ($profiledata) { - Profile::load($a, '', $profiledata, false); - } + if ($protocol != Protocol::PHANTOM) { + Profile::load($a, '', $contact, false); - if ($gcontact_id <> 0) { $o .= Renderer::replaceMacros(Renderer::getMarkupTemplate('section_title.tpl'), ['$title' => DI::l10n()->t('Status Messages and Posts')] ); // Show last public posts - $o .= Contact::getPostsFromUrl($ret['url']); + $o .= Contact::getPostsFromUrl($contact['url']); } return $o; } +function follow_process(App $a, string $url) +{ + $return_path = 'follow?url=' . urlencode($url); + + // Makes the connection request for friendica contacts easier + // This is just a precaution if maybe this page is called somewhere directly via POST + $_SESSION['fastlane'] = $url; + + $result = Contact::createFromProbe($a->user, $url, true); + + if ($result['success'] == false) { + // Possibly it is a remote item and not an account + follow_remote_item($url); + + if ($result['message']) { + notice($result['message']); + } + DI::baseUrl()->redirect($return_path); + } elseif ($result['cid']) { + DI::baseUrl()->redirect('contact/' . $result['cid']); + } + + notice(DI::l10n()->t('The contact could not be added.')); + + DI::baseUrl()->redirect($return_path); +} + function follow_remote_item($url) { $item_id = Item::fetchByLink($url, local_user()); diff --git a/mod/item.php b/mod/item.php index 10ad3ff05b..1ce1517a7b 100644 --- a/mod/item.php +++ b/mod/item.php @@ -29,6 +29,8 @@ */ use Friendica\App; +use Friendica\Content\Item as ItemHelper; +use Friendica\Content\PageInfo; use Friendica\Content\Text\BBCode; use Friendica\Core\Hook; use Friendica\Core\Logger; @@ -43,20 +45,20 @@ use Friendica\Model\Contact; use Friendica\Model\Conversation; use Friendica\Model\FileTag; use Friendica\Model\Item; +use Friendica\Model\Notify; use Friendica\Model\Notify\Type; use Friendica\Model\Photo; +use Friendica\Model\Post; use Friendica\Model\Tag; +use Friendica\Model\User; use Friendica\Network\HTTPException; use Friendica\Object\EMail\ItemCCEMail; use Friendica\Protocol\Activity; use Friendica\Protocol\Diaspora; use Friendica\Util\DateTimeFormat; -use Friendica\Util\Security; -use Friendica\Util\Strings; +use Friendica\Security\Security; use Friendica\Worker\Delivery; -require_once __DIR__ . '/../include/items.php'; - function item_post(App $a) { if (!Session::isAuthenticated()) { throw new HTTPException\ForbiddenException(); @@ -66,7 +68,10 @@ function item_post(App $a) { if (!empty($_REQUEST['dropitems'])) { $arr_drop = explode(',', $_REQUEST['dropitems']); - drop_items($arr_drop); + foreach ($arr_drop as $item) { + Item::deleteForUser(['id' => $item], $uid); + } + $json = ['success' => 1]; System::jsonExit($json); } @@ -77,8 +82,6 @@ function item_post(App $a) { $api_source = $_REQUEST['api_source'] ?? false; - $message_id = ((!empty($_REQUEST['message_id']) && $api_source) ? strip_tags($_REQUEST['message_id']) : ''); - $return_path = $_REQUEST['return'] ?? ''; $preview = intval($_REQUEST['preview'] ?? 0); @@ -97,36 +100,32 @@ function item_post(App $a) { } // Is this a reply to something? - $toplevel_item_id = intval($_REQUEST['parent'] ?? 0); + $parent_item_id = intval($_REQUEST['parent'] ?? 0); $thr_parent_uri = trim($_REQUEST['parent_uri'] ?? ''); - $thread_parent_uriid = 0; - $thread_parent_contact = null; - + $parent_item = null; $toplevel_item = null; - $parent_user = null; - - $parent_contact = null; + $toplevel_item_id = 0; + $toplevel_user_id = null; $objecttype = null; $profile_uid = ($_REQUEST['profile_uid'] ?? 0) ?: local_user(); $posttype = ($_REQUEST['post_type'] ?? '') ?: Item::PT_ARTICLE; - if ($toplevel_item_id || $thr_parent_uri) { - if ($toplevel_item_id) { - $toplevel_item = Item::selectFirst([], ['id' => $toplevel_item_id]); + if ($parent_item_id || $thr_parent_uri) { + if ($parent_item_id) { + $parent_item = Item::selectFirst([], ['id' => $parent_item_id]); } elseif ($thr_parent_uri) { - $toplevel_item = Item::selectFirst([], ['uri' => $thr_parent_uri, 'uid' => $profile_uid]); + $parent_item = Item::selectFirst([], ['uri' => $thr_parent_uri, 'uid' => $profile_uid]); } // if this isn't the top-level parent of the conversation, find it - if (DBA::isResult($toplevel_item)) { + if (DBA::isResult($parent_item)) { // The URI and the contact is taken from the direct parent which needn't to be the top parent - $thread_parent_uriid = $toplevel_item['uri-id']; - $thr_parent_uri = $toplevel_item['uri']; - $thread_parent_contact = Contact::getDetailsByURL($toplevel_item["author-link"]); + $thr_parent_uri = $parent_item['uri']; + $toplevel_item = $parent_item; - if ($toplevel_item['id'] != $toplevel_item['parent']) { + if ($parent_item['gravity'] != GRAVITY_PARENT) { $toplevel_item = Item::selectFirst([], ['id' => $toplevel_item['parent']]); } } @@ -139,8 +138,18 @@ function item_post(App $a) { throw new HTTPException\NotFoundException(DI::l10n()->t('Unable to locate original post.')); } + // When commenting on a public post then store the post for the current user + // This enables interaction like starring and saving into folders + if ($toplevel_item['uid'] == 0) { + $stored = Item::storeForUserByUriId($toplevel_item['uri-id'], local_user()); + Logger::info('Public item stored for user', ['uri-id' => $toplevel_item['uri-id'], 'uid' => $uid, 'stored' => $stored]); + if ($stored) { + $toplevel_item = Item::selectFirst([], ['id' => $stored]); + } + } + $toplevel_item_id = $toplevel_item['id']; - $parent_user = $toplevel_item['uid']; + $toplevel_user_id = $toplevel_item['uid']; $objecttype = Activity\ObjectType::COMMENT; } @@ -162,16 +171,8 @@ function item_post(App $a) { } // Ensure that the user id in a thread always stay the same - if (!is_null($parent_user) && in_array($parent_user, [local_user(), 0])) { - $profile_uid = $parent_user; - } - - // Check for multiple posts with the same message id (when the post was created via API) - if (($message_id != '') && ($profile_uid != 0)) { - if (Item::exists(['uri' => $message_id, 'uid' => $profile_uid])) { - Logger::info('Message already exists for user', ['uri' => $message_id, 'uid' => $profile_uid]); - return 0; - } + if (!is_null($toplevel_user_id) && in_array($toplevel_user_id, [local_user(), 0])) { + $profile_uid = $toplevel_user_id; } // Allow commenting if it is an answer to a public post @@ -195,8 +196,7 @@ function item_post(App $a) { $orig_post = Item::selectFirst(Item::ITEM_FIELDLIST, ['id' => $post_id]); } - $user = DBA::selectFirst('user', [], ['uid' => $profile_uid]); - + $user = User::getById($profile_uid, ['allow_cid', 'allow_gid', 'deny_cid', 'deny_gid']); if (!DBA::isResult($user) && !$toplevel_item_id) { return 0; } @@ -235,7 +235,7 @@ function item_post(App $a) { ]; } - $att_bbcode = add_page_info_data($attachment); + $att_bbcode = "\n" . PageInfo::getFooterFromData($attachment); $body .= $att_bbcode; } @@ -252,8 +252,8 @@ function item_post(App $a) { $verb = $orig_post['verb']; $objecttype = $orig_post['object-type']; $app = $orig_post['app']; - $categories = $orig_post['file']; - $title = Strings::escapeTags(trim($_REQUEST['title'])); + $categories = $orig_post['file'] ?? ''; + $title = trim($_REQUEST['title'] ?? ''); $body = trim($body); $private = $orig_post['private']; $pubmail_enabled = $orig_post['pubmail']; @@ -261,26 +261,30 @@ function item_post(App $a) { $guid = $orig_post['guid']; $extid = $orig_post['extid']; } else { - $str_contact_allow = ''; - $str_group_allow = ''; - $str_contact_deny = ''; - $str_group_deny = ''; + $aclFormatter = DI::aclFormatter(); + $str_contact_allow = isset($_REQUEST['contact_allow']) ? $aclFormatter->toString($_REQUEST['contact_allow']) : $user['allow_cid'] ?? ''; + $str_group_allow = isset($_REQUEST['group_allow']) ? $aclFormatter->toString($_REQUEST['group_allow']) : $user['allow_gid'] ?? ''; + $str_contact_deny = isset($_REQUEST['contact_deny']) ? $aclFormatter->toString($_REQUEST['contact_deny']) : $user['deny_cid'] ?? ''; + $str_group_deny = isset($_REQUEST['group_deny']) ? $aclFormatter->toString($_REQUEST['group_deny']) : $user['deny_gid'] ?? ''; - if (($_REQUEST['visibility'] ?? '') !== 'public') { - $aclFormatter = DI::aclFormatter(); - $str_contact_allow = isset($_REQUEST['contact_allow']) ? $aclFormatter->toString($_REQUEST['contact_allow']) : $user['allow_cid'] ?? ''; - $str_group_allow = isset($_REQUEST['group_allow']) ? $aclFormatter->toString($_REQUEST['group_allow']) : $user['allow_gid'] ?? ''; - $str_contact_deny = isset($_REQUEST['contact_deny']) ? $aclFormatter->toString($_REQUEST['contact_deny']) : $user['deny_cid'] ?? ''; - $str_group_deny = isset($_REQUEST['group_deny']) ? $aclFormatter->toString($_REQUEST['group_deny']) : $user['deny_gid'] ?? ''; + $visibility = $_REQUEST['visibility'] ?? ''; + if ($visibility === 'public') { + // The ACL selector introduced in version 2019.12 sends ACL input data even when the Public visibility is selected + $str_contact_allow = $str_group_allow = $str_contact_deny = $str_group_deny = ''; + } else if ($visibility === 'custom') { + // Since we know from the visibility parameter the item should be private, we have to prevent the empty ACL + // case that would make it public. So we always append the author's contact id to the allowed contacts. + // See https://github.com/friendica/friendica/issues/9672 + $str_contact_allow .= $aclFormatter->toString(Contact::getPublicIdByUserId($uid)); } - $title = Strings::escapeTags(trim($_REQUEST['title'] ?? '')); - $location = Strings::escapeTags(trim($_REQUEST['location'] ?? '')); - $coord = Strings::escapeTags(trim($_REQUEST['coord'] ?? '')); - $verb = Strings::escapeTags(trim($_REQUEST['verb'] ?? '')); - $emailcc = Strings::escapeTags(trim($_REQUEST['emailcc'] ?? '')); + $title = trim($_REQUEST['title'] ?? ''); + $location = trim($_REQUEST['location'] ?? ''); + $coord = trim($_REQUEST['coord'] ?? ''); + $verb = trim($_REQUEST['verb'] ?? ''); + $emailcc = trim($_REQUEST['emailcc'] ?? ''); $body = trim($body); - $network = Strings::escapeTags(trim(($_REQUEST['network'] ?? '') ?: Protocol::DFRN)); + $network = trim(($_REQUEST['network'] ?? '') ?: Protocol::DFRN); $guid = System::createUUID(); $postopts = $_REQUEST['postopts'] ?? ''; @@ -326,7 +330,7 @@ function item_post(App $a) { System::jsonExit(['preview' => '']); } - info(DI::l10n()->t('Empty post discarded.')); + notice(DI::l10n()->t('Empty post discarded.')); if ($return_path) { DI::baseUrl()->redirect($return_path); } @@ -369,27 +373,23 @@ function item_post(App $a) { // get contact info for owner if ($profile_uid == local_user() || $allow_comment) { - $contact_record = $author; + $contact_record = $author ?: []; } else { - $contact_record = DBA::selectFirst('contact', [], ['uid' => $profile_uid, 'self' => true]); + $contact_record = DBA::selectFirst('contact', [], ['uid' => $profile_uid, 'self' => true]) ?: []; } // Look for any tags and linkify them $inform = ''; - - $tags = BBCode::getTags($body); - - if ($thread_parent_uriid && !\Friendica\Content\Feature::isEnabled($uid, 'explicit_mentions')) { - $tags = item_add_implicit_mentions($tags, $thread_parent_contact, $thread_parent_uriid); - } - - $tagged = []; - $private_forum = false; + $private_id = null; $only_to_forum = false; $forum_contact = []; - if (count($tags)) { + $body = BBCode::performWithEscapedTags($body, ['noparse', 'pre', 'code', 'img'], function ($body) use ($profile_uid, $network, $str_contact_allow, &$inform, &$private_forum, &$private_id, &$only_to_forum, &$forum_contact) { + $tags = BBCode::getTags($body); + + $tagged = []; + foreach ($tags as $tag) { $tag_type = substr($tag, 0, 1); @@ -397,45 +397,39 @@ function item_post(App $a) { continue; } - /* - * If we already tagged 'Robert Johnson', don't try and tag 'Robert'. + /* If we already tagged 'Robert Johnson', don't try and tag 'Robert'. * Robert Johnson should be first in the $tags array */ - $fullnametagged = false; - /// @TODO $tagged is initialized above if () block and is not filled, maybe old-lost code? foreach ($tagged as $nextTag) { if (stristr($nextTag, $tag . ' ')) { - $fullnametagged = true; - break; + continue 2; } } - if ($fullnametagged) { - continue; - } - $success = handle_tag($body, $inform, local_user() ? local_user() : $profile_uid, $tag, $network); + $success = ItemHelper::replaceTag($body, $inform, local_user() ? local_user() : $profile_uid, $tag, $network); if ($success['replaced']) { $tagged[] = $tag; } // When the forum is private or the forum is addressed with a "!" make the post private - if (is_array($success['contact']) && (!empty($success['contact']['prv']) || ($tag_type == Tag::TAG_CHARACTER[Tag::EXCLUSIVE_MENTION]))) { + if (!empty($success['contact']['prv']) || ($tag_type == Tag::TAG_CHARACTER[Tag::EXCLUSIVE_MENTION])) { $private_forum = $success['contact']['prv']; $only_to_forum = ($tag_type == Tag::TAG_CHARACTER[Tag::EXCLUSIVE_MENTION]); $private_id = $success['contact']['id']; $forum_contact = $success['contact']; - } elseif (is_array($success['contact']) && !empty($success['contact']['forum']) && - ($str_contact_allow == '<' . $success['contact']['id'] . '>')) { + } elseif (!empty($success['contact']['forum']) && ($str_contact_allow == '<' . $success['contact']['id'] . '>')) { $private_forum = false; $only_to_forum = true; $private_id = $success['contact']['id']; $forum_contact = $success['contact']; } } - } + + return $body; + }); $original_contact_id = $contact_id; - if (!$toplevel_item_id && count($forum_contact) && ($private_forum || $only_to_forum)) { + if (!$toplevel_item_id && !empty($forum_contact) && ($private_forum || $only_to_forum)) { // we tagged a forum in a top level post. Now we change the post $private = $private_forum; @@ -535,9 +529,8 @@ function item_post(App $a) { if (strlen($attachments)) { $attachments .= ','; } - $attachments .= '[attach]href="' . DI::baseUrl() . '/attach/' . $attachment['id'] . - '" length="' . $attachment['filesize'] . '" type="' . $attachment['filetype'] . - '" title="' . ($attachment['filename'] ? $attachment['filename'] : '') . '"[/attach]'; + $attachments .= Post\Media::getAttachElement(DI::baseUrl() . '/attach/' . $attachment['id'], + $attachment['filesize'], $attachment['filetype'], $attachment['filename'] ?? ''); } $body = str_replace($match[1],'',$body); } @@ -563,7 +556,7 @@ function item_post(App $a) { $origin = $_REQUEST['origin']; } - $uri = ($message_id ? $message_id : Item::newURI($api_source ? $profile_uid : $uid, $guid)); + $uri = Item::newURI($api_source ? $profile_uid : $uid, $guid); // Fallback so that we alway have a parent uri if (!$thr_parent_uri || !$toplevel_item_id) { @@ -576,9 +569,9 @@ function item_post(App $a) { $datarray['gravity'] = $gravity; $datarray['network'] = $network; $datarray['contact-id'] = $contact_id; - $datarray['owner-name'] = $contact_record['name']; - $datarray['owner-link'] = $contact_record['url']; - $datarray['owner-avatar'] = $contact_record['thumb']; + $datarray['owner-name'] = $contact_record['name'] ?? ''; + $datarray['owner-link'] = $contact_record['url'] ?? ''; + $datarray['owner-avatar'] = $contact_record['thumb'] ?? ''; $datarray['owner-id'] = Contact::getIdForURL($datarray['owner-link']); $datarray['author-name'] = $author['name']; $datarray['author-link'] = $author['url']; @@ -610,8 +603,7 @@ function item_post(App $a) { $datarray['pubmail'] = $pubmail_enabled; $datarray['attach'] = $attachments; - // This is not a bug. The item store function changes 'parent-uri' to 'thr-parent' and fetches 'parent-uri' new. (We should change this) - $datarray['parent-uri'] = $thr_parent_uri; + $datarray['thr-parent'] = $thr_parent_uri; $datarray['postopts'] = $postopts; $datarray['origin'] = $origin; @@ -630,9 +622,10 @@ function item_post(App $a) { $datarray['api_source'] = $api_source; // This field is for storing the raw conversation data - $datarray['protocol'] = Conversation::PARCEL_DFRN; + $datarray['protocol'] = Conversation::PARCEL_DIRECT; + $datarray['direction'] = Conversation::PUSH; - $conversation = DBA::selectFirst('conversation', ['conversation-uri', 'conversation-href'], ['item-uri' => $datarray['parent-uri']]); + $conversation = DBA::selectFirst('conversation', ['conversation-uri', 'conversation-href'], ['item-uri' => $datarray['thr-parent']]); if (DBA::isResult($conversation)) { if ($conversation['conversation-uri'] != '') { $datarray['conversation-uri'] = $conversation['conversation-uri']; @@ -653,7 +646,7 @@ function item_post(App $a) { // Check for hashtags in the body and repair or add hashtag links if ($preview || $orig_post) { - Item::setHashtags($datarray); + $datarray['body'] = Item::setHashtags($datarray['body']); } // preview mode - prepare the body for display and send it via json @@ -661,6 +654,7 @@ function item_post(App $a) { // We set the datarray ID to -1 because in preview mode the dataray // doesn't have an ID. $datarray["id"] = -1; + $datarray["uri-id"] = -1; $datarray["item_id"] = -1; $datarray["author-network"] = Protocol::DFRN; @@ -705,7 +699,6 @@ function item_post(App $a) { // update filetags in pconfig FileTag::updatePconfig($uid, $categories_old, $categories_new, 'category'); - info(DI::l10n()->t('Post updated.')); if ($return_path) { DI::baseUrl()->redirect($return_path); } @@ -727,7 +720,7 @@ function item_post(App $a) { $post_id = Item::insert($datarray); if (!$post_id) { - info(DI::l10n()->t('Item wasn\'t stored.')); + notice(DI::l10n()->t('Item wasn\'t stored.')); if ($return_path) { DI::baseUrl()->redirect($return_path); } @@ -748,46 +741,34 @@ function item_post(App $a) { Tag::storeFromBody($datarray['uri-id'], $datarray['body']); + if (!\Friendica\Content\Feature::isEnabled($uid, 'explicit_mentions') && ($datarray['gravity'] == GRAVITY_COMMENT)) { + Tag::createImplicitMentions($datarray['uri-id'], $datarray['thr-parent-id']); + } + // update filetags in pconfig FileTag::updatePconfig($uid, $categories_old, $categories_new, 'category'); // These notifications are sent if someone else is commenting other your wall - if ($toplevel_item_id) { - if ($contact_record != $author) { + if ($contact_record != $author) { + if ($toplevel_item_id) { notification([ - 'type' => Type::COMMENT, - 'notify_flags' => $user['notify-flags'], - 'language' => $user['language'], - 'to_name' => $user['username'], - 'to_email' => $user['email'], - 'uid' => $user['uid'], - 'item' => $datarray, - 'link' => DI::baseUrl().'/display/'.urlencode($datarray['guid']), - 'source_name' => $datarray['author-name'], - 'source_link' => $datarray['author-link'], - 'source_photo' => $datarray['author-avatar'], - 'verb' => Activity::POST, - 'otype' => 'item', - 'parent' => $toplevel_item_id, - 'parent_uri' => $toplevel_item['uri'] + 'type' => Type::COMMENT, + 'otype' => Notify\ObjectType::ITEM, + 'verb' => Activity::POST, + 'uid' => $profile_uid, + 'cid' => $datarray['author-id'], + 'item' => $datarray, + 'link' => DI::baseUrl() . '/display/' . urlencode($datarray['guid']), ]); - } - } else { - if (($contact_record != $author) && !count($forum_contact)) { + } elseif (empty($forum_contact)) { notification([ - 'type' => Type::WALL, - 'notify_flags' => $user['notify-flags'], - 'language' => $user['language'], - 'to_name' => $user['username'], - 'to_email' => $user['email'], - 'uid' => $user['uid'], - 'item' => $datarray, - 'link' => DI::baseUrl().'/display/'.urlencode($datarray['guid']), - 'source_name' => $datarray['author-name'], - 'source_link' => $datarray['author-link'], - 'source_photo' => $datarray['author-avatar'], - 'verb' => Activity::POST, - 'otype' => 'item' + 'type' => Type::WALL, + 'otype' => Notify\ObjectType::ITEM, + 'verb' => Activity::POST, + 'uid' => $profile_uid, + 'cid' => $datarray['author-id'], + 'item' => $datarray, + 'link' => DI::baseUrl() . '/display/' . urlencode($datarray['guid']), ]); } } @@ -808,12 +789,6 @@ function item_post(App $a) { } } - // Insert an item entry for UID=0 for global entries. - // We now do it in the background to save some time. - // This is important in interactive environments like the frontend or the API. - // We don't fork a new process since this is done anyway with the following command - Worker::add(['priority' => PRIORITY_HIGH, 'dont_fork' => true], "CreateShadowEntry", $post_id); - // When we are doing some forum posting via ! we have to start the notifier manually. // These kind of posts don't initiate the notifier call in the item class. if ($only_to_forum) { @@ -826,7 +801,6 @@ function item_post(App $a) { return $post_id; } - info(DI::l10n()->t('Post published.')); item_post_return(DI::baseUrl(), $api_source, $return_path); // NOTREACHED } @@ -861,7 +835,9 @@ function item_content(App $a) if (($a->argc >= 3) && ($a->argv[1] === 'drop') && intval($a->argv[2])) { if (DI::mode()->isAjax()) { - $o = Item::deleteForUser(['id' => $a->argv[2]], local_user()); + Item::deleteForUser(['id' => $a->argv[2]], local_user()); + // ajax return: [, 0 (no perm) | ] + System::jsonExit([intval($a->argv[2]), local_user()]); } else { if (!empty($a->argv[3])) { $o = drop_item($a->argv[2], $a->argv[3]); @@ -870,163 +846,78 @@ function item_content(App $a) $o = drop_item($a->argv[2]); } } - - if (DI::mode()->isAjax()) { - // ajax return: [, 0 (no perm) | ] - System::jsonExit([intval($a->argv[2]), intval($o)]); - } } return $o; } /** - * This function removes the tag $tag from the text $body and replaces it with - * the appropriate link. - * - * @param App $a - * @param string $body the text to replace the tag in - * @param string $inform a comma-seperated string containing everybody to inform - * @param integer $profile_uid - * @param string $tag the tag to replace - * @param string $network The network of the post - * - * @return array|bool ['replaced' => $replaced, 'contact' => $contact]; - * @throws ImagickException + * @param int $id + * @param string $return + * @return string * @throws HTTPException\InternalServerErrorException */ -function handle_tag(&$body, &$inform, $profile_uid, $tag, $network = "") +function drop_item(int $id, string $return = '') { - $replaced = false; + // locate item to be deleted + $fields = ['id', 'uid', 'guid', 'contact-id', 'deleted', 'gravity', 'parent']; + $item = Item::selectFirstForUser(local_user(), $fields, ['id' => $id]); - //is it a person tag? - if (Tag::isType($tag, Tag::MENTION, Tag::IMPLICIT_MENTION, Tag::EXCLUSIVE_MENTION)) { - $tag_type = substr($tag, 0, 1); - //is it already replaced? - if (strpos($tag, '[url=')) { - // Checking for the alias that is used for OStatus - $pattern = "/[@!]\[url\=(.*?)\](.*?)\[\/url\]/ism"; - if (preg_match($pattern, $tag, $matches)) { - $data = Contact::getDetailsByURL($matches[1]); - - if ($data["alias"] != "") { - $newtag = '@[url=' . $data["alias"] . ']' . $data["nick"] . '[/url]'; - } - } - - return $replaced; - } - - //get the person's name - $name = substr($tag, 1); - - // Sometimes the tag detection doesn't seem to work right - // This is some workaround - $nameparts = explode(" ", $name); - $name = $nameparts[0]; - - // Try to detect the contact in various ways - if (strpos($name, 'http://')) { - // At first we have to ensure that the contact exists - Contact::getIdForURL($name); - - // Now we should have something - $contact = Contact::getDetailsByURL($name); - } elseif (strpos($name, '@')) { - // This function automatically probes when no entry was found - $contact = Contact::getDetailsByAddr($name); - } else { - $contact = false; - $fields = ['id', 'url', 'nick', 'name', 'alias', 'network', 'forum', 'prv']; - - if (strrpos($name, '+')) { - // Is it in format @nick+number? - $tagcid = intval(substr($name, strrpos($name, '+') + 1)); - $contact = DBA::selectFirst('contact', $fields, ['id' => $tagcid, 'uid' => $profile_uid]); - } - - // select someone by nick or attag in the current network - if (!DBA::isResult($contact) && ($network != "")) { - $condition = ["(`nick` = ? OR `attag` = ?) AND `network` = ? AND `uid` = ?", - $name, $name, $network, $profile_uid]; - $contact = DBA::selectFirst('contact', $fields, $condition); - } - - //select someone by name in the current network - if (!DBA::isResult($contact) && ($network != "")) { - $condition = ['name' => $name, 'network' => $network, 'uid' => $profile_uid]; - $contact = DBA::selectFirst('contact', $fields, $condition); - } - - // select someone by nick or attag in any network - if (!DBA::isResult($contact)) { - $condition = ["(`nick` = ? OR `attag` = ?) AND `uid` = ?", $name, $name, $profile_uid]; - $contact = DBA::selectFirst('contact', $fields, $condition); - } - - // select someone by name in any network - if (!DBA::isResult($contact)) { - $condition = ['name' => $name, 'uid' => $profile_uid]; - $contact = DBA::selectFirst('contact', $fields, $condition); - } - } - - // Check if $contact has been successfully loaded - if (DBA::isResult($contact)) { - if (strlen($inform) && (isset($contact["notify"]) || isset($contact["id"]))) { - $inform .= ','; - } - - if (isset($contact["id"])) { - $inform .= 'cid:' . $contact["id"]; - } elseif (isset($contact["notify"])) { - $inform .= $contact["notify"]; - } - - $profile = $contact["url"]; - $newname = ($contact["name"] ?? '') ?: $contact["nick"]; - } - - //if there is an url for this persons profile - if (isset($profile) && ($newname != "")) { - $replaced = true; - // create profile link - $profile = str_replace(',', '%2c', $profile); - $newtag = $tag_type.'[url=' . $profile . ']' . $newname . '[/url]'; - $body = str_replace($tag_type . $name, $newtag, $body); - } + if (!DBA::isResult($item)) { + notice(DI::l10n()->t('Item not found.')); + DI::baseUrl()->redirect('network'); } - return ['replaced' => $replaced, 'contact' => $contact]; -} + if ($item['deleted']) { + return ''; + } -function item_add_implicit_mentions(array $tags, array $thread_parent_contact, $thread_parent_uriid) -{ - if (DI::config()->get('system', 'disable_implicit_mentions')) { - // Add a tag if the parent contact is from ActivityPub or OStatus (This will notify them) - if (in_array($thread_parent_contact['network'], [Protocol::OSTATUS, Protocol::ACTIVITYPUB])) { - $contact = Tag::TAG_CHARACTER[Tag::MENTION] . '[url=' . $thread_parent_contact['url'] . ']' . $thread_parent_contact['nick'] . '[/url]'; - if (!stripos(implode($tags), '[url=' . $thread_parent_contact['url'] . ']')) { - $tags[] = $contact; + $contact_id = 0; + + // check if logged in user is either the author or owner of this item + if (Session::getRemoteContactID($item['uid']) == $item['contact-id']) { + $contact_id = $item['contact-id']; + } + + if ((local_user() == $item['uid']) || $contact_id) { + if (!empty($item['parent'])) { + $parentitem = Item::selectFirstForUser(local_user(), ['guid'], ['id' => $item['parent']]); + } + + // delete the item + Item::deleteForUser(['id' => $item['id']], local_user()); + + $return_url = hex2bin($return); + + // removes update_* from return_url to ignore Ajax refresh + $return_url = str_replace("update_", "", $return_url); + + // Check if delete a comment + if ($item['gravity'] == GRAVITY_COMMENT) { + // Return to parent guid + if (!empty($parentitem)) { + DI::baseUrl()->redirect('display/' . $parentitem['guid']); + //NOTREACHED + } // In case something goes wrong + else { + DI::baseUrl()->redirect('network'); + //NOTREACHED + } + } else { + // if unknown location or deleting top level post called from display + if (empty($return_url) || strpos($return_url, 'display') !== false) { + DI::baseUrl()->redirect('network'); + //NOTREACHED + } else { + DI::baseUrl()->redirect($return_url); + //NOTREACHED } } } else { - $implicit_mentions = [ - $thread_parent_contact['url'] => $thread_parent_contact['nick'] - ]; - - $parent_terms = Tag::getByURIId($thread_parent_uriid, [Tag::MENTION, Tag::IMPLICIT_MENTION]); - - foreach ($parent_terms as $parent_term) { - $implicit_mentions[$parent_term['url']] = $parent_term['name']; - } - - foreach ($implicit_mentions as $url => $label) { - if ($url != \Friendica\Model\Profile::getMyURL() && !stripos(implode($tags), '[url=' . $url . ']')) { - $tags[] = Tag::TAG_CHARACTER[Tag::IMPLICIT_MENTION] . '[url=' . $url . ']' . $label . '[/url]'; - } - } + notice(DI::l10n()->t('Permission denied.')); + DI::baseUrl()->redirect('display/' . $item['guid']); + //NOTREACHED } - return $tags; + return ''; } diff --git a/mod/lockview.php b/mod/lockview.php deleted file mode 100644 index e48debfc6b..0000000000 --- a/mod/lockview.php +++ /dev/null @@ -1,161 +0,0 @@ -. - * - */ - -use Friendica\App; -use Friendica\Core\Hook; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Model\Group; -use Friendica\Model\Item; - -function lockview_content(App $a) -{ - $type = (($a->argc > 1) ? $a->argv[1] : 0); - if (is_numeric($type)) { - $item_id = intval($type); - $type = 'item'; - } else { - $item_id = (($a->argc > 2) ? intval($a->argv[2]) : 0); - } - - if (!$item_id) { - exit(); - } - - if (!in_array($type, ['item','photo','event'])) { - exit(); - } - - $fields = ['uid', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid']; - $condition = ['id' => $item_id]; - - if ($type != 'item') { - $item = DBA::selectFirst($type, $fields, $condition); - } else { - $fields[] = 'private'; - $item = Item::selectFirst($fields, $condition); - } - - if (!DBA::isResult($item)) { - exit(); - } - - Hook::callAll('lockview_content', $item); - - if ($item['uid'] != local_user()) { - echo DI::l10n()->t('Remote privacy information not available.') . '
'; - exit(); - } - - if (isset($item['private']) - && $item['private'] == Item::PRIVATE - && empty($item['allow_cid']) - && empty($item['allow_gid']) - && empty($item['deny_cid']) - && empty($item['deny_gid'])) - { - echo DI::l10n()->t('Remote privacy information not available.') . '
'; - exit(); - } - - $aclFormatter = DI::aclFormatter(); - - $allowed_users = $aclFormatter->expand($item['allow_cid']); - $allowed_groups = $aclFormatter->expand($item['allow_gid']); - $deny_users = $aclFormatter->expand($item['deny_cid']); - $deny_groups = $aclFormatter->expand($item['deny_gid']); - - $o = DI::l10n()->t('Visible to:') . '
'; - $l = []; - - if (count($allowed_groups)) { - $key = array_search(Group::FOLLOWERS, $allowed_groups); - if ($key !== false) { - $l[] = '' . DI::l10n()->t('Followers') . ''; - unset($allowed_groups[$key]); - } - - $key = array_search(Group::MUTUALS, $allowed_groups); - if ($key !== false) { - $l[] = '' . DI::l10n()->t('Mutuals') . ''; - unset($allowed_groups[$key]); - } - - - $r = q("SELECT `name` FROM `group` WHERE `id` IN ( %s )", - DBA::escape(implode(', ', $allowed_groups)) - ); - if (DBA::isResult($r)) { - foreach ($r as $rr) { - $l[] = '' . $rr['name'] . ''; - } - } - } - - if (count($allowed_users)) { - $r = q("SELECT `name` FROM `contact` WHERE `id` IN ( %s )", - DBA::escape(implode(', ', $allowed_users)) - ); - if (DBA::isResult($r)) { - foreach ($r as $rr) { - $l[] = $rr['name']; - } - } - } - - if (count($deny_groups)) { - $key = array_search(Group::FOLLOWERS, $deny_groups); - if ($key !== false) { - $l[] = '' . DI::l10n()->t('Followers') . ''; - unset($deny_groups[$key]); - } - - $key = array_search(Group::MUTUALS, $deny_groups); - if ($key !== false) { - $l[] = '' . DI::l10n()->t('Mutuals') . ''; - unset($deny_groups[$key]); - } - - $r = q("SELECT `name` FROM `group` WHERE `id` IN ( %s )", - DBA::escape(implode(', ', $deny_groups)) - ); - if (DBA::isResult($r)) { - foreach ($r as $rr) { - $l[] = '' . $rr['name'] . ''; - } - } - } - - if (count($deny_users)) { - $r = q("SELECT `name` FROM `contact` WHERE `id` IN ( %s )", - DBA::escape(implode(', ', $deny_users)) - ); - if (DBA::isResult($r)) { - foreach ($r as $rr) { - $l[] = '' . $rr['name'] . ''; - } - } - } - - echo $o . implode(', ', $l); - exit(); - -} diff --git a/mod/lostpass.php b/mod/lostpass.php index 211477b0db..01e0006e95 100644 --- a/mod/lostpass.php +++ b/mod/lostpass.php @@ -37,7 +37,7 @@ function lostpass_post(App $a) $condition = ['(`email` = ? OR `nickname` = ?) AND `verified` = 1 AND `blocked` = 0', $loginame, $loginame]; $user = DBA::selectFirst('user', ['uid', 'username', 'nickname', 'email', 'language'], $condition); if (!DBA::isResult($user)) { - notice(DI::l10n()->t('No valid account found.') . EOL); + notice(DI::l10n()->t('No valid account found.')); DI::baseUrl()->redirect(); } @@ -49,7 +49,7 @@ function lostpass_post(App $a) ]; $result = DBA::update('user', $fields, ['uid' => $user['uid']]); if ($result) { - info(DI::l10n()->t('Password reset request issued. Check your email.') . EOL); + info(DI::l10n()->t('Password reset request issued. Check your email.')); } $sitename = DI::config()->get('config', 'sitename'); @@ -152,7 +152,7 @@ function lostpass_generate_password($user) '$newpass' => $new_password, ]); - info("Your password has been reset." . EOL); + info(DI::l10n()->t("Your password has been reset.")); $sitename = DI::config()->get('config', 'sitename'); $preamble = Strings::deindent(DI::l10n()->t(' diff --git a/mod/match.php b/mod/match.php index 47d9879794..cd1c66c891 100644 --- a/mod/match.php +++ b/mod/match.php @@ -27,8 +27,7 @@ use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\Profile; -use Friendica\Util\Network; -use Friendica\Util\Proxy as ProxyUtils; +use Friendica\Module\Contact as ModuleContact; /** * Controller for /match. @@ -60,7 +59,7 @@ function match_content(App $a) return ''; } if (!$profile['pub_keywords'] && (!$profile['prv_keywords'])) { - notice(DI::l10n()->t('No keywords to match. Please add keywords to your profile.') . EOL); + notice(DI::l10n()->t('No keywords to match. Please add keywords to your profile.')); return ''; } @@ -76,7 +75,7 @@ function match_content(App $a) $host = DI::baseUrl(); } - $msearch_json = Network::post($host . '/msearch', $params)->getBody(); + $msearch_json = DI::httpRequest()->post($host . '/msearch', $params)->getBody(); $msearch = json_decode($msearch_json); @@ -89,37 +88,14 @@ function match_content(App $a) $profile = $msearch->results[$i]; // Already known contact - if (!$profile || Contact::getIdForURL($profile->url, local_user(), true)) { + if (!$profile || Contact::getIdForURL($profile->url, local_user())) { continue; } - // Workaround for wrong directory photo URL - $profile->photo = str_replace('http:///photo/', Search::getGlobalDirectory() . '/photo/', $profile->photo); - - $connlnk = DI::baseUrl() . '/follow/?url=' . $profile->url; - $photo_menu = [ - 'profile' => [DI::l10n()->t("View Profile"), Contact::magicLink($profile->url)], - 'follow' => [DI::l10n()->t("Connect/Follow"), $connlnk] - ]; - - $contact_details = Contact::getDetailsByURL($profile->url, 0); - - $entry = [ - 'url' => Contact::magicLink($profile->url), - 'itemurl' => $contact_details['addr'] ?? $profile->url, - 'name' => $profile->name, - 'details' => $contact_details['location'] ?? '', - 'tags' => $contact_details['keywords'] ?? '', - 'about' => $contact_details['about'] ?? '', - 'account_type' => Contact::getAccountType($contact_details), - 'thumb' => ProxyUtils::proxifyUrl($profile->photo, false, ProxyUtils::SIZE_THUMB), - 'conntxt' => DI::l10n()->t('Connect'), - 'connlnk' => $connlnk, - 'img_hover' => $profile->tags, - 'photo_menu' => $photo_menu, - 'id' => $i, - ]; - $entries[] = $entry; + $contact = Contact::getByURLForUser($profile->url, local_user()); + if (!empty($contact)) { + $entries[] = ModuleContact::getContactTemplateVars($contact); + } } $data = [ @@ -141,7 +117,7 @@ function match_content(App $a) } if (empty($entries)) { - info(DI::l10n()->t('No matches') . EOL); + info(DI::l10n()->t('No matches')); } $tpl = Renderer::getMarkupTemplate('viewcontact_template.tpl'); diff --git a/mod/message.php b/mod/message.php index c024cbe144..f12f54015f 100644 --- a/mod/message.php +++ b/mod/message.php @@ -32,7 +32,6 @@ use Friendica\Model\Mail; use Friendica\Model\Notify\Type; use Friendica\Module\Security\Login; use Friendica\Util\DateTimeFormat; -use Friendica\Util\Proxy as ProxyUtils; use Friendica\Util\Strings; use Friendica\Util\Temporal; @@ -68,34 +67,32 @@ function message_init(App $a) function message_post(App $a) { if (!local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } $replyto = !empty($_REQUEST['replyto']) ? Strings::escapeTags(trim($_REQUEST['replyto'])) : ''; $subject = !empty($_REQUEST['subject']) ? Strings::escapeTags(trim($_REQUEST['subject'])) : ''; $body = !empty($_REQUEST['body']) ? Strings::escapeHtml(trim($_REQUEST['body'])) : ''; - $recipient = !empty($_REQUEST['messageto']) ? intval($_REQUEST['messageto']) : 0; + $recipient = !empty($_REQUEST['recipient']) ? intval($_REQUEST['recipient']) : 0; $ret = Mail::send($recipient, $body, $subject, $replyto); $norecip = false; switch ($ret) { case -1: - notice(DI::l10n()->t('No recipient selected.') . EOL); + notice(DI::l10n()->t('No recipient selected.')); $norecip = true; break; case -2: - notice(DI::l10n()->t('Unable to locate contact information.') . EOL); + notice(DI::l10n()->t('Unable to locate contact information.')); break; case -3: - notice(DI::l10n()->t('Message could not be sent.') . EOL); + notice(DI::l10n()->t('Message could not be sent.')); break; case -4: - notice(DI::l10n()->t('Message collection failure.') . EOL); + notice(DI::l10n()->t('Message collection failure.')); break; - default: - info(DI::l10n()->t('Message sent.') . EOL); } // fake it to go back to the input form if no recipient listed @@ -113,7 +110,7 @@ function message_content(App $a) Nav::setSelected('messages'); if (!local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return Login::form(); } @@ -144,51 +141,20 @@ function message_content(App $a) return; } - // Check if we should do HTML-based delete confirmation - if (!empty($_REQUEST['confirm'])) { - // can't take arguments in its "action" parameter - // so add any arguments as hidden inputs - $query = explode_querystring(DI::args()->getQueryString()); - $inputs = []; - foreach ($query['args'] as $arg) { - if (strpos($arg, 'confirm=') === false) { - $arg_parts = explode('=', $arg); - $inputs[] = ['name' => $arg_parts[0], 'value' => $arg_parts[1]]; - } - } - - //DI::page()['aside'] = ''; - return Renderer::replaceMacros(Renderer::getMarkupTemplate('confirm.tpl'), [ - '$method' => 'get', - '$message' => DI::l10n()->t('Do you really want to delete this message?'), - '$extra_inputs' => $inputs, - '$confirm' => DI::l10n()->t('Yes'), - '$confirm_url' => $query['base'], - '$confirm_name' => 'confirmed', - '$cancel' => DI::l10n()->t('Cancel'), - ]); - } - - // Now check how the user responded to the confirmation query - if (!empty($_REQUEST['canceled'])) { - DI::baseUrl()->redirect('message'); - } - $cmd = $a->argv[1]; if ($cmd === 'drop') { $message = DBA::selectFirst('mail', ['convid'], ['id' => $a->argv[2], 'uid' => local_user()]); if(!DBA::isResult($message)){ - info(DI::l10n()->t('Conversation not found.') . EOL); + notice(DI::l10n()->t('Conversation not found.')); DI::baseUrl()->redirect('message'); } - if (DBA::delete('mail', ['id' => $a->argv[2], 'uid' => local_user()])) { - info(DI::l10n()->t('Message deleted.') . EOL); + if (!DBA::delete('mail', ['id' => $a->argv[2], 'uid' => local_user()])) { + notice(DI::l10n()->t('Message was not deleted.')); } $conversation = DBA::selectFirst('mail', ['id'], ['convid' => $message['convid'], 'uid' => local_user()]); if(!DBA::isResult($conversation)){ - info(DI::l10n()->t('Conversation removed.') . EOL); DI::baseUrl()->redirect('message'); } @@ -201,8 +167,8 @@ function message_content(App $a) if (DBA::isResult($r)) { $parent = $r[0]['parent-uri']; - if (DBA::delete('mail', ['parent-uri' => $parent, 'uid' => local_user()])) { - info(DI::l10n()->t('Conversation removed.') . EOL); + if (!DBA::delete('mail', ['parent-uri' => $parent, 'uid' => local_user()])) { + notice(DI::l10n()->t('Conversation was not removed.')); } } DI::baseUrl()->redirect('message'); @@ -219,50 +185,14 @@ function message_content(App $a) '$linkurl' => DI::l10n()->t('Please enter a link URL:') ]); - $preselect = isset($a->argv[2]) ? [$a->argv[2]] : []; + $recipientId = $a->argv[2] ?? null; - $prename = $preurl = $preid = ''; - - if ($preselect) { - $r = q("SELECT `name`, `url`, `id` FROM `contact` WHERE `uid` = %d AND `id` = %d LIMIT 1", - intval(local_user()), - intval($a->argv[2]) - ); - if (!DBA::isResult($r)) { - $r = q("SELECT `name`, `url`, `id` FROM `contact` WHERE `uid` = %d AND `nurl` = '%s' LIMIT 1", - intval(local_user()), - DBA::escape(Strings::normaliseLink(base64_decode($a->argv[2]))) - ); - } - - if (!DBA::isResult($r)) { - $r = q("SELECT `name`, `url`, `id` FROM `contact` WHERE `uid` = %d AND `addr` = '%s' LIMIT 1", - intval(local_user()), - DBA::escape(base64_decode($a->argv[2])) - ); - } - - if (DBA::isResult($r)) { - $prename = $r[0]['name']; - $preid = $r[0]['id']; - $preselect = [$preid]; - } else { - $preselect = []; - } - } - - $prefill = $preselect ? $prename : ''; - - // the ugly select box - $select = ACL::getMessageContactSelectHTML('messageto', 'message-to-select', $preselect, 4, 10); + $select = ACL::getMessageContactSelectHTML($recipientId); $tpl = Renderer::getMarkupTemplate('prv_message.tpl'); $o .= Renderer::replaceMacros($tpl, [ '$header' => DI::l10n()->t('Send Private Message'), '$to' => DI::l10n()->t('To:'), - '$showinputs' => 'true', - '$prefill' => $prefill, - '$preid' => $preid, '$subject' => DI::l10n()->t('Subject:'), '$subjtxt' => $_REQUEST['subject'] ?? '', '$text' => $_REQUEST['body'] ?? '', @@ -301,7 +231,7 @@ function message_content(App $a) $r = get_messages(local_user(), $pager->getStart(), $pager->getItemsPerPage()); if (!DBA::isResult($r)) { - info(DI::l10n()->t('No messages.') . EOL); + notice(DI::l10n()->t('No messages.')); return $o; } @@ -352,13 +282,12 @@ function message_content(App $a) $messages = DBA::toArray($messages_stmt); DBA::update('mail', ['seen' => 1], ['parent-uri' => $message['parent-uri'], 'uid' => local_user()]); - DBA::update('notify', ['seen' => 1], ['type' => Type::MAIL, 'parent' => $message['id'], 'uid' => local_user()]); } else { $messages = false; } if (!DBA::isResult($messages)) { - notice(DI::l10n()->t('Message not available.') . EOL); + notice(DI::l10n()->t('Message not available.')); return $o; } @@ -396,20 +325,16 @@ function message_content(App $a) $body_e = BBCode::convert($message['body']); $to_name_e = $message['name']; - $contact = Contact::getDetailsByURL($message['from-url']); - if (isset($contact["thumb"])) { - $from_photo = $contact["thumb"]; - } else { - $from_photo = $message['from-photo']; - } + $contact = Contact::getByURL($message['from-url'], false, ['thumb', 'addr', 'id', 'avatar']); + $from_photo = Contact::getThumb($contact, $message['from-photo']); $mails[] = [ 'id' => $message['id'], 'from_name' => $from_name_e, 'from_url' => $from_url, - 'from_addr' => $contact['addr'], + 'from_addr' => $contact['addr'] ?? $from_url, 'sparkle' => $sparkle, - 'from_photo' => ProxyUtils::proxifyUrl($from_photo, false, ProxyUtils::SIZE_THUMB), + 'from_photo' => $from_photo, 'subject' => $subject_e, 'body' => $body_e, 'delete' => DI::l10n()->t('Delete message'), @@ -421,7 +346,7 @@ function message_content(App $a) $seen = $message['seen']; } - $select = $message['name'] . ''; + $select = $message['name'] . ''; $parent = ''; $tpl = Renderer::getMarkupTemplate('mail_display.tpl'); @@ -437,7 +362,6 @@ function message_content(App $a) // reply '$header' => DI::l10n()->t('Send Reply'), '$to' => DI::l10n()->t('To:'), - '$showinputs' => '', '$subject' => DI::l10n()->t('Subject:'), '$subjtxt' => $message['title'], '$readonly' => ' readonly="readonly" style="background: #BBBBBB;" ', @@ -461,7 +385,7 @@ function message_content(App $a) * @param int $limit * @return array */ -function get_messages($uid, $start, $limit) +function get_messages(int $uid, int $start, int $limit) { return DBA::toArray(DBA::p('SELECT m.`id`, @@ -528,12 +452,8 @@ function render_messages(array $msg, $t) $body_e = $rr['body']; $to_name_e = $rr['name']; - $contact = Contact::getDetailsByURL($rr['url']); - if (isset($contact["thumb"])) { - $from_photo = $contact["thumb"]; - } else { - $from_photo = (($rr['thumb']) ? $rr['thumb'] : $rr['from-photo']); - } + $contact = Contact::getByURL($rr['url'], false, ['thumb', 'addr', 'id', 'avatar']); + $from_photo = Contact::getThumb($contact, $rr['thumb'] ?: $rr['from-photo']); $rslt .= Renderer::replaceMacros($tpl, [ '$id' => $rr['id'], @@ -541,7 +461,7 @@ function render_messages(array $msg, $t) '$from_url' => Contact::magicLink($rr['url']), '$from_addr' => $contact['addr'] ?? '', '$sparkle' => ' sparkle', - '$from_photo' => ProxyUtils::proxifyUrl($from_photo, false, ProxyUtils::SIZE_THUMB), + '$from_photo' => $from_photo, '$subject' => $rr['title'], '$delete' => DI::l10n()->t('Delete conversation'), '$body' => $body_e, diff --git a/mod/network.php b/mod/network.php deleted file mode 100644 index b6afda608b..0000000000 --- a/mod/network.php +++ /dev/null @@ -1,1004 +0,0 @@ -. - * - */ - -use Friendica\App; -use Friendica\Content\Feature; -use Friendica\Content\ForumManager; -use Friendica\Content\Nav; -use Friendica\Content\Pager; -use Friendica\Content\Widget; -use Friendica\Content\Text\HTML; -use Friendica\Core\ACL; -use Friendica\Core\Hook; -use Friendica\Core\Logger; -use Friendica\Core\Protocol; -use Friendica\Core\Renderer; -use Friendica\Core\Session; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Model\Contact; -use Friendica\Model\Group; -use Friendica\Model\Item; -use Friendica\Model\Post\Category; -use Friendica\Model\Profile; -use Friendica\Module\Security\Login; -use Friendica\Util\DateTimeFormat; -use Friendica\Util\Proxy as ProxyUtils; -use Friendica\Util\Strings; - -function network_init(App $a) -{ - if (!local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); - return; - } - - Hook::add('head', __FILE__, 'network_infinite_scroll_head'); - - $is_a_date_query = false; - - $group_id = (($a->argc > 1 && is_numeric($a->argv[1])) ? intval($a->argv[1]) : 0); - - $cid = 0; - if (!empty($_GET['cid'])) { - $cid = $_GET['cid']; - $_GET['nets'] = ''; - $group_id = 0; - } - - if ($a->argc > 1) { - for ($x = 1; $x < $a->argc; $x ++) { - if (DI::dtFormat()->isYearMonthDay($a->argv[$x])) { - $is_a_date_query = true; - break; - } - } - } - - // convert query string to array. remove friendica args - $query_array = []; - parse_str(parse_url(DI::args()->getQueryString(), PHP_URL_QUERY), $query_array); - - // fetch last used network view and redirect if needed - if (!$is_a_date_query) { - $sel_nets = $_GET['nets'] ?? ''; - $sel_tabs = network_query_get_sel_tab($a); - $sel_groups = network_query_get_sel_group($a); - $last_sel_tabs = DI::pConfig()->get(local_user(), 'network.view', 'tab.selected'); - - $remember_tab = ($sel_tabs[0] === 'active' && is_array($last_sel_tabs) && $last_sel_tabs[0] !== 'active'); - - $net_baseurl = '/network'; - $net_args = []; - - if ($sel_groups !== false) { - $net_baseurl .= '/' . $sel_groups; - } - - if ($remember_tab) { - // redirect if current selected tab is '/network' and - // last selected tab is _not_ '/network?order=activity'. - // and this isn't a date query - - $tab_args = [ - 'order=activity', //all - 'order=post', //postord - 'conv=1', //conv - 'new=1', //new - 'star=1', //starred - 'bmark=1', //bookmarked - ]; - - $k = array_search('active', $last_sel_tabs); - - if ($k != 3) { - // parse out tab queries - $dest_qa = []; - $dest_qs = $tab_args[$k]; - parse_str($dest_qs, $dest_qa); - $net_args = array_merge($net_args, $dest_qa); - } else { - $remember_tab = false; - } - } - - if ($sel_nets) { - $net_args['nets'] = $sel_nets; - } - - if ($remember_tab) { - $net_args = array_merge($query_array, $net_args); - $net_queries = http_build_query($net_args); - - $redir_url = ($net_queries ? $net_baseurl . '?' . $net_queries : $net_baseurl); - - DI::baseUrl()->redirect($redir_url); - } - } - - if (empty(DI::page()['aside'])) { - DI::page()['aside'] = ''; - } - - DI::page()['aside'] .= Group::sidebarWidget('network/0', 'network', 'standard', $group_id); - DI::page()['aside'] .= ForumManager::widget(local_user(), $cid); - DI::page()['aside'] .= Widget::postedByYear('network', local_user(), false); - DI::page()['aside'] .= Widget::networks('network', $_GET['nets'] ?? ''); - DI::page()['aside'] .= Widget\SavedSearches::getHTML(DI::args()->getQueryString()); - DI::page()['aside'] .= Widget::fileAs('network', $_GET['file'] ?? ''); -} - -/** - * Return selected tab from query - * - * urls -> returns - * '/network' => $no_active = 'active' - * '/network?order=activity' => $activity_active = 'active' - * '/network?order=post' => $postord_active = 'active' - * '/network?conv=1', => $conv_active = 'active' - * '/network?new=1', => $new_active = 'active' - * '/network?star=1', => $starred_active = 'active' - * '/network?bmark=1', => $bookmarked_active = 'active' - * - * @param App $a - * @return array ($no_active, $activity_active, $postord_active, $conv_active, $new_active, $starred_active, $bookmarked_active); - */ -function network_query_get_sel_tab(App $a) -{ - $no_active = ''; - $starred_active = ''; - $new_active = ''; - $bookmarked_active = ''; - $all_active = ''; - $conv_active = ''; - $postord_active = ''; - - if (!empty($_GET['new'])) { - $new_active = 'active'; - } - - if (!empty($_GET['star'])) { - $starred_active = 'active'; - } - - if (!empty($_GET['bmark'])) { - $bookmarked_active = 'active'; - } - - if (!empty($_GET['conv'])) { - $conv_active = 'active'; - } - - if (($new_active == '') && ($starred_active == '') && ($bookmarked_active == '') && ($conv_active == '')) { - $no_active = 'active'; - } - - if ($no_active == 'active' && !empty($_GET['order'])) { - switch($_GET['order']) { - case 'post' : $postord_active = 'active'; $no_active=''; break; - case 'activity' : $all_active = 'active'; $no_active=''; break; - } - } - - return [$no_active, $all_active, $postord_active, $conv_active, $new_active, $starred_active, $bookmarked_active]; -} - -function network_query_get_sel_group(App $a) -{ - $group = false; - - if ($a->argc >= 2 && is_numeric($a->argv[1])) { - $group = $a->argv[1]; - } - - return $group; -} - -/** - * Sets the pager data and returns SQL - * - * @param App $a The global App - * @param Pager $pager - * @param integer $update Used for the automatic reloading - * @return string SQL with the appropriate LIMIT clause - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ -function networkPager(App $a, Pager $pager, $update) -{ - if ($update) { - // only setup pagination on initial page view - return ' LIMIT 100'; - } - - if (DI::mode()->isMobile()) { - $itemspage_network = DI::pConfig()->get(local_user(), 'system', 'itemspage_mobile_network', - DI::config()->get('system', 'itemspage_network_mobile')); - } else { - $itemspage_network = DI::pConfig()->get(local_user(), 'system', 'itemspage_network', - DI::config()->get('system', 'itemspage_network')); - } - - // now that we have the user settings, see if the theme forces - // a maximum item number which is lower then the user choice - if (($a->force_max_items > 0) && ($a->force_max_items < $itemspage_network)) { - $itemspage_network = $a->force_max_items; - } - - $pager->setItemsPerPage($itemspage_network); - - return sprintf(" LIMIT %d, %d ", $pager->getStart(), $pager->getItemsPerPage()); -} - -/** - * Sets items as seen - * - * @param array $condition The array with the SQL condition - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ -function networkSetSeen($condition) -{ - if (empty($condition)) { - return; - } - - $unseen = Item::exists($condition); - - if ($unseen) { - Item::update(['unseen' => false], $condition); - } -} - -/** - * Create the conversation HTML - * - * @param App $a The global App - * @param array $items Items of the conversation - * @param Pager $pager - * @param string $mode Display mode for the conversation - * @param integer $update Used for the automatic reloading - * @param string $ordering - * @return string HTML of the conversation - * @throws ImagickException - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ -function networkConversation(App $a, $items, Pager $pager, $mode, $update, $ordering = '') -{ - // Set this so that the conversation function can find out contact info for our wall-wall items - $a->page_contact = $a->contact; - - if (!is_array($items)) { - Logger::log("Expecting items to be an array. Got " . print_r($items, true)); - $items = []; - } - - $o = conversation($a, $items, $mode, $update, false, $ordering, local_user()); - - if (!$update) { - if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll')) { - $o .= HTML::scrollLoader(); - } else { - $o .= $pager->renderMinimal(count($items)); - } - } - - return $o; -} - -function network_content(App $a, $update = 0, $parent = 0) -{ - if (!local_user()) { - return Login::form(); - } - - /// @TODO Is this really necessary? $a is already available to hooks - $arr = ['query' => DI::args()->getQueryString()]; - Hook::callAll('network_content_init', $arr); - - if (!empty($_GET['new']) || !empty($_GET['file'])) { - $o = networkFlatView($a, $update); - } else { - $o = networkThreadedView($a, $update, $parent); - } - - if ($o === '') { - info("No items found"); - } - - return $o; -} - -/** - * Get the network content in flat view - * - * @param App $a The global App - * @param integer $update Used for the automatic reloading - * @return string HTML of the network content in flat view - * @throws ImagickException - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @global Pager $pager - */ -function networkFlatView(App $a, $update = 0) -{ - global $pager; - // Rawmode is used for fetching new content at the end of the page - $rawmode = (isset($_GET['mode']) && ($_GET['mode'] == 'raw')); - - $o = ''; - - $file = $_GET['file'] ?? ''; - - if (!$update && !$rawmode) { - $tabs = network_tabs($a); - $o .= $tabs; - - Nav::setSelected('network'); - - $x = [ - 'is_owner' => true, - 'allow_location' => $a->user['allow_location'], - 'default_location' => $a->user['default-location'], - 'nickname' => $a->user['nickname'], - 'lockstate' => (is_array($a->user) && - (strlen($a->user['allow_cid']) || strlen($a->user['allow_gid']) || - strlen($a->user['deny_cid']) || strlen($a->user['deny_gid'])) ? 'lock' : 'unlock'), - 'default_perms' => ACL::getDefaultUserPermissions($a->user), - 'acl' => ACL::getFullSelectorHTML(DI::page(), $a->user, true), - 'bang' => '', - 'visitor' => 'block', - 'profile_uid' => local_user(), - 'content' => '', - ]; - - $o .= status_editor($a, $x); - - if (!DI::config()->get('theme', 'hide_eventlist')) { - $o .= Profile::getBirthdays(); - $o .= Profile::getEventsReminderHTML(); - } - } - - $pager = new Pager(DI::l10n(), DI::args()->getQueryString()); - - networkPager($a, $pager, $update); - - - if (strlen($file)) { - $item_params = ['order' => ['uri-id' => true]]; - $term_condition = ['name' => $file, 'type' => Category::FILE, 'uid' => local_user()]; - $term_params = ['order' => ['tid' => true], 'limit' => [$pager->getStart(), $pager->getItemsPerPage()]]; - $result = DBA::select('category-view', ['uri-id'], $term_condition, $term_params); - - $posts = []; - while ($term = DBA::fetch($result)) { - $posts[] = $term['uri-id']; - } - DBA::close($result); - - if (count($posts) == 0) { - return ''; - } - $item_condition = ['uid' => local_user(), 'uri-id' => $posts]; - } else { - $item_params = ['order' => ['id' => true]]; - $item_condition = ['uid' => local_user()]; - $item_params['limit'] = [$pager->getStart(), $pager->getItemsPerPage()]; - - networkSetSeen(['unseen' => true, 'uid' => local_user()]); - } - - $result = Item::selectForUser(local_user(), [], $item_condition, $item_params); - $items = Item::inArray($result); - $o .= networkConversation($a, $items, $pager, 'network-new', $update); - - return $o; -} - -/** - * Get the network content in threaded view - * - * @param App $a The global App - * @param integer $update Used for the automatic reloading - * @param integer $parent - * @return string HTML of the network content in flat view - * @throws ImagickException - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @global Pager $pager - */ -function networkThreadedView(App $a, $update, $parent) -{ - /// @TODO this will have to be converted to a static property of the converted Module\Network class - global $pager; - - // Rawmode is used for fetching new content at the end of the page - $rawmode = (isset($_GET['mode']) AND ( $_GET['mode'] == 'raw')); - - if (isset($_GET['last_received']) && isset($_GET['last_commented']) && isset($_GET['last_created']) && isset($_GET['last_id'])) { - $last_received = DateTimeFormat::utc($_GET['last_received']); - $last_commented = DateTimeFormat::utc($_GET['last_commented']); - $last_created = DateTimeFormat::utc($_GET['last_created']); - $last_id = intval($_GET['last_id']); - } else { - $last_received = ''; - $last_commented = ''; - $last_created = ''; - $last_id = 0; - } - - $datequery = $datequery2 = ''; - - $gid = 0; - - $default_permissions = []; - - if ($a->argc > 1) { - for ($x = 1; $x < $a->argc; $x ++) { - if (DI::dtFormat()->isYearMonthDay($a->argv[$x])) { - if ($datequery) { - $datequery2 = Strings::escapeHtml($a->argv[$x]); - } else { - $datequery = Strings::escapeHtml($a->argv[$x]); - $_GET['order'] = 'post'; - } - } elseif (intval($a->argv[$x])) { - $gid = intval($a->argv[$x]); - $default_permissions['allow_gid'] = [$gid]; - } - } - } - - $o = ''; - - $cid = intval($_GET['cid'] ?? 0); - $star = intval($_GET['star'] ?? 0); - $bmark = intval($_GET['bmark'] ?? 0); - $conv = intval($_GET['conv'] ?? 0); - $order = Strings::escapeTags(($_GET['order'] ?? '') ?: 'activity'); - $nets = $_GET['nets'] ?? ''; - - $allowedCids = []; - if ($cid) { - $allowedCids[] = (int) $cid; - } elseif ($nets) { - $condition = [ - 'uid' => local_user(), - 'network' => $nets, - 'self' => false, - 'blocked' => false, - 'pending' => false, - 'archive' => false, - 'rel' => [Contact::SHARING, Contact::FRIEND], - ]; - $contactStmt = DBA::select('contact', ['id'], $condition); - while ($contact = DBA::fetch($contactStmt)) { - $allowedCids[] = (int) $contact['id']; - } - DBA::close($contactStmt); - } - - if (count($allowedCids)) { - $default_permissions['allow_cid'] = $allowedCids; - } - - if (!$update && !$rawmode) { - $tabs = network_tabs($a); - $o .= $tabs; - - Nav::setSelected('network'); - - $content = ''; - - if ($cid) { - // If $cid belongs to a communitity forum or a privat goup,.add a mention to the status editor - $condition = ["`id` = ? AND (`forum` OR `prv`)", $cid]; - $contact = DBA::selectFirst('contact', ['addr', 'nick'], $condition); - if (DBA::isResult($contact)) { - if ($contact['addr'] != '') { - $content = '!' . $contact['addr']; - } else { - $content = '!' . $contact['nick'] . '+' . $cid; - } - } - } - - $x = [ - 'is_owner' => true, - 'allow_location' => $a->user['allow_location'], - 'default_location' => $a->user['default-location'], - 'nickname' => $a->user['nickname'], - 'lockstate' => ($gid || $cid || $nets || (is_array($a->user) && - (strlen($a->user['allow_cid']) || strlen($a->user['allow_gid']) || - strlen($a->user['deny_cid']) || strlen($a->user['deny_gid']))) ? 'lock' : 'unlock'), - 'default_perms' => ACL::getDefaultUserPermissions($a->user), - 'acl' => ACL::getFullSelectorHTML(DI::page(), $a->user, true, $default_permissions), - 'bang' => (($gid || $cid || $nets) ? '!' : ''), - 'visitor' => 'block', - 'profile_uid' => local_user(), - 'content' => $content, - ]; - - $o .= status_editor($a, $x); - } - - // We don't have to deal with ACLs on this page. You're looking at everything - // that belongs to you, hence you can see all of it. We will filter by group if - // desired. - - $sql_post_table = ''; - $sql_options = ($star ? " AND `thread`.`starred` " : ''); - $sql_options .= ($bmark ? sprintf(" AND `thread`.`post-type` = %d ", Item::PT_PAGE) : ''); - $sql_extra = $sql_options; - $sql_extra2 = ''; - $sql_extra3 = ''; - $sql_table = '`thread`'; - $sql_parent = '`iid`'; - - if ($update) { - $sql_table = '`item`'; - $sql_parent = '`parent`'; - $sql_post_table = " INNER JOIN `thread` ON `thread`.`iid` = `item`.`parent`"; - } - - $sql_nets = (($nets) ? sprintf(" AND $sql_table.`network` = '%s' ", DBA::escape($nets)) : ''); - $sql_tag_nets = (($nets) ? sprintf(" AND `item`.`network` = '%s' ", DBA::escape($nets)) : ''); - - if ($gid) { - $group = DBA::selectFirst('group', ['name'], ['id' => $gid, 'uid' => local_user()]); - if (!DBA::isResult($group)) { - if ($update) { - exit(); - } - notice(DI::l10n()->t('No such group') . EOL); - DI::baseUrl()->redirect('network/0'); - // NOTREACHED - } - - $contacts = Group::expand(local_user(), [$gid]); - - if ((is_array($contacts)) && count($contacts)) { - $contact_str_self = ''; - - $contact_str = implode(',', $contacts); - $self = DBA::selectFirst('contact', ['id'], ['uid' => local_user(), 'self' => true]); - if (DBA::isResult($self)) { - $contact_str_self = $self['id']; - } - - $sql_post_table .= " INNER JOIN `item` AS `temp1` ON `temp1`.`id` = " . $sql_table . "." . $sql_parent; - $sql_extra3 .= " AND (`thread`.`contact-id` IN ($contact_str) "; - $sql_extra3 .= " OR (`thread`.`contact-id` = '$contact_str_self' AND `temp1`.`allow_gid` LIKE '" . Strings::protectSprintf('%<' . intval($gid) . '>%') . "' AND `temp1`.`private`))"; - } else { - $sql_extra3 .= " AND false "; - info(DI::l10n()->t('Group is empty')); - } - - $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('section_title.tpl'), [ - '$title' => DI::l10n()->t('Group: %s', $group['name']) - ]) . $o; - } elseif ($cid) { - $fields = ['id', 'name', 'network', 'writable', 'nurl', - 'forum', 'prv', 'contact-type', 'addr', 'thumb', 'location']; - $condition = ["`id` = ? AND (NOT `blocked` OR `pending`)", $cid]; - $contact = DBA::selectFirst('contact', $fields, $condition); - if (DBA::isResult($contact)) { - $sql_extra = " AND " . $sql_table . ".`contact-id` = " . intval($cid); - - $entries[0] = [ - 'id' => 'network', - 'name' => $contact['name'], - 'itemurl' => ($contact['addr'] ?? '') ?: $contact['nurl'], - 'thumb' => ProxyUtils::proxifyUrl($contact['thumb'], false, ProxyUtils::SIZE_THUMB), - 'details' => $contact['location'], - ]; - - $entries[0]['account_type'] = Contact::getAccountType($contact); - - $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('viewcontact_template.tpl'), [ - 'contacts' => $entries, - 'id' => 'network', - ]) . $o; - } else { - notice(DI::l10n()->t('Invalid contact.') . EOL); - DI::baseUrl()->redirect('network'); - // NOTREACHED - } - } - - if (!$gid && !$cid && !$update && !DI::config()->get('theme', 'hide_eventlist')) { - $o .= Profile::getBirthdays(); - $o .= Profile::getEventsReminderHTML(); - } - - if ($datequery) { - $sql_extra3 .= Strings::protectSprintf(sprintf(" AND $sql_table.received <= '%s' ", - DBA::escape(DateTimeFormat::convert($datequery, 'UTC', date_default_timezone_get())))); - } - if ($datequery2) { - $sql_extra3 .= Strings::protectSprintf(sprintf(" AND $sql_table.received >= '%s' ", - DBA::escape(DateTimeFormat::convert($datequery2, 'UTC', date_default_timezone_get())))); - } - - if ($conv) { - $sql_extra3 .= " AND $sql_table.`mention`"; - } - - // Normal conversation view - if ($order === 'post') { - $ordering = '`received`'; - $order_mode = 'received'; - } else { - $ordering = '`commented`'; - $order_mode = 'commented'; - } - - $sql_order = "$sql_table.$ordering"; - - if (!empty($_GET['offset'])) { - $sql_range = sprintf(" AND $sql_order <= '%s'", DBA::escape($_GET['offset'])); - } else { - $sql_range = ''; - } - - $pager = new Pager(DI::l10n(), DI::args()->getQueryString()); - - $pager_sql = networkPager($a, $pager, $update); - - $last_date = ''; - - switch ($order_mode) { - case 'received': - if ($last_received != '') { - $last_date = $last_received; - $sql_range .= sprintf(" AND $sql_table.`received` < '%s'", DBA::escape($last_received)); - $pager->setPage(1); - $pager_sql = sprintf(" LIMIT %d, %d ", $pager->getStart(), $pager->getItemsPerPage()); - } - break; - case 'commented': - if ($last_commented != '') { - $last_date = $last_commented; - $sql_range .= sprintf(" AND $sql_table.`commented` < '%s'", DBA::escape($last_commented)); - $pager->setPage(1); - $pager_sql = sprintf(" LIMIT %d, %d ", $pager->getStart(), $pager->getItemsPerPage()); - } - break; - case 'created': - if ($last_created != '') { - $last_date = $last_created; - $sql_range .= sprintf(" AND $sql_table.`created` < '%s'", DBA::escape($last_created)); - $pager->setPage(1); - $pager_sql = sprintf(" LIMIT %d, %d ", $pager->getStart(), $pager->getItemsPerPage()); - } - break; - case 'id': - if (($last_id > 0) && ($sql_table == '`thread`')) { - $sql_range .= sprintf(" AND $sql_table.`iid` < '%s'", DBA::escape($last_id)); - $pager->setPage(1); - $pager_sql = sprintf(" LIMIT %d, %d ", $pager->getStart(), $pager->getItemsPerPage()); - } - break; - } - - // Fetch a page full of parent items for this page - if ($update) { - if (!empty($parent)) { - // Load only a single thread - $sql_extra4 = "`item`.`id` = ".intval($parent); - } else { - // Load all unseen items - $sql_extra4 = "`item`.`unseen`"; - if (DI::config()->get("system", "like_no_comment")) { - $sql_extra4 .= " AND `item`.`gravity` IN (" . GRAVITY_PARENT . "," . GRAVITY_COMMENT . ")"; - } - if ($order === 'post') { - // Only show toplevel posts when updating posts in this order mode - $sql_extra4 .= " AND `item`.`id` = `item`.`parent`"; - } - } - - $r = q("SELECT `item`.`parent-uri` AS `uri`, `item`.`parent` AS `item_id`, $sql_order AS `order_date` - FROM `item` $sql_post_table - STRAIGHT_JOIN `contact` ON `contact`.`id` = `item`.`contact-id` - AND (NOT `contact`.`blocked` OR `contact`.`pending`) - AND (`item`.`gravity` != %d - OR `contact`.`uid` = `item`.`uid` AND `contact`.`self` - OR `contact`.`rel` IN (%d, %d) AND NOT `contact`.`readonly`) - LEFT JOIN `user-item` ON `user-item`.`iid` = `item`.`id` AND `user-item`.`uid` = %d - WHERE `item`.`uid` = %d AND `item`.`visible` AND NOT `item`.`deleted` - AND (`user-item`.`hidden` IS NULL OR NOT `user-item`.`hidden`) - AND NOT `item`.`moderated` AND $sql_extra4 - $sql_extra3 $sql_extra $sql_range $sql_nets - ORDER BY `order_date` DESC LIMIT 100", - intval(GRAVITY_PARENT), - intval(Contact::SHARING), - intval(Contact::FRIEND), - intval(local_user()), - intval(local_user()) - ); - } else { - $r = q("SELECT `item`.`uri`, `thread`.`iid` AS `item_id`, $sql_order AS `order_date` - FROM `thread` $sql_post_table - STRAIGHT_JOIN `contact` ON `contact`.`id` = `thread`.`contact-id` - AND (NOT `contact`.`blocked` OR `contact`.`pending`) - STRAIGHT_JOIN `item` ON `item`.`id` = `thread`.`iid` - AND (`item`.`gravity` != %d - OR `contact`.`uid` = `item`.`uid` AND `contact`.`self` - OR `contact`.`rel` IN (%d, %d) AND NOT `contact`.`readonly`) - LEFT JOIN `user-item` ON `user-item`.`iid` = `item`.`id` AND `user-item`.`uid` = %d - WHERE `thread`.`uid` = %d AND `thread`.`visible` AND NOT `thread`.`deleted` - AND NOT `thread`.`moderated` - AND (`user-item`.`hidden` IS NULL OR NOT `user-item`.`hidden`) - $sql_extra2 $sql_extra3 $sql_range $sql_extra $sql_nets - ORDER BY `order_date` DESC $pager_sql", - intval(GRAVITY_PARENT), - intval(Contact::SHARING), - intval(Contact::FRIEND), - intval(local_user()), - intval(local_user()) - ); - } - - // Only show it when unfiltered (no groups, no networks, ...) - if (in_array($nets, ['', Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA, Protocol::OSTATUS]) && (strlen($sql_extra . $sql_extra2 . $sql_extra3) == 0)) { - if (DBA::isResult($r)) { - $top_limit = current($r)['order_date']; - $bottom_limit = end($r)['order_date']; - if (empty($_SESSION['network_last_top_limit']) || ($_SESSION['network_last_top_limit'] < $top_limit)) { - $_SESSION['network_last_top_limit'] = $top_limit; - } - } else { - $top_limit = $bottom_limit = DateTimeFormat::utcNow(); - } - - // When checking for updates we need to fetch from the newest date to the newest date before - // Only do this, when the last stored date isn't too long ago (10 times the update interval) - $browser_update = DI::pConfig()->get(local_user(), 'system', 'update_interval', 40000) / 1000; - - if (($browser_update > 0) && $update && !empty($_SESSION['network_last_date']) && - (($bottom_limit < $_SESSION['network_last_date']) || ($top_limit == $bottom_limit)) && - ((time() - $_SESSION['network_last_date_timestamp']) < ($browser_update * 10))) { - $bottom_limit = $_SESSION['network_last_date']; - } - $_SESSION['network_last_date'] = Session::get('network_last_top_limit', $top_limit); - $_SESSION['network_last_date_timestamp'] = time(); - - if ($last_date > $top_limit) { - $top_limit = $last_date; - } elseif ($pager->getPage() == 1) { - // Highest possible top limit when we are on the first page - $top_limit = DateTimeFormat::utcNow(); - } - - $items = DBA::p("SELECT `item`.`parent-uri` AS `uri`, 0 AS `item_id`, `item`.$ordering AS `order_date`, `author`.`url` AS `author-link` FROM `item` - STRAIGHT_JOIN (SELECT `uri-id` FROM `tag-search-view` WHERE `name` IN - (SELECT SUBSTR(`term`, 2) FROM `search` WHERE `uid` = ? AND `term` LIKE '#%') AND `uid` = 0) AS `tag-search` - ON `item`.`uri-id` = `tag-search`.`uri-id` - STRAIGHT_JOIN `contact` AS `author` ON `author`.`id` = `item`.`author-id` - WHERE `item`.`uid` = 0 AND `item`.$ordering < ? AND `item`.$ordering > ? AND `item`.`gravity` = ? - AND NOT `author`.`hidden` AND NOT `author`.`blocked`" . $sql_tag_nets, - local_user(), $top_limit, $bottom_limit, GRAVITY_PARENT); - - $data = DBA::toArray($items); - - if (count($data) > 0) { - $tag_top_limit = current($data)['order_date']; - if ($_SESSION['network_last_date'] < $tag_top_limit) { - $_SESSION['network_last_date'] = $tag_top_limit; - } - - Logger::log('Tagged items: ' . count($data) . ' - ' . $bottom_limit . ' - ' . $top_limit . ' - ' . local_user().' - '.(int)$update); - $s = []; - foreach ($r as $item) { - $s[$item['uri']] = $item; - } - foreach ($data as $item) { - // Don't show hash tag posts from blocked or ignored contacts - $condition = ["`nurl` = ? AND `uid` = ? AND (`blocked` OR `readonly`)", - Strings::normaliseLink($item['author-link']), local_user()]; - if (!DBA::exists('contact', $condition)) { - $s[$item['uri']] = $item; - } - } - $r = $s; - } - } - - $parents_str = ''; - $date_offset = ''; - - $items = $r; - - if (DBA::isResult($items)) { - $parents_arr = []; - - foreach ($items as $item) { - if ($date_offset < $item['order_date']) { - $date_offset = $item['order_date']; - } - if (!in_array($item['item_id'], $parents_arr) && ($item['item_id'] > 0)) { - $parents_arr[] = $item['item_id']; - } - } - $parents_str = implode(', ', $parents_arr); - } - - if (!empty($_GET['offset'])) { - $date_offset = $_GET['offset']; - } - - $query_string = DI::args()->getQueryString(); - if ($date_offset && !preg_match('/[?&].offset=/', $query_string)) { - $query_string .= '&offset=' . urlencode($date_offset); - } - - $pager->setQueryString($query_string); - - // We aren't going to try and figure out at the item, group, and page - // level which items you've seen and which you haven't. If you're looking - // at the top level network page just mark everything seen. - - if (!$gid && !$cid && !$star) { - $condition = ['unseen' => true, 'uid' => local_user()]; - networkSetSeen($condition); - } elseif ($parents_str) { - $condition = ["`uid` = ? AND `unseen` AND `parent` IN (" . DBA::escape($parents_str) . ")", local_user()]; - networkSetSeen($condition); - } - - - $mode = 'network'; - $o .= networkConversation($a, $items, $pager, $mode, $update, $ordering); - - return $o; -} - -/** - * Get the network tabs menu - * - * @param App $a The global App - * @return string Html of the networktab - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ -function network_tabs(App $a) -{ - // item filter tabs - /// @TODO fix this logic, reduce duplication - /// DI::page()['content'] .= '
'; - list($no_active, $all_active, $post_active, $conv_active, $new_active, $starred_active, $bookmarked_active) = network_query_get_sel_tab($a); - - // if no tabs are selected, defaults to activitys - if ($no_active == 'active') { - $all_active = 'active'; - } - - $cmd = DI::args()->getCommand(); - - $def_param = []; - if (!empty($_GET['cid'])) { - $def_param['cid'] = $_GET['cid']; - } - - // tabs - $tabs = [ - [ - 'label' => DI::l10n()->t('Latest Activity'), - 'url' => $cmd . '?' . http_build_query(array_merge($def_param, ['order' => 'activity'])), - 'sel' => $all_active, - 'title' => DI::l10n()->t('Sort by latest activity'), - 'id' => 'activity-order-tab', - 'accesskey' => 'e', - ], - [ - 'label' => DI::l10n()->t('Latest Posts'), - 'url' => $cmd . '?' . http_build_query(array_merge($def_param, ['order' => 'post'])), - 'sel' => $post_active, - 'title' => DI::l10n()->t('Sort by post received date'), - 'id' => 'post-order-tab', - 'accesskey' => 't', - ], - ]; - - $tabs[] = [ - 'label' => DI::l10n()->t('Personal'), - 'url' => $cmd . '?' . http_build_query(array_merge($def_param, ['conv' => true])), - 'sel' => $conv_active, - 'title' => DI::l10n()->t('Posts that mention or involve you'), - 'id' => 'personal-tab', - 'accesskey' => 'r', - ]; - - if (Feature::isEnabled(local_user(), 'new_tab')) { - $tabs[] = [ - 'label' => DI::l10n()->t('New'), - 'url' => $cmd . '?' . http_build_query(array_merge($def_param, ['new' => true])), - 'sel' => $new_active, - 'title' => DI::l10n()->t('Activity Stream - by date'), - 'id' => 'activitiy-by-date-tab', - 'accesskey' => 'w', - ]; - } - - if (Feature::isEnabled(local_user(), 'link_tab')) { - $tabs[] = [ - 'label' => DI::l10n()->t('Shared Links'), - 'url' => $cmd . '?' . http_build_query(array_merge($def_param, ['bmark' => true])), - 'sel' => $bookmarked_active, - 'title' => DI::l10n()->t('Interesting Links'), - 'id' => 'shared-links-tab', - 'accesskey' => 'b', - ]; - } - - $tabs[] = [ - 'label' => DI::l10n()->t('Starred'), - 'url' => $cmd . '?' . http_build_query(array_merge($def_param, ['star' => true])), - 'sel' => $starred_active, - 'title' => DI::l10n()->t('Favourite Posts'), - 'id' => 'starred-posts-tab', - 'accesskey' => 'm', - ]; - - // save selected tab, but only if not in file mode - if (empty($_GET['file'])) { - DI::pConfig()->set(local_user(), 'network.view', 'tab.selected', [ - $all_active, $post_active, $conv_active, $new_active, $starred_active, $bookmarked_active - ]); - } - - $arr = ['tabs' => $tabs]; - Hook::callAll('network_tabs', $arr); - - $tpl = Renderer::getMarkupTemplate('common_tabs.tpl'); - - return Renderer::replaceMacros($tpl, ['$tabs' => $arr['tabs']]); - - // --- end item filter tabs -} - -/** - * Network hook into the HTML head to enable infinite scroll. - * - * Since the HTML head is built after the module content has been generated, we need to retrieve the base query string - * of the page to make the correct asynchronous call. This is obtained through the Pager that was instantiated in - * networkThreadedView or networkFlatView. - * - * @param App $a - * @param string $htmlhead The head tag HTML string - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @global Pager $pager - */ -function network_infinite_scroll_head(App $a, &$htmlhead) -{ - /// @TODO this will have to be converted to a static property of the converted Module\Network class - /** - * @var $pager Pager - */ - global $pager; - - if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll') - && ($_GET['mode'] ?? '') != 'minimal' - ) { - $tpl = Renderer::getMarkupTemplate('infinite_scroll_head.tpl'); - $htmlhead .= Renderer::replaceMacros($tpl, [ - '$pageno' => $pager->getPage(), - '$reload_uri' => $pager->getBaseQueryString() - ]); - } -} diff --git a/mod/notes.php b/mod/notes.php index 67f8fcab29..d3ce0fa40c 100644 --- a/mod/notes.php +++ b/mod/notes.php @@ -40,7 +40,7 @@ function notes_init(App $a) function notes_content(App $a, $update = false) { if (!local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } @@ -55,7 +55,7 @@ function notes_content(App $a, $update = false) 'default_location' => $a->user['default-location'], 'nickname' => $a->user['nickname'], 'lockstate' => 'lock', - 'acl' => '', + 'acl' => \Friendica\Core\ACL::getSelfOnlyHTML(local_user(), DI::l10n()->t('Personal notes are visible only by yourself.')), 'bang' => '', 'visitor' => 'block', 'profile_uid' => local_user(), diff --git a/mod/oexchange.php b/mod/oexchange.php index 97367c3ea5..f68fe6f2d2 100644 --- a/mod/oexchange.php +++ b/mod/oexchange.php @@ -23,7 +23,6 @@ use Friendica\App; use Friendica\Core\Renderer; use Friendica\DI; use Friendica\Module\Security\Login; -use Friendica\Util\Network; use Friendica\Util\Strings; function oexchange_init(App $a) { @@ -45,7 +44,6 @@ function oexchange_content(App $a) { } if (($a->argc > 1) && $a->argv[1] === 'done') { - info(DI::l10n()->t('Post successful.') . EOL); return; } @@ -58,7 +56,7 @@ function oexchange_content(App $a) { $tags = ((!empty($_REQUEST['tags'])) ? '&tags=' . urlencode(Strings::escapeTags(trim($_REQUEST['tags']))) : ''); - $s = Network::fetchUrl(DI::baseUrl() . '/parse_url?url=' . $url . $title . $description . $tags); + $s = DI::httpRequest()->fetch(DI::baseUrl() . '/parse_url?url=' . $url . $title . $description . $tags); if (!strlen($s)) { return; diff --git a/mod/ostatus_subscribe.php b/mod/ostatus_subscribe.php index bdf362e1ce..3f716c8c7b 100644 --- a/mod/ostatus_subscribe.php +++ b/mod/ostatus_subscribe.php @@ -23,13 +23,11 @@ use Friendica\App; use Friendica\Core\Protocol; use Friendica\DI; use Friendica\Model\Contact; -use Friendica\Network\Probe; -use Friendica\Util\Network; function ostatus_subscribe_content(App $a) { if (!local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); DI::baseUrl()->redirect('ostatus_subscribe'); // NOTREACHED } @@ -47,8 +45,7 @@ function ostatus_subscribe_content(App $a) return $o . DI::l10n()->t('No contact provided.'); } - $contact = Probe::uri($_REQUEST['url']); - + $contact = Contact::getByURL($_REQUEST['url']); if (!$contact) { DI::pConfig()->delete($uid, 'ostatus', 'legacy_contact'); return $o . DI::l10n()->t('Couldn\'t fetch information for contact.'); @@ -57,7 +54,7 @@ function ostatus_subscribe_content(App $a) $api = $contact['baseurl'] . '/api/'; // Fetching friends - $curlResult = Network::curl($api . 'statuses/friends.json?screen_name=' . $contact['nick']); + $curlResult = DI::httpRequest()->get($api . 'statuses/friends.json?screen_name=' . $contact['nick']); if (!$curlResult->isSuccess()) { DI::pConfig()->delete($uid, 'ostatus', 'legacy_contact'); @@ -89,9 +86,9 @@ function ostatus_subscribe_content(App $a) $o .= '

' . $counter . '/' . $total . ': ' . $url; - $probed = Probe::uri($url); + $probed = Contact::getByURL($url); if ($probed['network'] == Protocol::OSTATUS) { - $result = Contact::createFromProbe($uid, $url, true, Protocol::OSTATUS); + $result = Contact::createFromProbe($a->user, $probed['url'], true, Protocol::OSTATUS); if ($result['success']) { $o .= ' - ' . DI::l10n()->t('success'); } else { diff --git a/mod/parse_url.php b/mod/parse_url.php index b40ddf1d71..82325aa553 100644 --- a/mod/parse_url.php +++ b/mod/parse_url.php @@ -24,10 +24,11 @@ */ use Friendica\App; +use Friendica\Content\PageInfo; use Friendica\Core\Hook; use Friendica\Core\Logger; use Friendica\Core\System; -use Friendica\Util\Network; +use Friendica\DI; use Friendica\Util\ParseUrl; use Friendica\Util\Strings; @@ -84,19 +85,11 @@ function parse_url_content(App $a) // Check if the URL is an image, video or audio file. If so format // the URL with the corresponding BBCode media tag // Fetch the header of the URL - $curlResponse = Network::curl($url, false, ['novalidate' => true, 'nobody' => true]); + $curlResponse = DI::httpRequest()->head($url); if ($curlResponse->isSuccess()) { - // Convert the header fields into an array - $hdrs = []; - $h = explode("\n", $curlResponse->getHeader()); - foreach ($h as $l) { - $header = array_map('trim', explode(':', trim($l), 2)); - if (count($header) == 2) { - list($k, $v) = $header; - $hdrs[$k] = $v; - } - } + $hdrs = $curlResponse->getHeaderArray(); + $type = null; $content_type = ''; $bbcode = ''; @@ -133,13 +126,17 @@ function parse_url_content(App $a) $template = '[bookmark=%s]%s[/bookmark]%s'; - $arr = ['url' => $url, 'text' => '']; + $arr = ['url' => $url, 'format' => $format, 'text' => null]; Hook::callAll('parse_link', $arr); - if (strlen($arr['text'])) { - echo $arr['text']; - exit(); + if ($arr['text']) { + if ($format == 'json') { + System::jsonExit($arr['text']); + } else { + echo $arr['text']; + exit(); + } } // If there is already some content information submitted we don't @@ -177,7 +174,7 @@ function parse_url_content(App $a) } // Format it as BBCode attachment - $info = add_page_info_data($siteinfo); + $info = "\n" . PageInfo::getFooterFromData($siteinfo); echo $info; diff --git a/mod/photos.php b/mod/photos.php index 311d2b1c14..ca5b66abe4 100644 --- a/mod/photos.php +++ b/mod/photos.php @@ -25,6 +25,7 @@ use Friendica\Content\Nav; use Friendica\Content\Pager; use Friendica\Content\Text\BBCode; use Friendica\Core\ACL; +use Friendica\Core\Addon; use Friendica\Core\Hook; use Friendica\Core\Logger; use Friendica\Core\Renderer; @@ -46,7 +47,7 @@ use Friendica\Util\Crypto; use Friendica\Util\DateTimeFormat; use Friendica\Util\Images; use Friendica\Util\Map; -use Friendica\Util\Security; +use Friendica\Security\Security; use Friendica\Util\Strings; use Friendica\Util\Temporal; use Friendica\Util\XML; @@ -82,7 +83,7 @@ function photos_init(App $a) { '$photo' => $profile['photo'], '$addr' => $profile['addr'] ?? '', '$account_type' => $account_type, - '$about' => BBCode::convert($profile['about'] ?? ''), + '$about' => BBCode::convert($profile['about']), ]); $albums = Photo::getAlbums($a->data['user']['uid']); @@ -154,10 +155,6 @@ function photos_init(App $a) { function photos_post(App $a) { - Logger::log('mod-photos: photos_post: begin' , Logger::DEBUG); - Logger::log('mod_photos: REQUEST ' . print_r($_REQUEST, true), Logger::DATA); - Logger::log('mod_photos: FILES ' . print_r($_FILES, true), Logger::DATA); - $phototypes = Images::supportedTypes(); $can_post = false; @@ -175,25 +172,42 @@ function photos_post(App $a) } if (!$can_post) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); exit(); } $owner_record = User::getOwnerDataById($page_owner_uid); if (!$owner_record) { - notice(DI::l10n()->t('Contact information unavailable') . EOL); - Logger::log('photos_post: unable to locate contact record for page owner. uid=' . $page_owner_uid); + notice(DI::l10n()->t('Contact information unavailable')); + DI::logger()->info('photos_post: unable to locate contact record for page owner. uid=' . $page_owner_uid); exit(); } + $aclFormatter = DI::aclFormatter(); + $str_contact_allow = isset($_REQUEST['contact_allow']) ? $aclFormatter->toString($_REQUEST['contact_allow']) : $owner_record['allow_cid'] ?? ''; + $str_group_allow = isset($_REQUEST['group_allow']) ? $aclFormatter->toString($_REQUEST['group_allow']) : $owner_record['allow_gid'] ?? ''; + $str_contact_deny = isset($_REQUEST['contact_deny']) ? $aclFormatter->toString($_REQUEST['contact_deny']) : $owner_record['deny_cid'] ?? ''; + $str_group_deny = isset($_REQUEST['group_deny']) ? $aclFormatter->toString($_REQUEST['group_deny']) : $owner_record['deny_gid'] ?? ''; + + $visibility = $_REQUEST['visibility'] ?? ''; + if ($visibility === 'public') { + // The ACL selector introduced in version 2019.12 sends ACL input data even when the Public visibility is selected + $str_contact_allow = $str_group_allow = $str_contact_deny = $str_group_deny = ''; + } else if ($visibility === 'custom') { + // Since we know from the visibility parameter the item should be private, we have to prevent the empty ACL + // case that would make it public. So we always append the author's contact id to the allowed contacts. + // See https://github.com/friendica/friendica/issues/9672 + $str_contact_allow .= $aclFormatter->toString(Contact::getPublicIdByUserId($page_owner_uid)); + } + if ($a->argc > 3 && $a->argv[2] === 'album') { if (!Strings::isHex($a->argv[3])) { DI::baseUrl()->redirect('photos/' . $a->data['user']['nickname'] . '/album'); } $album = hex2bin($a->argv[3]); - if ($album === DI::l10n()->t('Profile Photos') || $album === 'Contact Photos' || $album === DI::l10n()->t('Contact Photos')) { + if ($album === DI::l10n()->t('Profile Photos') || $album === Photo::CONTACT_PHOTOS || $album === DI::l10n()->t(Photo::CONTACT_PHOTOS)) { DI::baseUrl()->redirect($_SESSION['photo_return']); return; // NOTREACHED } @@ -204,7 +218,7 @@ function photos_post(App $a) ); if (!DBA::isResult($r)) { - notice(DI::l10n()->t('Album not found.') . EOL); + notice(DI::l10n()->t('Album not found.')); DI::baseUrl()->redirect('photos/' . $a->data['user']['nickname'] . '/album'); return; // NOTREACHED } @@ -295,9 +309,8 @@ function photos_post(App $a) // Update the photo albums cache Photo::clearAlbumCache($page_owner_uid); - notice('Successfully deleted the photo.'); } else { - notice('Failed to delete the photo.'); + notice(DI::l10n()->t('Failed to delete the photo.')); DI::baseUrl()->redirect('photos/' . $a->argv[1] . '/image/' . $a->argv[3]); } @@ -313,13 +326,6 @@ function photos_post(App $a) $albname = !empty($_POST['albname']) ? trim($_POST['albname']) : ''; $origaname = !empty($_POST['origaname']) ? Strings::escapeTags(trim($_POST['origaname'])) : ''; - $aclFormatter = DI::aclFormatter(); - - $str_group_allow = !empty($_POST['group_allow']) ? $aclFormatter->toString($_POST['group_allow']) : ''; - $str_contact_allow = !empty($_POST['contact_allow']) ? $aclFormatter->toString($_POST['contact_allow']) : ''; - $str_group_deny = !empty($_POST['group_deny']) ? $aclFormatter->toString($_POST['group_deny']) : ''; - $str_contact_deny = !empty($_POST['contact_deny']) ? $aclFormatter->toString($_POST['contact_deny']) : ''; - $resource_id = $a->argv[3]; if (!strlen($albname)) { @@ -395,7 +401,6 @@ function photos_post(App $a) $arr['guid'] = System::createUUID(); $arr['uid'] = $page_owner_uid; $arr['uri'] = $uri; - $arr['parent-uri'] = $uri; $arr['post-type'] = Item::PT_IMAGE; $arr['wall'] = 1; $arr['resource-id'] = $photo['resource-id']; @@ -509,9 +514,9 @@ function photos_post(App $a) if ($profile) { if (!empty($contact)) { - $taginfo[] = [$newname, $profile, $notify, $contact, '@[url=' . str_replace(',', '%2c', $profile) . ']' . $newname . '[/url]']; + $taginfo[] = [$newname, $profile, $notify, $contact]; } else { - $taginfo[] = [$newname, $profile, $notify, null, '@[url=' . $profile . ']' . $newname . '[/url]']; + $taginfo[] = [$newname, $profile, $notify, null]; } $profile = str_replace(',', '%2c', $profile); @@ -560,7 +565,6 @@ function photos_post(App $a) $arr['guid'] = System::createUUID(); $arr['uid'] = $page_owner_uid; $arr['uri'] = $uri; - $arr['parent-uri'] = $uri; $arr['wall'] = 1; $arr['contact-id'] = $owner_record['id']; $arr['owner-name'] = $owner_record['name']; @@ -579,7 +583,6 @@ function photos_post(App $a) $arr['gravity'] = GRAVITY_PARENT; $arr['object-type'] = Activity\ObjectType::PERSON; $arr['target-type'] = Activity\ObjectType::IMAGE; - $arr['tag'] = $tagged[4]; $arr['inform'] = $tagged[2]; $arr['origin'] = 1; $arr['body'] = DI::l10n()->t('%1$s was tagged in %2$s by %3$s', '[url=' . $tagged[1] . ']' . $tagged[0] . '[/url]', '[url=' . DI::baseUrl() . '/photos/' . $owner_record['nickname'] . '/image/' . $photo['resource-id'] . ']' . DI::l10n()->t('a photo') . '[/url]', '[url=' . $owner_record['url'] . ']' . $owner_record['name'] . '[/url]') ; @@ -642,18 +645,6 @@ function photos_post(App $a) $visible = 0; } - $group_allow = $_REQUEST['group_allow'] ?? []; - $contact_allow = $_REQUEST['contact_allow'] ?? []; - $group_deny = $_REQUEST['group_deny'] ?? []; - $contact_deny = $_REQUEST['contact_deny'] ?? []; - - $aclFormatter = DI::aclFormatter(); - - $str_group_allow = $aclFormatter->toString(is_array($group_allow) ? $group_allow : explode(',', $group_allow)); - $str_contact_allow = $aclFormatter->toString(is_array($contact_allow) ? $contact_allow : explode(',', $contact_allow)); - $str_group_deny = $aclFormatter->toString(is_array($group_deny) ? $group_deny : explode(',', $group_deny)); - $str_contact_deny = $aclFormatter->toString(is_array($contact_deny) ? $contact_deny : explode(',', $contact_deny)); - $ret = ['src' => '', 'filename' => '', 'filesize' => 0, 'type' => '']; Hook::callAll('photo_post_file', $ret); @@ -677,21 +668,21 @@ function photos_post(App $a) if ($error !== UPLOAD_ERR_OK) { switch ($error) { case UPLOAD_ERR_INI_SIZE: - notice(DI::l10n()->t('Image exceeds size limit of %s', ini_get('upload_max_filesize')) . EOL); + notice(DI::l10n()->t('Image exceeds size limit of %s', ini_get('upload_max_filesize'))); break; case UPLOAD_ERR_FORM_SIZE: - notice(DI::l10n()->t('Image exceeds size limit of %s', Strings::formatBytes($_REQUEST['MAX_FILE_SIZE'] ?? 0)) . EOL); + notice(DI::l10n()->t('Image exceeds size limit of %s', Strings::formatBytes($_REQUEST['MAX_FILE_SIZE'] ?? 0))); break; case UPLOAD_ERR_PARTIAL: - notice(DI::l10n()->t('Image upload didn\'t complete, please try again') . EOL); + notice(DI::l10n()->t('Image upload didn\'t complete, please try again')); break; case UPLOAD_ERR_NO_FILE: - notice(DI::l10n()->t('Image file is missing') . EOL); + notice(DI::l10n()->t('Image file is missing')); break; case UPLOAD_ERR_NO_TMP_DIR: case UPLOAD_ERR_CANT_WRITE: case UPLOAD_ERR_EXTENSION: - notice(DI::l10n()->t('Server can\'t accept new file upload at this time, please contact your administrator') . EOL); + notice(DI::l10n()->t('Server can\'t accept new file upload at this time, please contact your administrator')); break; } @unlink($src); @@ -707,7 +698,7 @@ function photos_post(App $a) $maximagesize = DI::config()->get('system', 'maximagesize'); if ($maximagesize && ($filesize > $maximagesize)) { - notice(DI::l10n()->t('Image exceeds size limit of %s', Strings::formatBytes($maximagesize)) . EOL); + notice(DI::l10n()->t('Image exceeds size limit of %s', Strings::formatBytes($maximagesize))); @unlink($src); $foo = 0; Hook::callAll('photo_post_end', $foo); @@ -715,7 +706,7 @@ function photos_post(App $a) } if (!$filesize) { - notice(DI::l10n()->t('Image file is empty.') . EOL); + notice(DI::l10n()->t('Image file is empty.')); @unlink($src); $foo = 0; Hook::callAll('photo_post_end', $foo); @@ -730,7 +721,7 @@ function photos_post(App $a) if (!$image->isValid()) { Logger::log('mod/photos.php: photos_post(): unable to process image' , Logger::DEBUG); - notice(DI::l10n()->t('Unable to process image.') . EOL); + notice(DI::l10n()->t('Unable to process image.')); @unlink($src); $foo = 0; Hook::callAll('photo_post_end',$foo); @@ -759,7 +750,7 @@ function photos_post(App $a) if (!$r) { Logger::log('mod/photos.php: photos_post(): image store failed', Logger::DEBUG); - notice(DI::l10n()->t('Image upload failed.') . EOL); + notice(DI::l10n()->t('Image upload failed.')); return; } @@ -779,7 +770,7 @@ function photos_post(App $a) // Create item container $lat = $lon = null; - if ($exif && $exif['GPS'] && Feature::isEnabled($page_owner_uid, 'photo_location')) { + if (!empty($exif['GPS']) && Feature::isEnabled($page_owner_uid, 'photo_location')) { $lat = Photo::getGps($exif['GPS']['GPSLatitude'], $exif['GPS']['GPSLatitudeRef']); $lon = Photo::getGps($exif['GPS']['GPSLongitude'], $exif['GPS']['GPSLongitudeRef']); } @@ -792,7 +783,6 @@ function photos_post(App $a) $arr['guid'] = System::createUUID(); $arr['uid'] = $page_owner_uid; $arr['uri'] = $uri; - $arr['parent-uri'] = $uri; $arr['type'] = 'photo'; $arr['wall'] = 1; $arr['resource-id'] = $resource_id; @@ -842,12 +832,12 @@ function photos_content(App $a) // photos/name/image/xxxxx/drop if (DI::config()->get('system', 'block_public') && !Session::isAuthenticated()) { - notice(DI::l10n()->t('Public access denied.') . EOL); + notice(DI::l10n()->t('Public access denied.')); return; } if (empty($a->data['user'])) { - notice(DI::l10n()->t('No photos selected') . EOL); + notice(DI::l10n()->t('No photos selected')); return; } @@ -913,7 +903,7 @@ function photos_content(App $a) } if ($a->data['user']['hidewall'] && (local_user() != $owner_uid) && !$remote_contact) { - notice(DI::l10n()->t('Access to this item is restricted.') . EOL); + notice(DI::l10n()->t('Access to this item is restricted.')); return; } @@ -939,7 +929,7 @@ function photos_content(App $a) $albumselect .= ''; if (!empty($a->data['albums'])) { foreach ($a->data['albums'] as $album) { - if (($album['album'] === '') || ($album['album'] === 'Contact Photos') || ($album['album'] === DI::l10n()->t('Contact Photos'))) { + if (($album['album'] === '') || ($album['album'] === Photo::CONTACT_PHOTOS) || ($album['album'] === DI::l10n()->t(Photo::CONTACT_PHOTOS))) { continue; } $selected = (($selname === $album['album']) ? ' selected="selected" ' : ''); @@ -989,8 +979,6 @@ function photos_content(App $a) '$uploadurl' => $ret['post_url'], // ACL permissions box - '$group_perms' => DI::l10n()->t('Show to Groups'), - '$contact_perms' => DI::l10n()->t('Show to Contacts'), '$return_path' => DI::args()->getQueryString(), ]); @@ -1042,7 +1030,6 @@ function photos_content(App $a) return Renderer::replaceMacros(Renderer::getMarkupTemplate('confirm.tpl'), [ '$method' => 'post', '$message' => DI::l10n()->t('Do you really want to delete this photo album and all its photos?'), - '$extra_inputs' => [], '$confirm' => DI::l10n()->t('Delete Album'), '$confirm_url' => $drop_url, '$confirm_name' => 'dropalbum', @@ -1052,7 +1039,7 @@ function photos_content(App $a) // edit album name if ($cmd === 'edit') { - if (($album !== DI::l10n()->t('Profile Photos')) && ($album !== 'Contact Photos') && ($album !== DI::l10n()->t('Contact Photos'))) { + if (($album !== DI::l10n()->t('Profile Photos')) && ($album !== Photo::CONTACT_PHOTOS) && ($album !== DI::l10n()->t(Photo::CONTACT_PHOTOS))) { if ($can_post) { $edit_tpl = Renderer::getMarkupTemplate('album_edit.tpl'); @@ -1069,7 +1056,7 @@ function photos_content(App $a) } } } else { - if (($album !== DI::l10n()->t('Profile Photos')) && ($album !== 'Contact Photos') && ($album !== DI::l10n()->t('Contact Photos')) && $can_post) { + if (($album !== DI::l10n()->t('Profile Photos')) && ($album !== Photo::CONTACT_PHOTOS) && ($album !== DI::l10n()->t(Photo::CONTACT_PHOTOS)) && $can_post) { $edit = [DI::l10n()->t('Edit Album'), 'photos/' . $a->data['user']['nickname'] . '/album/' . bin2hex($album) . '/edit']; $drop = [DI::l10n()->t('Drop Album'), 'photos/' . $a->data['user']['nickname'] . '/album/' . bin2hex($album) . '/drop']; } @@ -1138,7 +1125,7 @@ function photos_content(App $a) if (DBA::exists('photo', ['resource-id' => $datum, 'uid' => $owner_uid])) { notice(DI::l10n()->t('Permission denied. Access to this item may be restricted.')); } else { - notice(DI::l10n()->t('Photo not available') . EOL); + notice(DI::l10n()->t('Photo not available')); } return; } @@ -1149,7 +1136,6 @@ function photos_content(App $a) return Renderer::replaceMacros(Renderer::getMarkupTemplate('confirm.tpl'), [ '$method' => 'post', '$message' => DI::l10n()->t('Do you really want to delete this photo?'), - '$extra_inputs' => [], '$confirm' => DI::l10n()->t('Delete Photo'), '$confirm_url' => $drop_url, '$confirm_name' => 'delete', @@ -1288,7 +1274,7 @@ function photos_content(App $a) } if (!empty($link_item['parent']) && !empty($link_item['uid'])) { - $condition = ["`parent` = ? AND `parent` != `id`", $link_item['parent']]; + $condition = ["`parent` = ? AND `gravity` != ?", $link_item['parent'], GRAVITY_PARENT]; $total = DBA::count('item', $condition); $pager = new Pager(DI::l10n(), DI::args()->getQueryString()); @@ -1354,8 +1340,6 @@ function photos_content(App $a) '$delete' => DI::l10n()->t('Delete Photo'), // ACL permissions box - '$group_perms' => DI::l10n()->t('Show to Groups'), - '$contact_perms' => DI::l10n()->t('Show to Contacts'), '$return_path' => DI::args()->getQueryString(), ]); } @@ -1371,19 +1355,18 @@ function photos_content(App $a) $tpl = Renderer::getMarkupTemplate('photo_item.tpl'); $return_path = DI::args()->getCommand(); - if ($cmd === 'view' && ($can_post || Security::canWriteToUserWall($owner_uid))) { - $like_tpl = Renderer::getMarkupTemplate('like_noshare.tpl'); - $likebuttons = Renderer::replaceMacros($like_tpl, [ - '$id' => $link_item['id'], - '$likethis' => DI::l10n()->t("I like this \x28toggle\x29"), - '$dislike' => DI::pConfig()->get(local_user(), 'system', 'hide_dislike') ? '' : DI::l10n()->t("I don't like this \x28toggle\x29"), - '$wait' => DI::l10n()->t('Please wait'), - '$return_path' => DI::args()->getQueryString(), - ]); - } - if (!DBA::isResult($items)) { if (($can_post || Security::canWriteToUserWall($owner_uid))) { + /* + * Hmmm, code depending on the presence of a particular addon? + * This should be better if done by a hook + */ + $qcomment = null; + if (Addon::isEnabled('qcomment')) { + $words = DI::pConfig()->get(local_user(), 'qcomment', 'words'); + $qcomment = $words ? explode("\n", $words) : []; + } + $comments .= Renderer::replaceMacros($cmnt_tpl, [ '$return_path' => '', '$jsreload' => $return_path, @@ -1398,7 +1381,7 @@ function photos_content(App $a) '$preview' => DI::l10n()->t('Preview'), '$loading' => DI::l10n()->t('Loading...'), '$sourceapp' => DI::l10n()->t($a->sourcename), - '$ww' => '', + '$qcomment' => $qcomment, '$rand_num' => Crypto::randomDigits(12) ]); } @@ -1423,14 +1406,24 @@ function photos_content(App $a) } if (!empty($conv_responses['like'][$link_item['uri']])) { - $like = format_like($conv_responses['like'][$link_item['uri']], $conv_responses['like'][$link_item['uri'] . '-l'], 'like', $link_item['id']); + $like = format_activity($conv_responses['like'][$link_item['uri']]['links'], 'like', $link_item['id']); } if (!empty($conv_responses['dislike'][$link_item['uri']])) { - $dislike = format_like($conv_responses['dislike'][$link_item['uri']], $conv_responses['dislike'][$link_item['uri'] . '-l'], 'dislike', $link_item['id']); + $dislike = format_activity($conv_responses['dislike'][$link_item['uri']]['links'], 'dislike', $link_item['id']); } if (($can_post || Security::canWriteToUserWall($owner_uid))) { + /* + * Hmmm, code depending on the presence of a particular addon? + * This should be better if done by a hook + */ + $qcomment = null; + if (Addon::isEnabled('qcomment')) { + $words = DI::pConfig()->get(local_user(), 'qcomment', 'words'); + $qcomment = $words ? explode("\n", $words) : []; + } + $comments .= Renderer::replaceMacros($cmnt_tpl,[ '$return_path' => '', '$jsreload' => $return_path, @@ -1444,7 +1437,7 @@ function photos_content(App $a) '$submit' => DI::l10n()->t('Submit'), '$preview' => DI::l10n()->t('Preview'), '$sourceapp' => DI::l10n()->t($a->sourcename), - '$ww' => '', + '$qcomment' => $qcomment, '$rand_num' => Crypto::randomDigits(12) ]); } @@ -1457,11 +1450,11 @@ function photos_content(App $a) if (($activity->match($item['verb'], Activity::LIKE) || $activity->match($item['verb'], Activity::DISLIKE)) && - ($item['id'] != $item['parent'])) { + ($item['gravity'] != GRAVITY_PARENT)) { continue; } - $profile_url = Contact::magicLinkbyId($item['author-id']); + $profile_url = Contact::magicLinkById($item['author-id']); if (strpos($profile_url, 'redir/') === 0) { $sparkle = ' sparkle'; } else { @@ -1494,6 +1487,16 @@ function photos_content(App $a) ]); if (($can_post || Security::canWriteToUserWall($owner_uid))) { + /* + * Hmmm, code depending on the presence of a particular addon? + * This should be better if done by a hook + */ + $qcomment = null; + if (Addon::isEnabled('qcomment')) { + $words = DI::pConfig()->get(local_user(), 'qcomment', 'words'); + $qcomment = $words ? explode("\n", $words) : []; + } + $comments .= Renderer::replaceMacros($cmnt_tpl, [ '$return_path' => '', '$jsreload' => $return_path, @@ -1507,13 +1510,35 @@ function photos_content(App $a) '$submit' => DI::l10n()->t('Submit'), '$preview' => DI::l10n()->t('Preview'), '$sourceapp' => DI::l10n()->t($a->sourcename), - '$ww' => '', + '$qcomment' => $qcomment, '$rand_num' => Crypto::randomDigits(12) ]); } } } + $responses = []; + foreach ($conv_responses as $verb => $activity) { + if (isset($activity[$link_item['uri']])) { + $responses[$verb] = $activity[$link_item['uri']]; + } + } + + if ($cmd === 'view' && ($can_post || Security::canWriteToUserWall($owner_uid))) { + $like_tpl = Renderer::getMarkupTemplate('like_noshare.tpl'); + $likebuttons = Renderer::replaceMacros($like_tpl, [ + '$id' => $link_item['id'], + '$like' => DI::l10n()->t('Like'), + '$like_title' => DI::l10n()->t('I like this (toggle)'), + '$dislike' => DI::l10n()->t('Dislike'), + '$wait' => DI::l10n()->t('Please wait'), + '$dislike_title' => DI::l10n()->t('I don\'t like this (toggle)'), + '$hide_dislike' => DI::pConfig()->get(local_user(), 'system', 'hide_dislike'), + '$responses' => $responses, + '$return_path' => DI::args()->getQueryString(), + ]); + } + $paginate = $pager->renderFull($total); } @@ -1552,8 +1577,8 @@ function photos_content(App $a) $r = q("SELECT `resource-id`, max(`scale`) AS `scale` FROM `photo` WHERE `uid` = %d AND `album` != '%s' AND `album` != '%s' $sql_extra GROUP BY `resource-id`", intval($a->data['user']['uid']), - DBA::escape('Contact Photos'), - DBA::escape(DI::l10n()->t('Contact Photos')) + DBA::escape(Photo::CONTACT_PHOTOS), + DBA::escape(DI::l10n()->t(Photo::CONTACT_PHOTOS)) ); if (DBA::isResult($r)) { $total = count($r); @@ -1567,8 +1592,8 @@ function photos_content(App $a) WHERE `uid` = %d AND `album` != '%s' AND `album` != '%s' $sql_extra GROUP BY `resource-id` ORDER BY `created` DESC LIMIT %d , %d", intval($a->data['user']['uid']), - DBA::escape('Contact Photos'), - DBA::escape(DI::l10n()->t('Contact Photos')), + DBA::escape(Photo::CONTACT_PHOTOS), + DBA::escape(DI::l10n()->t(Photo::CONTACT_PHOTOS)), $pager->getStart(), $pager->getItemsPerPage() ); diff --git a/mod/ping.php b/mod/ping.php index 3057fb9e31..762d0a0f81 100644 --- a/mod/ping.php +++ b/mod/ping.php @@ -30,9 +30,10 @@ use Friendica\Model\Contact; use Friendica\Model\Group; use Friendica\Model\Item; use Friendica\Model\Notify\Type; +use Friendica\Model\Verb; +use Friendica\Protocol\Activity; use Friendica\Util\DateTimeFormat; use Friendica\Util\Temporal; -use Friendica\Util\Proxy as ProxyUtils; use Friendica\Util\XML; /** @@ -132,14 +133,11 @@ function ping_init(App $a) exit(); } - $notifs = ping_get_notifications(local_user()); - - $condition = ["`unseen` AND `uid` = ? AND `contact-id` != ?", local_user(), local_user()]; - $fields = ['id', 'parent', 'verb', 'author-name', 'unseen', 'author-link', 'author-avatar', 'contact-avatar', - 'network', 'created', 'object', 'parent-author-name', 'parent-author-link', 'parent-guid', 'wall']; - $params = ['order' => ['received' => true]]; - $items = Item::selectForUser(local_user(), $fields, $condition, $params); + $notifications = ping_get_notifications(local_user()); + $condition = ["`unseen` AND `uid` = ? AND NOT `origin` AND (`vid` != ? OR `vid` IS NULL)", + local_user(), Verb::getID(Activity::FOLLOW)]; + $items = Item::selectForUser(local_user(), ['wall', 'uid', 'uri-id'], $condition); if (DBA::isResult($items)) { $items_unseen = Item::inArray($items); $arr = ['items' => $items_unseen]; @@ -153,6 +151,7 @@ function ping_init(App $a) } } } + DBA::close($items); if ($network_count) { // Find out how unseen network posts are spread across groups @@ -178,15 +177,15 @@ function ping_init(App $a) $intros1 = q( "SELECT `intro`.`id`, `intro`.`datetime`, `fcontact`.`name`, `fcontact`.`url`, `fcontact`.`photo` - FROM `intro` LEFT JOIN `fcontact` ON `intro`.`fid` = `fcontact`.`id` - WHERE `intro`.`uid` = %d AND `intro`.`blocked` = 0 AND `intro`.`ignore` = 0 AND `intro`.`fid` != 0", + FROM `intro` INNER JOIN `fcontact` ON `intro`.`fid` = `fcontact`.`id` + WHERE `intro`.`uid` = %d AND NOT `intro`.`blocked` AND NOT `intro`.`ignore` AND `intro`.`fid` != 0", intval(local_user()) ); $intros2 = q( "SELECT `intro`.`id`, `intro`.`datetime`, `contact`.`name`, `contact`.`url`, `contact`.`photo` - FROM `intro` LEFT JOIN `contact` ON `intro`.`contact-id` = `contact`.`id` - WHERE `intro`.`uid` = %d AND `intro`.`blocked` = 0 AND `intro`.`ignore` = 0 AND `intro`.`contact-id` != 0", + FROM `intro` INNER JOIN `contact` ON `intro`.`contact-id` = `contact`.`id` + WHERE `intro`.`uid` = %d AND NOT `intro`.`blocked` AND NOT `intro`.`ignore` AND `intro`.`contact-id` != 0 AND (`intro`.`fid` = 0 OR `intro`.`fid` IS NULL)", intval(local_user()) ); @@ -264,8 +263,8 @@ function ping_init(App $a) $data['birthdays'] = $birthdays; $data['birthdays-today'] = $birthdays_today; - if (DBA::isResult($notifs)) { - foreach ($notifs as $notif) { + if (DBA::isResult($notifications)) { + foreach ($notifications as $notif) { if ($notif['seen'] == 0) { $sysnotify_count ++; } @@ -278,30 +277,44 @@ function ping_init(App $a) $notif = [ 'id' => 0, 'href' => DI::baseUrl() . '/notifications/intros/' . $intro['id'], - 'name' => $intro['name'], + 'name' => BBCode::convert($intro['name']), 'url' => $intro['url'], 'photo' => $intro['photo'], 'date' => $intro['datetime'], 'seen' => false, 'message' => DI::l10n()->t('{0} wants to be your friend'), ]; - $notifs[] = $notif; + $notifications[] = $notif; } } if (DBA::isResult($regs)) { - foreach ($regs as $reg) { + if (count($regs) <= 1 || DI::pConfig()->get(local_user(), 'system', 'detailed_notif')) { + foreach ($regs as $reg) { + $notif = [ + 'id' => 0, + 'href' => DI::baseUrl() . '/admin/users/pending', + 'name' => $reg['name'], + 'url' => $reg['url'], + 'photo' => $reg['micro'], + 'date' => $reg['created'], + 'seen' => false, + 'message' => DI::l10n()->t('{0} requested registration'), + ]; + $notifications[] = $notif; + } + } else { $notif = [ 'id' => 0, - 'href' => DI::baseUrl() . '/admin/users/', - 'name' => $reg['name'], - 'url' => $reg['url'], - 'photo' => $reg['micro'], - 'date' => $reg['created'], + 'href' => DI::baseUrl() . '/admin/users/pending', + 'name' => $regs[0]['name'], + 'url' => $regs[0]['url'], + 'photo' => $regs[0]['micro'], + 'date' => $regs[0]['created'], 'seen' => false, - 'message' => DI::l10n()->t('{0} requested registration'), + 'message' => DI::l10n()->t('{0} and %d others requested registration', count($regs) - 1), ]; - $notifs[] = $notif; + $notifications[] = $notif; } } @@ -324,32 +337,17 @@ function ping_init(App $a) } return ($adate < $bdate) ? 1 : -1; }; - usort($notifs, $sort_function); + usort($notifications, $sort_function); - if (DBA::isResult($notifs)) { - foreach ($notifs as $notif) { - $contact = Contact::getDetailsByURL($notif['url']); - if (isset($contact['micro'])) { - $notif['photo'] = ProxyUtils::proxifyUrl($contact['micro'], false, ProxyUtils::SIZE_MICRO); - } else { - $notif['photo'] = ProxyUtils::proxifyUrl($notif['photo'], false, ProxyUtils::SIZE_MICRO); - } - - $local_time = DateTimeFormat::local($notif['date']); - - $notifications[] = [ - 'id' => $notif['id'], - 'href' => $notif['href'], - 'name' => $notif['name'], - 'url' => $notif['url'], - 'photo' => $notif['photo'], - 'date' => Temporal::getRelativeDate($notif['date']), - 'message' => $notif['message'], - 'seen' => $notif['seen'], - 'timestamp' => strtotime($local_time) - ]; + array_walk($notifications, function (&$notification) { + if (empty($notification['photo'])) { + $contact = Contact::getByURL($notification['url'], false, ['micro', 'id', 'avatar']); + $notification['photo'] = Contact::getMicro($contact, $notification['photo']); } - } + + $notification['timestamp'] = DateTimeFormat::local($notification['date']); + $notification['date'] = Temporal::getRelativeDate($notification['date']); + }); } $sysmsgs = []; @@ -464,13 +462,13 @@ function ping_get_notifications($uid) if ($notification["visible"] && !$notification["deleted"] - && empty($result[$notification["parent"]]) + && empty($result[$notification['parent']]) ) { // Should we condense the notifications or show them all? if (DI::pConfig()->get(local_user(), 'system', 'detailed_notif')) { $result[$notification["id"]] = $notification; } else { - $result[$notification["parent"]] = $notification; + $result[$notification['parent']] = $notification; } } } diff --git a/mod/poco.php b/mod/poco.php index ef77c9c992..f084361dc4 100644 --- a/mod/poco.php +++ b/mod/poco.php @@ -27,139 +27,37 @@ use Friendica\Core\Protocol; use Friendica\Core\Renderer; use Friendica\Database\DBA; use Friendica\DI; -use Friendica\Protocol\PortableContact; -use Friendica\Util\DateTimeFormat; use Friendica\Util\Strings; use Friendica\Util\XML; function poco_init(App $a) { - $system_mode = false; - if (intval(DI::config()->get('system', 'block_public')) || (DI::config()->get('system', 'block_local_dir'))) { throw new \Friendica\Network\HTTPException\ForbiddenException(); } if ($a->argc > 1) { - $nickname = Strings::escapeTags(trim($a->argv[1])); - } - if (empty($nickname)) { - if (!DBA::exists('profile', ['net-publish' => true])) { - throw new \Friendica\Network\HTTPException\ForbiddenException(); - } - $system_mode = true; + // Only the system mode is supported + throw new \Friendica\Network\HTTPException\NotFoundException(); } $format = ($_GET['format'] ?? '') ?: 'json'; - $justme = false; - $global = false; - - if ($a->argc > 1 && $a->argv[1] === '@server') { - // List of all servers that this server knows - $ret = PortableContact::serverlist(); - header('Content-type: application/json'); - echo json_encode($ret); - exit(); + $totalResults = DBA::count('profile', ['net-publish' => true]); + if ($totalResults == 0) { + throw new \Friendica\Network\HTTPException\ForbiddenException(); } - if ($a->argc > 1 && $a->argv[1] === '@global') { - // List of all profiles that this server recently had data from - $global = true; - $update_limit = date(DateTimeFormat::MYSQL, time() - 30 * 86400); - } - if ($a->argc > 2 && $a->argv[2] === '@me') { - $justme = true; - } - if ($a->argc > 3 && $a->argv[3] === '@all') { - $justme = false; - } - if ($a->argc > 3 && $a->argv[3] === '@self') { - $justme = true; - } - if ($a->argc > 4 && intval($a->argv[4]) && $justme == false) { - $cid = intval($a->argv[4]); - } - - if (!$system_mode && !$global) { - $user = DBA::selectFirst('owner-view', ['uid', 'nickname'], ['nickname' => $nickname, 'hide-friends' => false]); - if (!DBA::isResult($user)) { - throw new \Friendica\Network\HTTPException\NotFoundException(); - } - } - - if ($justme) { - $sql_extra = " AND `contact`.`self` = 1 "; - } else { - $sql_extra = ""; - } - - if (!empty($cid)) { - $sql_extra = sprintf(" AND `contact`.`id` = %d ", intval($cid)); - } - if (!empty($_GET['updatedSince'])) { - $update_limit = date(DateTimeFormat::MYSQL, strtotime($_GET['updatedSince'])); - } - if ($global) { - $contacts = q("SELECT count(*) AS `total` FROM `gcontact` WHERE `updated` >= '%s' AND `updated` >= `last_failure` AND NOT `hide` AND `network` IN ('%s', '%s', '%s')", - DBA::escape($update_limit), - DBA::escape(Protocol::DFRN), - DBA::escape(Protocol::DIASPORA), - DBA::escape(Protocol::OSTATUS) - ); - } elseif ($system_mode) { - $totalResults = DBA::count('profile', ['net-publish' => true]); - } else { - $contacts = q("SELECT count(*) AS `total` FROM `contact` WHERE `uid` = %d AND `blocked` = 0 AND `pending` = 0 AND `hidden` = 0 AND `archive` = 0 - AND (`success_update` >= `failure_update` OR `last-item` >= `failure_update`) - AND `network` IN ('%s', '%s', '%s', '%s') $sql_extra", - intval($user['uid']), - DBA::escape(Protocol::DFRN), - DBA::escape(Protocol::DIASPORA), - DBA::escape(Protocol::OSTATUS), - DBA::escape(Protocol::STATUSNET) - ); - } - if (empty($totalResults) && DBA::isResult($contacts)) { - $totalResults = intval($contacts[0]['total']); - } elseif (empty($totalResults)) { - $totalResults = 0; - } if (!empty($_GET['startIndex'])) { $startIndex = intval($_GET['startIndex']); } else { $startIndex = 0; } - $itemsPerPage = ((!empty($_GET['count'])) ? intval($_GET['count']) : $totalResults); + $itemsPerPage = (!empty($_GET['count']) ? intval($_GET['count']) : $totalResults); - if ($global) { - Logger::log("Start global query", Logger::DEBUG); - $contacts = q("SELECT * FROM `gcontact` WHERE `updated` > '%s' AND NOT `hide` AND `network` IN ('%s', '%s', '%s') AND `updated` > `last_failure` - ORDER BY `updated` DESC LIMIT %d, %d", - DBA::escape($update_limit), - DBA::escape(Protocol::DFRN), - DBA::escape(Protocol::DIASPORA), - DBA::escape(Protocol::OSTATUS), - intval($startIndex), - intval($itemsPerPage) - ); - } elseif ($system_mode) { - Logger::log("Start system mode query", Logger::DEBUG); - $contacts = DBA::selectToArray('owner-view', [], ['net-publish' => true], ['limit' => [$startIndex, $itemsPerPage]]); - } else { - Logger::log("Start query for user " . $user['nickname'], Logger::DEBUG); - $contacts = q("SELECT * FROM `contact` WHERE `uid` = %d AND `blocked` = 0 AND `pending` = 0 AND `hidden` = 0 AND `archive` = 0 - AND (`success_update` >= `failure_update` OR `last-item` >= `failure_update`) - AND `network` IN ('%s', '%s', '%s', '%s') $sql_extra LIMIT %d, %d", - intval($user['uid']), - DBA::escape(Protocol::DFRN), - DBA::escape(Protocol::DIASPORA), - DBA::escape(Protocol::OSTATUS), - DBA::escape(Protocol::STATUSNET), - intval($startIndex), - intval($itemsPerPage) - ); - } - Logger::log("Query done", Logger::DEBUG); + 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'])) { @@ -168,7 +66,7 @@ function poco_init(App $a) { if (!empty($_GET['filtered'])) { $ret['filtered'] = false; } - if (!empty($_GET['updatedSince']) && ! $global) { + if (!empty($_GET['updatedSince'])) { $ret['updatedSince'] = false; } $ret['startIndex'] = (int) $startIndex; @@ -176,7 +74,6 @@ function poco_init(App $a) { $ret['totalResults'] = (int) $totalResults; $ret['entry'] = []; - $fields_ret = [ 'id' => false, 'displayName' => false, @@ -193,7 +90,7 @@ function poco_init(App $a) { 'generation' => false ]; - if (empty($_GET['fields']) || ($_GET['fields'] === '@all')) { + if (empty($_GET['fields'])) { foreach ($fields_ret as $k => $v) { $fields_ret[$k] = true; } @@ -204,145 +101,129 @@ function poco_init(App $a) { } } - if (is_array($contacts)) { - if (DBA::isResult($contacts)) { - foreach ($contacts as $contact) { - if (!isset($contact['updated'])) { - $contact['updated'] = ''; - } - - if (! isset($contact['generation'])) { - if ($global) { - $contact['generation'] = 3; - } elseif ($system_mode) { - $contact['generation'] = 1; - } else { - $contact['generation'] = 2; - } - } - - if (($contact['keywords'] == "") && isset($contact['pub_keywords'])) { - $contact['keywords'] = $contact['pub_keywords']; - } - if (isset($contact['account-type'])) { - $contact['contact-type'] = $contact['account-type']; - } - $about = DI::cache()->get("about:" . $contact['updated'] . ":" . $contact['nurl']); - if (is_null($about)) { - $about = BBCode::convert($contact['about'], false); - DI::cache()->set("about:" . $contact['updated'] . ":" . $contact['nurl'], $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']) { - if (! $global) { - $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']; - } - } else { - $entry['updated'] = $contact['updated']; - } - $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'] = []; - - // Deactivated. It just reveals too much data. (Although its from the default profile) - //if (isset($rr['address'])) - // $entry['address']['streetAddress'] = $rr['address']; - - if (isset($contact['locality'])) { - $entry['address']['locality'] = $contact['locality']; - } - if (isset($contact['region'])) { - $entry['address']['region'] = $contact['region']; - } - // See above - //if (isset($rr['postal-code'])) - // $entry['address']['postalCode'] = $rr['postal-code']; - - 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'][] = []; - } - } else { + if (!is_array($contacts)) { throw new \Friendica\Network\HTTPException\InternalServerErrorException(); } - Logger::log("End of poco", Logger::DEBUG); + 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']; + } + $about = DI::cache()->get("about:" . $contact['updated'] . ":" . $contact['nurl']); + if (is_null($about)) { + $about = BBCode::convert($contact['about'], false); + DI::cache()->set("about:" . $contact['updated'] . ":" . $contact['nurl'], $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 === 'xml') { header('Content-type: text/xml'); diff --git a/mod/pubsub.php b/mod/pubsub.php index cae346493f..cbf678a93f 100644 --- a/mod/pubsub.php +++ b/mod/pubsub.php @@ -25,6 +25,7 @@ use Friendica\Core\Protocol; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; +use Friendica\Protocol\Feed; use Friendica\Protocol\OStatus; use Friendica\Util\Strings; use Friendica\Util\Network; @@ -138,20 +139,15 @@ function pubsub_post(App $a) hub_post_return(); } - // We import feeds from OStatus, Friendica and ATOM/RSS. - /// @todo Check if Friendica posts really arrive here - otherwise we can discard some stuff - if (!in_array($contact['network'], [Protocol::OSTATUS, Protocol::DFRN, Protocol::FEED])) { + // We only import feeds from OStatus here + if ($contact['network'] != Protocol::OSTATUS) { + Logger::warning('Unexpected network', ['contact' => $contact]); hub_post_return(); } Logger::log('Import item for ' . $nick . ' from ' . $contact['nick'] . ' (' . $contact['id'] . ')'); $feedhub = ''; - consume_feed($xml, $importer, $contact, $feedhub); - - // do it a second time for DFRN so that any children find their parents. - if ($contact['network'] === Protocol::DFRN) { - consume_feed($xml, $importer, $contact, $feedhub); - } + OStatus::import($xml, $importer, $contact, $feedhub); hub_post_return(); } diff --git a/mod/pubsubhubbub.php b/mod/pubsubhubbub.php index 4d33503796..3445436182 100644 --- a/mod/pubsubhubbub.php +++ b/mod/pubsubhubbub.php @@ -24,7 +24,6 @@ use Friendica\Core\Logger; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\PushSubscriber; -use Friendica\Util\Network; use Friendica\Util\Strings; function post_var($name) { @@ -126,7 +125,7 @@ function pubsubhubbub_init(App $a) { $hub_callback = rtrim($hub_callback, ' ?&#'); $separator = parse_url($hub_callback, PHP_URL_QUERY) === null ? '?' : '&'; - $fetchResult = Network::fetchUrlFull($hub_callback . $separator . $params); + $fetchResult = DI::httpRequest()->fetchFull($hub_callback . $separator . $params); $body = $fetchResult->getBody(); $ret = $fetchResult->getReturnCode(); diff --git a/mod/redir.php b/mod/redir.php index 56cb13a06b..b2f76738be 100644 --- a/mod/redir.php +++ b/mod/redir.php @@ -27,10 +27,12 @@ use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\Profile; -use Friendica\Util\Network; use Friendica\Util\Strings; function redir_init(App $a) { + if (!Session::isAuthenticated()) { + throw new \Friendica\Network\HTTPException\ForbiddenException(DI::l10n()->t('Access denied.')); + } $url = $_GET['url'] ?? ''; $quiet = !empty($_GET['quiet']) ? '&quiet=1' : ''; @@ -44,102 +46,102 @@ function redir_init(App $a) { // Try magic auth before the legacy stuff redir_magic($a, $cid, $url); - if (!empty($cid)) { - $fields = ['id', 'uid', 'nurl', 'url', 'addr', 'name', 'network', 'poll', 'issued-id', 'dfrn-id', 'duplex', 'pending']; - $contact = DBA::selectFirst('contact', $fields, ['id' => $cid, 'uid' => [0, local_user()]]); - if (!DBA::isResult($contact)) { - notice(DI::l10n()->t('Contact not found.')); - DI::baseUrl()->redirect(); + if (empty($cid)) { + throw new \Friendica\Network\HTTPException\BadRequestException(DI::l10n()->t('Bad Request.')); + } + + $fields = ['id', 'uid', 'nurl', 'url', 'addr', 'name', 'network', 'poll', 'issued-id', 'dfrn-id', 'duplex', 'pending']; + $contact = DBA::selectFirst('contact', $fields, ['id' => $cid, 'uid' => [0, local_user()]]); + if (!DBA::isResult($contact)) { + throw new \Friendica\Network\HTTPException\NotFoundException(DI::l10n()->t('Contact not found.')); + } + + $contact_url = $contact['url']; + + if (!empty($a->contact['id']) && $a->contact['id'] == $cid) { + // Local user is already authenticated. + redir_check_url($contact_url, $url); + $a->redirect($url ?: $contact_url); + } + + if ($contact['uid'] == 0 && local_user()) { + // Let's have a look if there is an established connection + // between the public contact we have found and the local user. + $contact = DBA::selectFirst('contact', $fields, ['nurl' => $contact['nurl'], 'uid' => local_user()]); + + if (DBA::isResult($contact)) { + $cid = $contact['id']; } - $contact_url = $contact['url']; + if (!empty($a->contact['id']) && $a->contact['id'] == $cid) { + // Local user is already authenticated. + redir_check_url($contact_url, $url); + $target_url = $url ?: $contact_url; + Logger::log($contact['name'] . " is already authenticated. Redirecting to " . $target_url, Logger::DEBUG); + $a->redirect($target_url); + } + } - if (!Session::isAuthenticated() // Visitors (not logged in or not remotes) can't authenticate. - || (!empty($a->contact['id']) && $a->contact['id'] == $cid)) // Local user is already authenticated. - { - $a->redirect($url ?: $contact_url); + if (remote_user()) { + $host = substr(DI::baseUrl()->getUrlPath() . (DI::baseUrl()->getUrlPath() ? '/' . DI::baseUrl()->getUrlPath() : ''), strpos(DI::baseUrl()->getUrlPath(), '://') + 3); + $remotehost = substr($contact['addr'], strpos($contact['addr'], '@') + 1); + + // On a local instance we have to check if the local user has already authenticated + // with the local contact. Otherwise the local user would ask the local contact + // for authentification everytime he/she is visiting a profile page of the local + // contact. + if (($host == $remotehost) && (Session::getRemoteContactID(Session::get('visitor_visiting')) == Session::get('visitor_id'))) { + // Remote user is already authenticated. + redir_check_url($contact_url, $url); + $target_url = $url ?: $contact_url; + Logger::log($contact['name'] . " is already authenticated. Redirecting to " . $target_url, Logger::DEBUG); + $a->redirect($target_url); + } + } + + // Doing remote auth with dfrn. + if (local_user() && (!empty($contact['dfrn-id']) || !empty($contact['issued-id'])) && empty($contact['pending'])) { + $dfrn_id = $orig_id = (($contact['issued-id']) ? $contact['issued-id'] : $contact['dfrn-id']); + + if ($contact['duplex'] && $contact['issued-id']) { + $orig_id = $contact['issued-id']; + $dfrn_id = '1:' . $orig_id; + } + if ($contact['duplex'] && $contact['dfrn-id']) { + $orig_id = $contact['dfrn-id']; + $dfrn_id = '0:' . $orig_id; } - if ($contact['uid'] == 0 && local_user()) { - // Let's have a look if there is an established connection - // between the public contact we have found and the local user. - $contact = DBA::selectFirst('contact', $fields, ['nurl' => $contact['nurl'], 'uid' => local_user()]); + $sec = Strings::getRandomHex(); - if (DBA::isResult($contact)) { - $cid = $contact['id']; - } + $fields = ['uid' => local_user(), 'cid' => $cid, 'dfrn_id' => $dfrn_id, + 'sec' => $sec, 'expire' => time() + 45]; + DBA::insert('profile_check', $fields); - if (!empty($a->contact['id']) && $a->contact['id'] == $cid) { - // Local user is already authenticated. - $target_url = $url ?: $contact_url; - Logger::log($contact['name'] . " is already authenticated. Redirecting to " . $target_url, Logger::DEBUG); - $a->redirect($target_url); - } - } + Logger::log('mod_redir: ' . $contact['name'] . ' ' . $sec, Logger::DEBUG); - if (remote_user()) { - $host = substr(DI::baseUrl()->getUrlPath() . (DI::baseUrl()->getUrlPath() ? '/' . DI::baseUrl()->getUrlPath() : ''), strpos(DI::baseUrl()->getUrlPath(), '://') + 3); - $remotehost = substr($contact['addr'], strpos($contact['addr'], '@') + 1); + $dest = (!empty($url) ? '&destination_url=' . $url : ''); - // On a local instance we have to check if the local user has already authenticated - // with the local contact. Otherwise the local user would ask the local contact - // for authentification everytime he/she is visiting a profile page of the local - // contact. - if (($host == $remotehost) && (Session::getRemoteContactID(Session::get('visitor_visiting')) == Session::get('visitor_id'))) { - // Remote user is already authenticated. - $target_url = $url ?: $contact_url; - Logger::log($contact['name'] . " is already authenticated. Redirecting to " . $target_url, Logger::DEBUG); - $a->redirect($target_url); - } - } + System::externalRedirect($contact['poll'] . '?dfrn_id=' . $dfrn_id + . '&dfrn_version=' . DFRN_PROTOCOL_VERSION . '&type=profile&sec=' . $sec . $dest . $quiet); + } - // Doing remote auth with dfrn. - if (local_user() && (!empty($contact['dfrn-id']) || !empty($contact['issued-id'])) && empty($contact['pending'])) { - $dfrn_id = $orig_id = (($contact['issued-id']) ? $contact['issued-id'] : $contact['dfrn-id']); - - if ($contact['duplex'] && $contact['issued-id']) { - $orig_id = $contact['issued-id']; - $dfrn_id = '1:' . $orig_id; - } - if ($contact['duplex'] && $contact['dfrn-id']) { - $orig_id = $contact['dfrn-id']; - $dfrn_id = '0:' . $orig_id; - } - - $sec = Strings::getRandomHex(); - - $fields = ['uid' => local_user(), 'cid' => $cid, 'dfrn_id' => $dfrn_id, - 'sec' => $sec, 'expire' => time() + 45]; - DBA::insert('profile_check', $fields); - - Logger::log('mod_redir: ' . $contact['name'] . ' ' . $sec, Logger::DEBUG); - - $dest = (!empty($url) ? '&destination_url=' . $url : ''); - - System::externalRedirect($contact['poll'] . '?dfrn_id=' . $dfrn_id - . '&dfrn_version=' . DFRN_PROTOCOL_VERSION . '&type=profile&sec=' . $sec . $dest . $quiet); - } - - $url = $url ?: $contact_url; + if (empty($url)) { + throw new \Friendica\Network\HTTPException\BadRequestException(DI::l10n()->t('Bad Request.')); } // If we don't have a connected contact, redirect with // the 'zrl' parameter. - if (!empty($url)) { - $my_profile = Profile::getMyURL(); + $my_profile = Profile::getMyURL(); - if (!empty($my_profile) && !Strings::compareLink($my_profile, $url)) { - $separator = strpos($url, '?') ? '&' : '?'; + if (!empty($my_profile) && !Strings::compareLink($my_profile, $url)) { + $separator = strpos($url, '?') ? '&' : '?'; - $url .= $separator . 'zrl=' . urlencode($my_profile); - } - - Logger::log('redirecting to ' . $url, Logger::DEBUG); - $a->redirect($url); + $url .= $separator . 'zrl=' . urlencode($my_profile); } - notice(DI::l10n()->t('Contact not found.')); - DI::baseUrl()->redirect(); + Logger::log('redirecting to ' . $url, Logger::DEBUG); + $a->redirect($url); } function redir_magic($a, $cid, $url) @@ -152,15 +154,10 @@ function redir_magic($a, $cid, $url) $contact = DBA::selectFirst('contact', ['url'], ['id' => $cid]); if (!DBA::isResult($contact)) { Logger::info('Contact not found', ['id' => $cid]); - // Shouldn't happen under normal conditions - notice(DI::l10n()->t('Contact not found.')); - if (!empty($url)) { - System::externalRedirect($url); - } else { - DI::baseUrl()->redirect(); - } + throw new \Friendica\Network\HTTPException\NotFoundException(DI::l10n()->t('Contact not found.')); } else { $contact_url = $contact['url']; + redir_check_url($contact_url, $url); $target_url = $url ?: $contact_url; } @@ -173,7 +170,7 @@ function redir_magic($a, $cid, $url) } // Test for magic auth on the target system - $serverret = Network::curl($basepath . '/magic'); + $serverret = DI::httpRequest()->get($basepath . '/magic'); if ($serverret->isSuccess()) { $separator = strpos($target_url, '?') ? '&' : '?'; $target_url .= $separator . 'zrl=' . urlencode($visitor) . '&addr=' . urlencode($contact_url); @@ -184,3 +181,24 @@ function redir_magic($a, $cid, $url) Logger::info('No magic for contact', ['contact' => $contact_url]); } } + +function redir_check_url(string $contact_url, string $url) +{ + if (empty($contact_url) || empty($url)) { + return; + } + + $url_host = parse_url($url, PHP_URL_HOST); + if (empty($url_host)) { + $url_host = parse_url(DI::baseUrl(), PHP_URL_HOST); + } + + $contact_url_host = parse_url($contact_url, PHP_URL_HOST); + + if ($url_host == $contact_url_host) { + return; + } + + Logger::error('URL check host mismatch', ['contact' => $contact_url, 'url' => $url]); + throw new \Friendica\Network\HTTPException\ForbiddenException(DI::l10n()->t('Access denied.')); +} diff --git a/mod/repair_ostatus.php b/mod/repair_ostatus.php index 6ceba80553..0d30fc298d 100644 --- a/mod/repair_ostatus.php +++ b/mod/repair_ostatus.php @@ -28,7 +28,7 @@ use Friendica\Model\Contact; function repair_ostatus_content(App $a) { if (! local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); DI::baseUrl()->redirect('ostatus_repair'); // NOTREACHED } @@ -70,7 +70,7 @@ function repair_ostatus_content(App $a) { $o .= "

".DI::l10n()->t("Keep this window open until done.")."

"; - Contact::createFromProbe($uid, $r[0]["url"], true); + Contact::createFromProbe($a->user, $r[0]["url"], true); DI::page()['htmlhead'] = ''; diff --git a/mod/salmon.php b/mod/salmon.php index 6eea57f6a7..bc4410434b 100644 --- a/mod/salmon.php +++ b/mod/salmon.php @@ -42,15 +42,11 @@ function salmon_post(App $a, $xml = '') { $nick = (($a->argc > 1) ? Strings::escapeTags(trim($a->argv[1])) : ''); - $r = q("SELECT * FROM `user` WHERE `nickname` = '%s' AND `account_expired` = 0 AND `account_removed` = 0 LIMIT 1", - DBA::escape($nick) - ); - if (! DBA::isResult($r)) { + $importer = DBA::selectFirst('user', [], ['nickname' => $nick, 'account_expired' => false, 'account_removed' => false]); + if (! DBA::isResult($importer)) { throw new \Friendica\Network\HTTPException\InternalServerErrorException(); } - $importer = $r[0]; - // parse the xml $dom = simplexml_load_string($xml,'SimpleXMLElement',0, ActivityNamespace::SALMON_ME); @@ -83,7 +79,7 @@ function salmon_post(App $a, $xml = '') { // stash away some other stuff for later $type = $base->data[0]->attributes()->type[0]; - $keyhash = $base->sig[0]->attributes()->keyhash[0]; + $keyhash = $base->sig[0]->attributes()->keyhash[0] ?? ''; $encoding = $base->encoding; $alg = $base->alg; @@ -124,7 +120,7 @@ function salmon_post(App $a, $xml = '') { $m = Strings::base64UrlDecode($key_info[1]); $e = Strings::base64UrlDecode($key_info[2]); - Logger::log('key details: ' . print_r($key_info,true), Logger::DEBUG); + Logger::info('key details', ['info' => $key_info]); $pubkey = Crypto::meToPem($m, $e); @@ -175,7 +171,7 @@ function salmon_post(App $a, $xml = '') { Logger::log('Author ' . $author_link . ' unknown to user ' . $importer['uid'] . '.'); if (DI::pConfig()->get($importer['uid'], 'system', 'ostatus_autofriend')) { - $result = Contact::createFromProbe($importer['uid'], $author_link); + $result = Contact::createFromProbe($importer, $author_link); if ($result['success']) { $r = q("SELECT * FROM `contact` WHERE `network` = '%s' AND ( `url` = '%s' OR `alias` = '%s') diff --git a/mod/settings.php b/mod/settings.php index c4e2482939..6c41856a4f 100644 --- a/mod/settings.php +++ b/mod/settings.php @@ -31,7 +31,6 @@ use Friendica\Core\Worker; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; -use Friendica\Model\GContact; use Friendica\Model\Group; use Friendica\Model\Notify\Type; use Friendica\Model\User; @@ -63,7 +62,7 @@ function settings_post(App $a) } if (count($a->user) && !empty($a->user['uid']) && $a->user['uid'] != local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } @@ -183,7 +182,7 @@ function settings_post(App $a) intval($mail_pubmail), intval(local_user()) ); - Logger::log("mail: updating mailaccount. Response: ".print_r($r, true)); + Logger::notice('updating mailaccount', ['response' => $r]); $r = q("SELECT * FROM `mailacct` WHERE `uid` = %d LIMIT 1", intval(local_user()) ); @@ -198,13 +197,10 @@ function settings_post(App $a) unset($dcrpass); if (!$mbox) { $failed = true; - notice(DI::l10n()->t('Failed to connect with email account using the settings provided.') . EOL); + notice(DI::l10n()->t('Failed to connect with email account using the settings provided.')); } } } - if (!$failed) { - info(DI::l10n()->t('Email settings updated.') . EOL); - } } } @@ -219,7 +215,6 @@ function settings_post(App $a) DI::pConfig()->set(local_user(), 'feature', substr($k, 8), ((intval($v)) ? 1 : 0)); } } - info(DI::l10n()->t('Features updated') . EOL); return; } @@ -230,10 +225,11 @@ function settings_post(App $a) if (isset($_FILES['importcontact-filename'])) { // was there an error if ($_FILES['importcontact-filename']['error'] > 0) { - Logger::notice('Contact CSV file upload error'); - info(DI::l10n()->t('Contact CSV file upload error')); + Logger::notice('Contact CSV file upload error', ['error' => $_FILES['importcontact-filename']['error']]); + notice(DI::l10n()->t('Contact CSV file upload error')); } else { $csvArray = array_map('str_getcsv', file($_FILES['importcontact-filename']['tmp_name'])); + Logger::info('Import started', ['lines' => count($csvArray)]); // import contacts foreach ($csvArray as $csvRow) { // The 1st row may, or may not contain the headers of the table @@ -245,11 +241,14 @@ function settings_post(App $a) Worker::add(PRIORITY_LOW, 'AddContact', $_SESSION['uid'], $csvRow[0]); } } + Logger::info('Import done'); info(DI::l10n()->t('Importing Contacts done')); // delete temp file unlink($_FILES['importcontact-filename']['tmp_name']); } + } else { + Logger::info('Import triggered, but no import file was found.'); } return; @@ -424,10 +423,10 @@ function settings_post(App $a) $hidewall = 1; if (!$str_contact_allow && !$str_group_allow && !$str_contact_deny && !$str_group_deny) { if ($def_gid) { - info(DI::l10n()->t('Private forum has no privacy permissions. Using default privacy group.'). EOL); + info(DI::l10n()->t('Private forum has no privacy permissions. Using default privacy group.')); $str_group_allow = '<' . $def_gid . '>'; } else { - notice(DI::l10n()->t('Private forum has no privacy permissions and no default privacy group.') . EOL); + notice(DI::l10n()->t('Private forum has no privacy permissions and no default privacy group.')); } } } @@ -443,8 +442,8 @@ function settings_post(App $a) $fields['openidserver'] = ''; } - if (DBA::update('user', $fields, ['uid' => local_user()])) { - info(DI::l10n()->t('Settings updated.') . EOL); + if (!DBA::update('user', $fields, ['uid' => local_user()])) { + notice(DI::l10n()->t('Settings were not updated.')); } // clear session language @@ -475,9 +474,6 @@ function settings_post(App $a) Worker::add(PRIORITY_LOW, 'ProfileUpdate', local_user()); - // Update the global contact for the user - GContact::updateForUser(local_user()); - DI::baseUrl()->redirect('settings'); return; // NOTREACHED } @@ -489,12 +485,12 @@ function settings_content(App $a) Nav::setSelected('settings'); if (!local_user()) { - //notice(DI::l10n()->t('Permission denied.') . EOL); + //notice(DI::l10n()->t('Permission denied.')); return Login::form(); } if (!empty($_SESSION['submanage'])) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } @@ -722,7 +718,7 @@ function settings_content(App $a) $profile = DBA::selectFirst('profile', [], ['uid' => local_user()]); if (!DBA::isResult($profile)) { - notice(DI::l10n()->t('Unable to find your profile. Please contact your admin.') . EOL); + notice(DI::l10n()->t('Unable to find your profile. Please contact your admin.')); return; } @@ -837,26 +833,6 @@ function settings_content(App $a) $stpl = Renderer::getMarkupTemplate('settings/settings.tpl'); - // Private/public post links for the non-JS ACL form - $private_post = 1; - if (!empty($_REQUEST['public']) && !$_REQUEST['public']) { - $private_post = 0; - } - - $query_str = DI::args()->getQueryString(); - if (strpos($query_str, 'public=1') !== false) { - $query_str = str_replace(['?public=1', '&public=1'], ['', ''], $query_str); - } - - // I think $a->query_string may never have ? in it, but I could be wrong - // It looks like it's from the index.php?q=[etc] rewrite that the web - // server does, which converts any ? to &, e.g. suggest&ignore=61 for suggest?ignore=61 - if (strpos($query_str, '?') === false) { - $public_post_link = '?public=1'; - } else { - $public_post_link = '&public=1'; - } - /* Installed langs */ $lang_choices = DI::l10n()->getAvailableLanguages(); @@ -874,7 +850,7 @@ function settings_content(App $a) '$password1'=> ['password', DI::l10n()->t('New Password:'), '', DI::l10n()->t('Allowed characters are a-z, A-Z, 0-9 and special characters except white spaces, accentuated letters and colon (:).')], '$password2'=> ['confirm', DI::l10n()->t('Confirm:'), '', DI::l10n()->t('Leave password fields blank unless changing')], '$password3'=> ['opassword', DI::l10n()->t('Current Password:'), '', DI::l10n()->t('Your current password to confirm the changes')], - '$password4'=> ['mpassword', DI::l10n()->t('Password:'), '', DI::l10n()->t('Your current password to confirm the changes')], + '$password4'=> ['mpassword', DI::l10n()->t('Password:'), '', DI::l10n()->t('Your current password to confirm the changes of the email address')], '$oid_enable' => (!DI::config()->get('system', 'no_openid')), '$openid' => $openid_field, '$delete_openid' => ['delete_openid', DI::l10n()->t('Delete OpenID URL'), false, ''], diff --git a/mod/share.php b/mod/share.php index 3e9b6aee6f..a8ac3bd8b5 100644 --- a/mod/share.php +++ b/mod/share.php @@ -20,6 +20,7 @@ */ use Friendica\App; +use Friendica\Content\Text\BBCode; use Friendica\Database\DBA; use Friendica\Model\Item; @@ -42,7 +43,7 @@ function share_init(App $a) { $pos = strpos($item['body'], "[share"); $o = substr($item['body'], $pos); } else { - $o = share_header($item['author-name'], $item['author-link'], $item['author-avatar'], $item['guid'], $item['created'], $item['plink']); + $o = BBCode::getShareOpeningTag($item['author-name'], $item['author-link'], $item['author-avatar'], $item['plink'], $item['created'], $item['guid']); if ($item['title']) { $o .= '[h3]'.$item['title'].'[/h3]'."\n"; @@ -55,22 +56,3 @@ function share_init(App $a) { echo $o; exit(); } - -/// @TODO Rewrite to handle over whole record array -function share_header($author, $profile, $avatar, $guid, $posted, $link) { - $header = "[share author='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $author). - "' profile='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $profile). - "' avatar='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $avatar); - - if ($guid) { - $header .= "' guid='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $guid); - } - - if ($posted) { - $header .= "' posted='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $posted); - } - - $header .= "' link='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $link)."']"; - - return $header; -} diff --git a/mod/subthread.php b/mod/subthread.php index ebec978c59..93992d8daa 100644 --- a/mod/subthread.php +++ b/mod/subthread.php @@ -34,7 +34,7 @@ function subthread_content(App $a) $item_id = (($a->argc > 1) ? Strings::escapeTags(trim($a->argv[1])) : 0); - if (!Item::performActivity($item_id, 'follow')) { + if (!Item::performActivity($item_id, 'follow', local_user())) { Logger::info('Following item failed', ['item' => $item_id]); throw new HTTPException\BadRequestException(); } diff --git a/mod/suggest.php b/mod/suggest.php index 5fb9bdcff7..0965978ce0 100644 --- a/mod/suggest.php +++ b/mod/suggest.php @@ -20,39 +20,18 @@ */ use Friendica\App; -use Friendica\Content\ContactSelector; use Friendica\Content\Widget; use Friendica\Core\Renderer; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; -use Friendica\Model\GContact; -use Friendica\Util\Proxy as ProxyUtils; - -function suggest_init(App $a) -{ - if (! local_user()) { - return; - } -} - -function suggest_post(App $a) -{ - if (!empty($_POST['ignore']) && !empty($_POST['confirm'])) { - DBA::insert('gcign', ['uid' => local_user(), 'gcid' => $_POST['ignore']]); - notice(DI::l10n()->t('Contact suggestion successfully ignored.')); - } - - DI::baseUrl()->redirect('suggest'); -} +use Friendica\Module\Contact as ModuleContact; +use Friendica\Network\HTTPException; function suggest_content(App $a) { - $o = ''; - - if (! local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); - return; + if (!local_user()) { + throw new HTTPException\ForbiddenException(DI::l10n()->t('Permission denied.')); } $_SESSION['return_path'] = DI::args()->getCommand(); @@ -60,80 +39,20 @@ function suggest_content(App $a) DI::page()['aside'] .= Widget::findPeople(); DI::page()['aside'] .= Widget::follow(); - - $r = GContact::suggestionQuery(local_user()); - - if (! DBA::isResult($r)) { - $o .= DI::l10n()->t('No suggestions available. If this is a new site, please try again in 24 hours.'); - return $o; + $contacts = Contact\Relation::getSuggestions(local_user()); + if (!DBA::isResult($contacts)) { + return DI::l10n()->t('No suggestions available. If this is a new site, please try again in 24 hours.'); } - - if (!empty($_GET['ignore'])) { - // can't take arguments in its "action" parameter - // so add any arguments as hidden inputs - $query = explode_querystring(DI::args()->getQueryString()); - $inputs = []; - foreach ($query['args'] as $arg) { - if (strpos($arg, 'confirm=') === false) { - $arg_parts = explode('=', $arg); - $inputs[] = ['name' => $arg_parts[0], 'value' => $arg_parts[1]]; - } - } - - return Renderer::replaceMacros(Renderer::getMarkupTemplate('confirm.tpl'), [ - '$method' => 'post', - '$message' => DI::l10n()->t('Do you really want to delete this suggestion?'), - '$extra_inputs' => $inputs, - '$confirm' => DI::l10n()->t('Yes'), - '$confirm_url' => $query['base'], - '$confirm_name' => 'confirm', - '$cancel' => DI::l10n()->t('Cancel'), - ]); - } - - $id = 0; $entries = []; - - foreach ($r as $rr) { - $connlnk = DI::baseUrl() . '/follow/?url=' . (($rr['connect']) ? $rr['connect'] : $rr['url']); - $ignlnk = DI::baseUrl() . '/suggest?ignore=' . $rr['id']; - $photo_menu = [ - 'profile' => [DI::l10n()->t("View Profile"), Contact::magicLink($rr["url"])], - 'follow' => [DI::l10n()->t("Connect/Follow"), $connlnk], - 'hide' => [DI::l10n()->t('Ignore/Hide'), $ignlnk] - ]; - - $contact_details = Contact::getDetailsByURL($rr["url"], local_user(), $rr); - - $entry = [ - 'url' => Contact::magicLink($rr['url']), - 'itemurl' => (($contact_details['addr'] != "") ? $contact_details['addr'] : $rr['url']), - 'img_hover' => $rr['url'], - 'name' => $contact_details['name'], - 'thumb' => ProxyUtils::proxifyUrl($contact_details['thumb'], false, ProxyUtils::SIZE_THUMB), - 'details' => $contact_details['location'], - 'tags' => $contact_details['keywords'], - 'about' => $contact_details['about'], - 'account_type' => Contact::getAccountType($contact_details), - 'ignlnk' => $ignlnk, - 'ignid' => $rr['id'], - 'conntxt' => DI::l10n()->t('Connect'), - 'connlnk' => $connlnk, - 'photo_menu' => $photo_menu, - 'ignore' => DI::l10n()->t('Ignore/Hide'), - 'network' => ContactSelector::networkToName($rr['network'], $rr['url']), - 'id' => ++$id, - ]; - $entries[] = $entry; + foreach ($contacts as $contact) { + $entries[] = ModuleContact::getContactTemplateVars($contact); } $tpl = Renderer::getMarkupTemplate('viewcontact_template.tpl'); - $o .= Renderer::replaceMacros($tpl,[ + return Renderer::replaceMacros($tpl,[ '$title' => DI::l10n()->t('Friend Suggestions'), '$contacts' => $entries, ]); - - return $o; } diff --git a/mod/tagger.php b/mod/tagger.php index 86a6ff69f3..63e7f2ca80 100644 --- a/mod/tagger.php +++ b/mod/tagger.php @@ -136,7 +136,7 @@ EOT; $arr['wall'] = $item['wall']; $arr['gravity'] = GRAVITY_COMMENT; $arr['parent'] = $item['id']; - $arr['parent-uri'] = $item['uri']; + $arr['thr-parent'] = $item['uri']; $arr['owner-name'] = $item['author-name']; $arr['owner-link'] = $item['author-link']; $arr['owner-avatar'] = $item['author-avatar']; diff --git a/mod/tagrm.php b/mod/tagrm.php index 4022f999db..179276663b 100644 --- a/mod/tagrm.php +++ b/mod/tagrm.php @@ -44,7 +44,6 @@ function tagrm_post(App $a) $item_id = $_POST['item'] ?? 0; update_tags($item_id, $tags); - info(DI::l10n()->t('Tag(s) removed') . EOL); DI::baseUrl()->redirect($_SESSION['photo_return']); // NOTREACHED diff --git a/mod/uimport.php b/mod/uimport.php index eb99a366f2..8abff0cd99 100644 --- a/mod/uimport.php +++ b/mod/uimport.php @@ -29,7 +29,7 @@ use Friendica\DI; function uimport_post(App $a) { if ((DI::config()->get('config', 'register_policy') != \Friendica\Module\Register::OPEN) && !is_site_admin()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } @@ -42,7 +42,7 @@ function uimport_post(App $a) function uimport_content(App $a) { if ((DI::config()->get('config', 'register_policy') != \Friendica\Module\Register::OPEN) && !is_site_admin()) { - notice(DI::l10n()->t('User imports on closed servers can only be done by an administrator.') . EOL); + notice(DI::l10n()->t('User imports on closed servers can only be done by an administrator.')); return; } @@ -51,7 +51,7 @@ function uimport_content(App $a) $r = q("select count(*) as total from user where register_date > UTC_TIMESTAMP - INTERVAL 1 day"); if ($r && $r[0]['total'] >= $max_dailies) { Logger::log('max daily registrations exceeded.'); - notice(DI::l10n()->t('This site has exceeded the number of allowed daily account registrations. Please try again tomorrow.') . EOL); + notice(DI::l10n()->t('This site has exceeded the number of allowed daily account registrations. Please try again tomorrow.')); return; } } diff --git a/mod/unfollow.php b/mod/unfollow.php index c754b384d3..54e015cf52 100644 --- a/mod/unfollow.php +++ b/mod/unfollow.php @@ -31,57 +31,15 @@ use Friendica\Util\Strings; function unfollow_post(App $a) { - $base_return_path = 'contact'; - if (!local_user()) { notice(DI::l10n()->t('Permission denied.')); DI::baseUrl()->redirect('login'); // NOTREACHED } - $uid = local_user(); $url = Strings::escapeTags(trim($_REQUEST['url'] ?? '')); - $condition = ["`uid` = ? AND (`rel` = ? OR `rel` = ?) AND (`nurl` = ? OR `alias` = ? OR `alias` = ?)", - $uid, Contact::SHARING, Contact::FRIEND, Strings::normaliseLink($url), - Strings::normaliseLink($url), $url]; - $contact = DBA::selectFirst('contact', [], $condition); - - if (!DBA::isResult($contact)) { - notice(DI::l10n()->t("You aren't following this contact.")); - DI::baseUrl()->redirect($base_return_path); - // NOTREACHED - } - - if (!empty($_REQUEST['cancel'])) { - DI::baseUrl()->redirect($base_return_path . '/' . $contact['id']); - } - - if (!in_array($contact['network'], Protocol::NATIVE_SUPPORT)) { - notice(DI::l10n()->t('Unfollowing is currently not supported by your network.')); - DI::baseUrl()->redirect($base_return_path . '/' . $contact['id']); - // NOTREACHED - } - - $dissolve = ($contact['rel'] == Contact::SHARING); - - $owner = User::getOwnerDataById($uid); - if ($owner) { - Contact::terminateFriendship($owner, $contact, $dissolve); - } - - // Sharing-only contacts get deleted as there no relationship any more - if ($dissolve) { - Contact::remove($contact['id']); - $return_path = $base_return_path; - } else { - DBA::update('contact', ['rel' => Contact::FOLLOWER], ['id' => $contact['id']]); - $return_path = $base_return_path . '/' . $contact['id']; - } - - info(DI::l10n()->t('Contact unfollowed')); - DI::baseUrl()->redirect($return_path); - // NOTREACHED + unfollow_process($url); } function unfollow_content(App $a) @@ -129,6 +87,10 @@ function unfollow_content(App $a) // Makes the connection request for friendica contacts easier $_SESSION['fastlane'] = $contact['url']; + if (!empty($_REQUEST['auto'])) { + unfollow_process($contact['url']); + } + $o = Renderer::replaceMacros($tpl, [ '$header' => DI::l10n()->t('Disconnect/Unfollow'), '$page_desc' => '', @@ -146,7 +108,7 @@ function unfollow_content(App $a) ]); DI::page()['aside'] = ''; - Profile::load($a, '', Contact::getDetailsByURL($contact['url'])); + Profile::load($a, '', Contact::getByURL($contact['url'], false)); $o .= Renderer::replaceMacros(Renderer::getMarkupTemplate('section_title.tpl'), ['$title' => DI::l10n()->t('Status Messages and Posts')]); @@ -155,3 +117,45 @@ function unfollow_content(App $a) return $o; } + +function unfollow_process(string $url) +{ + $base_return_path = 'contact'; + + $uid = local_user(); + + $condition = ["`uid` = ? AND (`rel` = ? OR `rel` = ?) AND (`nurl` = ? OR `alias` = ? OR `alias` = ?)", + $uid, Contact::SHARING, Contact::FRIEND, Strings::normaliseLink($url), + Strings::normaliseLink($url), $url]; + $contact = DBA::selectFirst('contact', [], $condition); + + if (!DBA::isResult($contact)) { + notice(DI::l10n()->t("You aren't following this contact.")); + DI::baseUrl()->redirect($base_return_path); + // NOTREACHED + } + + if (!in_array($contact['network'], Protocol::NATIVE_SUPPORT)) { + notice(DI::l10n()->t('Unfollowing is currently not supported by your network.')); + DI::baseUrl()->redirect($base_return_path . '/' . $contact['id']); + // NOTREACHED + } + + $dissolve = ($contact['rel'] == Contact::SHARING); + + $owner = User::getOwnerDataById($uid); + if ($owner) { + Contact::terminateFriendship($owner, $contact, $dissolve); + } + + // Sharing-only contacts get deleted as there no relationship any more + if ($dissolve) { + Contact::remove($contact['id']); + $return_path = $base_return_path; + } else { + DBA::update('contact', ['rel' => Contact::FOLLOWER], ['id' => $contact['id']]); + $return_path = $base_return_path . '/' . $contact['id']; + } + + DI::baseUrl()->redirect($return_path); +} \ No newline at end of file diff --git a/mod/update_contact.php b/mod/update_contact.php index 4863ad02de..9681e7c179 100644 --- a/mod/update_contact.php +++ b/mod/update_contact.php @@ -24,15 +24,21 @@ use Friendica\App; use Friendica\Core\System; use Friendica\DI; +use Friendica\Model\Item; use Friendica\Module\Contact; function update_contact_content(App $a) { - if (!empty($_GET['force']) || !DI::pConfig()->get(local_user(), 'system', 'no_auto_update')) { - $text = Contact::content([], true); + if (!empty($a->argv[1]) && (!empty($_GET['force']) || !DI::pConfig()->get(local_user(), 'system', 'no_auto_update'))) { + if (!empty($_GET['item'])) { + $item = Item::selectFirst(['parent'], ['id' => $_GET['item']]); + $parentid = $item['parent'] ?? 0; + } else { + $parentid = 0; + } + $text = Contact::getConversationsHMTL($a, $a->argv[1], true, $parentid); } else { $text = ''; } - System::htmlUpdateExit($text); } diff --git a/mod/videos.php b/mod/videos.php index 49c64ef973..1ba566eeaa 100644 --- a/mod/videos.php +++ b/mod/videos.php @@ -33,7 +33,7 @@ use Friendica\Model\Item; use Friendica\Model\Profile; use Friendica\Model\User; use Friendica\Module\BaseProfile; -use Friendica\Util\Security; +use Friendica\Security\Security; function videos_init(App $a) { @@ -67,7 +67,7 @@ function videos_init(App $a) '$photo' => $profile['photo'], '$addr' => $profile['addr'] ?? '', '$account_type' => $account_type, - '$about' => BBCode::convert($profile['about'] ?? ''), + '$about' => BBCode::convert($profile['about']), ]); // If not there, create 'aside' empty @@ -126,7 +126,7 @@ function videos_content(App $a) if (DI::config()->get('system', 'block_public') && !Session::isAuthenticated()) { - notice(DI::l10n()->t('Public access denied.') . EOL); + notice(DI::l10n()->t('Public access denied.')); return; } @@ -179,7 +179,7 @@ function videos_content(App $a) } if ($a->data['user']['hidewall'] && (local_user() != $owner_uid) && !$remote_contact) { - notice(DI::l10n()->t('Access to this item is restricted.') . EOL); + notice(DI::l10n()->t('Access to this item is restricted.')); return; } diff --git a/mod/wall_attach.php b/mod/wall_attach.php index c02a06c375..8cb19ab1a0 100644 --- a/mod/wall_attach.php +++ b/mod/wall_attach.php @@ -106,7 +106,7 @@ function wall_attach_post(App $a) { if ($r_json) { echo json_encode(['error' => $msg]); } else { - notice($msg . EOL); + notice($msg); } @unlink($src); exit(); diff --git a/mod/wall_upload.php b/mod/wall_upload.php index 093d5db773..c3ba323043 100644 --- a/mod/wall_upload.php +++ b/mod/wall_upload.php @@ -99,7 +99,7 @@ function wall_upload_post(App $a, $desktopmode = true) echo json_encode(['error' => DI::l10n()->t('Permission denied.')]); exit(); } - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); exit(); } @@ -159,7 +159,7 @@ function wall_upload_post(App $a, $desktopmode = true) echo json_encode(['error' => DI::l10n()->t('Invalid request.')]); exit(); } - notice(DI::l10n()->t('Invalid request.').EOL); + notice(DI::l10n()->t('Invalid request.')); exit(); } diff --git a/mod/wallmessage.php b/mod/wallmessage.php index e5b482a65e..cbf53b45d6 100644 --- a/mod/wallmessage.php +++ b/mod/wallmessage.php @@ -32,7 +32,7 @@ function wallmessage_post(App $a) { $replyto = Profile::getMyURL(); if (!$replyto) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } @@ -56,7 +56,7 @@ function wallmessage_post(App $a) { $user = $r[0]; if (! intval($user['unkmail'])) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } @@ -73,19 +73,17 @@ function wallmessage_post(App $a) { switch ($ret) { case -1: - notice(DI::l10n()->t('No recipient selected.') . EOL); + notice(DI::l10n()->t('No recipient selected.')); break; case -2: - notice(DI::l10n()->t('Unable to check your home location.') . EOL); + notice(DI::l10n()->t('Unable to check your home location.')); break; case -3: - notice(DI::l10n()->t('Message could not be sent.') . EOL); + notice(DI::l10n()->t('Message could not be sent.')); break; case -4: - notice(DI::l10n()->t('Message collection failure.') . EOL); + notice(DI::l10n()->t('Message collection failure.')); break; - default: - info(DI::l10n()->t('Message sent.') . EOL); } DI::baseUrl()->redirect('profile/'.$user['nickname']); @@ -95,14 +93,14 @@ function wallmessage_post(App $a) { function wallmessage_content(App $a) { if (!Profile::getMyURL()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } $recipient = (($a->argc > 1) ? $a->argv[1] : ''); if (!$recipient) { - notice(DI::l10n()->t('No recipient.') . EOL); + notice(DI::l10n()->t('No recipient.')); return; } @@ -111,7 +109,7 @@ function wallmessage_content(App $a) { ); if (! DBA::isResult($r)) { - notice(DI::l10n()->t('No recipient.') . EOL); + notice(DI::l10n()->t('No recipient.')); Logger::log('wallmessage: no recipient'); return; } @@ -119,7 +117,7 @@ function wallmessage_content(App $a) { $user = $r[0]; if (!intval($user['unkmail'])) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } diff --git a/mods/.drone.yml b/mods/.drone.yml index 21754ef06c..696bbfa803 100644 --- a/mods/.drone.yml +++ b/mods/.drone.yml @@ -36,7 +36,7 @@ volumes: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -79,7 +79,7 @@ volumes: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -122,7 +122,7 @@ volumes: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -169,7 +169,7 @@ volumes: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -211,7 +211,7 @@ volumes: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -253,7 +253,7 @@ volumes: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -282,7 +282,7 @@ services: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -306,7 +306,7 @@ services: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -330,7 +330,7 @@ services: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -360,7 +360,7 @@ services: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -384,7 +384,7 @@ services: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -408,7 +408,7 @@ services: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -439,7 +439,7 @@ services: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -463,7 +463,7 @@ services: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: @@ -487,7 +487,7 @@ services: trigger: branch: -# - master +# - stable - develop # - "*-rc" # event: diff --git a/mods/sample-Lighttpd.config b/mods/sample-Lighttpd.config index fb8ef0b2a4..c4ccc12665 100644 --- a/mods/sample-Lighttpd.config +++ b/mods/sample-Lighttpd.config @@ -105,6 +105,9 @@ $HTTP["scheme"] == "https" { "^\/([^\?]*)\?(.*)$" => "/index.php?pagename=$1&$2", "^\/(.*)$" => "/index.php?pagename=$1" ) + $HOST["url"] =~ "^/bin/" { + url.access.deny ( "" ) + } } else $HTTP["host"] !~ "(friendica.example.com|wordpress.example.com)" { server.document-root = "/var/www/wordpress" diff --git a/mods/sample-nginx.config b/mods/sample-nginx.config index 71d3785516..b90e1fe29f 100644 --- a/mods/sample-nginx.config +++ b/mods/sample-nginx.config @@ -141,4 +141,9 @@ server { location ~ /\. { deny all; } + + # deny access to the CLI scripts + location ^~ /bin { + deny all; + } } diff --git a/spec/dfrn2_contact_confirmation.svg b/spec/dfrn2_contact_confirmation.svg index fc5b331625..81268509d4 100644 --- a/spec/dfrn2_contact_confirmation.svg +++ b/spec/dfrn2_contact_confirmation.svg @@ -1,162 +1 @@ - - - - - - - - - -Friendica - Contact confirmation - - - - -bob@example.com - - - - -karen@karenhompage.com - - -notifications.php - - -notifications_content() ------------------------------------------ -- This is the page where Karen see Bobs friendship request -- the submit form redirects to Karens local dfrn_confirm page -($dfrn_id, $contact_id, $intro_id are submitted) - - -dfrn_confirm.php - - -dfrn_confirm_post() -SCENARIO 1 ( no $_POST['source_url'] available) --------------------------------------------------------------------------------- -- contact data come either form $handsfree (if autoconfirm) or -from $_POST -- get all data about Karen form the user table -[Note: Bob have been issued an ID (contact issue-id) when he first -requested the friendship. Locate Bobs contact record. At this -time, his record will have both pending and blocked set to 1. -There won't be any dfrn_id if this is a network follower, so use -the contact_id instead] -- search for Bob in the contact table by contact_id, dfrn_id and -issued-id not empty (for the uid -> Karens user id) -- if network = dfrn - -> create a new keypair (prvkey & pubkey) and update the -contact -[Note: Generate a key pair for all further communications with -this person. We have a keypair for every contact, and a site key -for unknown people. This provides a means to carry on -relationships with other people any single key is compromised. It -is a robust key. We're much more worried about key leakage -than anybody cracking it.] - -> update Bobs contact record (in the contact table) with the -generated prvkey - -> encrypting the dfrn_id with Karens prvkey (Bob can decrypt it -on the other and with Karens site-pubkey) and add it to the -transmit params. - -> encrypting Karens profile url with Bobs site-pubkey (Bob -can decrypt it with his own private key) and add it to the -transmit params. - -> add the above generated public key to params which -getting transmitted (if $aes_allow -> encrypt the the public key) - -> add duplex state and page-flags to the params - -> send params to Bobs dfrn_confirm page ($res = -Network::post($dfrn_confirm,$params); - - -dfrn_confirm_post() -SCENARIO 2 ( $_POST['source_url'] is available) ------------------------------------------------------------------------- -- get all data about Bob from the user table (prvkey and uid form -Bob ) -- decrypt the transmitted source_url (profile url) with Bobs -prvkey -- get data of Karen from contact table by her source_url (and by -her user id) -- decrypt the dfrn_id sent by Karen with Karens site-pubkey -(taken from contact table) -- if possible decrpyt the pubkey sent by Karen with the prvkey of -Bob (taken from user table) -> if this is not possible use the raw -pubkey -- search if the dfrn_id is already present in the contact table (if it -is prensent it is a duplicate) -- update dfrn-id and pubkey for Karens contact entry in the -contact table - - - -> set the relation for the contact and set pending = 0 and -blocked = 0 - - -- update the relationship of the contact Karen --> if duplex delete the issued-id --> set blocked = 0 and pending = 0 - - -send a notification - - -delete the intro of Bob - - -Note: this chart respects only dfrn -contacts and focuses on key exchange -(for other areas it might be very -incomplete) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Friendica - Contact confirmationbob@example.comkaren@karenhompage.comnotifications.phpnotifications_content()------------------------------------------ This is the page where Karen see Bobs friendship request- the submit form redirects to Karens local dfrn_confirm page($dfrn_id, $contact_id, $intro_id are submitted)dfrn_confirm.phpdfrn_confirm_post()SCENARIO 1 ( no $_POST['source_url'] available)--------------------------------------------------------------------------------- contact data come either form $handsfree (if autoconfirm) orfrom $_POST- get all data about Karen form the user table[Note: Bob have been issued an ID (contact issue-id) when he firstrequested the friendship. Locate Bobs contact record. At thistime, his record will have both pending and blocked set to 1.There won't be any dfrn_id if this is a network follower, so usethe contact_id instead]- search for Bob in the contact table by contact_id, dfrn_id andissued-id not empty (for the uid -> Karens user id)- if network = dfrn-> create a new keypair (prvkey & pubkey) and update thecontact[Note: Generate a key pair for all further communications withthis person. We have a keypair for every contact, and a site keyfor unknown people. This provides a means to carry onrelationships with other people any single key is compromised. Itis a robust key. We're much more worried about key leakagethan anybody cracking it.]-> update Bobs contact record (in the contact table) with thegenerated prvkey-> encrypting the dfrn_id with Karens prvkey (Bob can decrypt iton the other and with Karens site-pubkey) and add it to thetransmit params.-> encrypting Karens profile url with Bobs site-pubkey (Bobcan decrypt it with his own private key) and add it to thetransmit params.-> add the above generated public key to params whichgetting transmitted (if $aes_allow -> encrypt the the public key)-> add duplex state and page-flags to the params-> send params to Bobs dfrn_confirm page ($res =Network::post($dfrn_confirm,$params);dfrn_confirm_post()SCENARIO 2 ( $_POST['source_url'] is available)------------------------------------------------------------------------- get all data about Bob from the user table (prvkey and uid formBob )- decrypt the transmitted source_url (profile url) with Bobsprvkey- get data of Karen from contact table by her source_url (and byher user id)- decrypt the dfrn_id sent by Karen with Karens site-pubkey(taken from contact table)- if possible decrpyt the pubkey sent by Karen with the prvkey ofBob (taken from user table) -> if this is not possible use the rawpubkey- search if the dfrn_id is already present in the contact table (if itis prensent it is a duplicate)- update dfrn-id and pubkey for Karens contact entry in thecontact table-> set the relation for the contact and set pending = 0 andblocked = 0- update the relationship of the contact Karen-> if duplex delete the issued-id-> set blocked = 0 and pending = 0send a notificationdelete the intro of BobNote: this chart respects only dfrncontacts and focuses on key exchange(for other areas it might be veryincomplete) \ No newline at end of file diff --git a/spec/dfrn2_contact_request.svg b/spec/dfrn2_contact_request.svg index d32718271b..9b75acd5db 100644 --- a/spec/dfrn2_contact_request.svg +++ b/spec/dfrn2_contact_request.svg @@ -1,218 +1 @@ - - - - - - - - - -Friendica - Contact request - - - - -karenn@karenhompage.com - - - - -bob@example.com - - - dfrn_request.php -- -https://karenhompage/dfrn_request/karin - - -dfrn_request_post - SCENARIO 1 ----------------------------------------------- -- Cleanup old introductions that remain blocked + Cleanup -any old email intros - which will have a greater lifetime -- Probe::uri Bobs posted dfrn_url and get the network with -webfinger_dfrn -- try to select all contact data of Bob (contact table) by the -url ($_POST['dfrn_url] and profile uid ($a->profile['uid']) -where self = 0 to look if this contact is already there (if -issued-id or rel is already available return here because it -seems that we are already connected) -- create a issued-id with $issued_id = Strings::getRandomHex(); -- if we already found a contact record above update the -issued-id with the one we have created -- otherwise if Bob is not already in the contact table scrape -Bobs profile and create a new contact with this data (e.g. -the scraped issued-id / profiles pubkey becomes contacts -site-pubkey) in the contact table (blocked = 1, pending = 1) -- select this created contact from contact table and create -an intro in the intro table (blocked = 1) - - -$_POST['dfrn_url'] is transmited and is Bobs profile url - - -redirect to Bobs request page -goaway($parms['dfrn-request'] . "?dfrn_url=$dfrn_url" - . '&dfrn_version=' . -DFRN_PROTOCOL_VERSION - . '&confirm_key=' . $hash - . (($aes_allow) ? "&aes_allow=1" : "") - ); -http://example.com/dfrn_request/bob?dfrn_url=6874747 -03a2f2f6b6172656e686f6d65706167652e636f6d2f70726f66 -696c652f6b6172656e&aes_allow=1&confirm_key=”ABC123” - - -dfrn_request.php - - -http://example.com/dfrn_request/bob? -dfrn_url= -687474703a2f2f6b6172656e686f6d65706167652e -636f6d2f70726f66696c652f6b6172656e&aes_allow=1& -confirm_key=”ABC123” -dfrn_request_content() ------------------------------------------- -- copy the posted parameters (dfrn_url, key and so on) -to $_POST - dfrn_request_post() - SCENARIO 2 -($_POST['localconfirm'] == 1) ------------------------------------------------------------------------ -- if(local_user() && ($a->user['nickname'] == $a- ->argv[1]) && !empty($_POST['dfrn_url'])) --> -- $confirm_key comes from $_POST -- get data for contact Karen (contact table) by -$dfrn_url (contacts url and nurl) -> if contact Karen -does already have a dfrn-id Bob seems already -connected with Karen (abort here) -- if this contact (Karen) isn't available in the contact -tabel, scrape Karens profile page to pick up the dfrn -links, key, fn, and photo -- create a contact for Karen in the contact table with -the scraped data with blocked = 1 and pending = 1 -(Karens pubkey becomes the contact site-pubkey) -- Network::fetchUrl($dfrn_request . '?confirm_key=' . -$confirm_key); -- Network::fetchUrl(http://karenhomepage.com/dfrn_request? -confirm_key=”ABC123”) - - -dfrn_request.php - - -http://karenhomepage.com/dfrn_request?confirm_key=”ABC123” -dfrn_request_content() - -elseif (!empty($_GET['confirm_key'])) ----------------------------------------------------------------------------------------------- -- select the intro by confirm_key (intro table) -> get contact id -- use the intro contact id to get the contact in the contact table -- build a notification package ( notification(array.....) ) -- update intro in intro table (blocked = 0) - - -Bob stays on his Friendica server -- goaway($forwardurl); - - -Note: this chart respects only dfrn -contacts and focuses on key exchange -(for other areas it might be very -incomplete) - - -dfrn_request_content() ------------------------------------- -- the page for the on Katrins server where Bob do a connection -request -- the form transmit on submit Bobs profile url as dfrn_url - - - - - - - - - - -bob wants to make a request and is directed from karens profile page to karens dfrn-request page - - - - - - - - - - - - - - - - - - - - - - - - - - -redirict to bobs dfrn_request page - - - - - - - - - - - - - - - - - - - - -http://karenhomepage.com/dfrn_request?confirm_key=”ABC123” - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Bob fills request form and presses submit - +Friendica - Contact requestkarenn@karenhompage.combob@example.comdfrn_request.php-https://karenhompage/dfrn_request/karindfrn_request_post - SCENARIO 1----------------------------------------------- Cleanup old introductions that remain blocked + Cleanupany old email intros - which will have a greater lifetime- Probe::uri Bobs posted dfrn_url and get the network withwebfinger_dfrn- try to select all contact data of Bob (contact table) by theurl ($_POST['dfrn_url] and profile uid ($a->profile['uid'])where self = 0 to look if this contact is already there (ifissued-id or rel is already available return here because itseems that we are already connected)- create a issued-id with $issued_id = Strings::getRandomHex();- if we already found a contact record above update theissued-id with the one we have created- otherwise if Bob is not already in the contact table scrapeBobs profile and create a new contact with this data (e.g.the scraped issued-id / profiles pubkey becomes contactssite-pubkey) in the contact table (blocked = 1, pending = 1)- select this created contact from contact table and createan intro in the intro table (blocked = 1)$_POST['dfrn_url'] is transmited and is Bobs profile urlredirect to Bobs request pagegoaway($parms['dfrn-request'] . "?dfrn_url=$dfrn_url". '&dfrn_version=' .DFRN_PROTOCOL_VERSION. '&confirm_key=' . $hash. (($aes_allow) ? "&aes_allow=1" : ""));http://example.com/dfrn_request/bob?dfrn_url=687474703a2f2f6b6172656e686f6d65706167652e636f6d2f70726f66696c652f6b6172656e&aes_allow=1&confirm_key=”ABC123”dfrn_request.phphttp://example.com/dfrn_request/bob?dfrn_url=687474703a2f2f6b6172656e686f6d65706167652e636f6d2f70726f66696c652f6b6172656e&aes_allow=1&confirm_key=”ABC123”dfrn_request_content()------------------------------------------- copy the posted parameters (dfrn_url, key and so on)to $_POSTdfrn_request_post() - SCENARIO 2($_POST['localconfirm'] == 1)------------------------------------------------------------------------ if(local_user() && ($a->user['nickname'] == $a->argv[1]) && !empty($_POST['dfrn_url']))->- $confirm_key comes from $_POST- get data for contact Karen (contact table) by$dfrn_url (contacts url and nurl) -> if contact Karendoes already have a dfrn-id Bob seems alreadyconnected with Karen (abort here)- if this contact (Karen) isn't available in the contacttabel, scrape Karens profile page to pick up the dfrnlinks, key, fn, and photo- create a contact for Karen in the contact table withthe scraped data with blocked = 1 and pending = 1(Karens pubkey becomes the contact site-pubkey)- Network::fetchUrl($dfrn_request . '?confirm_key=' .$confirm_key);- Network::fetchUrl(http://karenhomepage.com/dfrn_request?confirm_key=”ABC123”)dfrn_request.phphttp://karenhomepage.com/dfrn_request?confirm_key=”ABC123”dfrn_request_content() -elseif (!empty($_GET['confirm_key']))----------------------------------------------------------------------------------------------- select the intro by confirm_key (intro table) -> get contact id- use the intro contact id to get the contact in the contact table- build a notification package ( notification(array.....) )- update intro in intro table (blocked = 0)Bob stays on his Friendica server- goaway($forwardurl);Note: this chart respects only dfrncontacts and focuses on key exchange(for other areas it might be veryincomplete)dfrn_request_content()------------------------------------- the page for the on Katrins server where Bob do a connectionrequest- the form transmit on submit Bobs profile url as dfrn_urlbob wants to make a request and is directed from karens profile page to karens dfrn-request pageredirict to bobs dfrn_request pagehttp://karenhomepage.com/dfrn_request?confirm_key=”ABC123”Bob fills request form and presses submit \ No newline at end of file diff --git a/src/App.php b/src/App.php index 9b6f6a5a29..4a9552aafb 100644 --- a/src/App.php +++ b/src/App.php @@ -24,7 +24,7 @@ namespace Friendica; use Exception; use Friendica\App\Arguments; use Friendica\App\BaseURL; -use Friendica\App\Authentication; +use Friendica\Security\Authentication; use Friendica\Core\Config\Cache; use Friendica\Core\Config\IConfig; use Friendica\Core\PConfig\IPConfig; @@ -77,7 +77,6 @@ class App public $sourcename = ''; public $videowidth = 425; public $videoheight = 350; - public $force_max_items = 0; public $theme_events_in_profile = true; public $queue; @@ -240,22 +239,6 @@ class App } } - /** - * Returns the current UserAgent as a String - * - * @return string the UserAgent as a String - * @throws HTTPException\InternalServerErrorException - */ - public function getUserAgent() - { - return - FRIENDICA_PLATFORM . " '" . - FRIENDICA_CODENAME . "' " . - FRIENDICA_VERSION . '-' . - DB_UPDATE_VERSION . '; ' . - $this->baseURL->get(); - } - /** * Returns the current theme name. May be overriden by the mobile theme name. * @@ -432,8 +415,11 @@ class App * @throws HTTPException\InternalServerErrorException * @throws \ImagickException */ - public function runFrontend(App\Module $module, App\Router $router, IPConfig $pconfig, Authentication $auth, App\Page $page) + public function runFrontend(App\Module $module, App\Router $router, IPConfig $pconfig, Authentication $auth, App\Page $page, float $start_time) { + $this->profiler->set($start_time, 'start'); + $this->profiler->set(microtime(true), 'classinit'); + $moduleName = $module->getName(); try { @@ -459,12 +445,7 @@ class App Core\Hook::callAll('init_1'); } - // Exclude the backend processes from the session management - if ($this->mode->isBackend()) { - Core\Worker::executeIfIdle(); - } - - if ($this->mode->isNormal()) { + if ($this->mode->isNormal() && !$this->mode->isBackend()) { $requester = HTTPSignature::getSigner('', $_SERVER); if (!empty($requester)) { Profile::addVisitorCookieForHandle($requester); @@ -472,7 +453,7 @@ class App } // ZRL - if (!empty($_GET['zrl']) && $this->mode->isNormal()) { + if (!empty($_GET['zrl']) && $this->mode->isNormal() && !$this->mode->isBackend()) { if (!local_user()) { // Only continue when the given profile link seems valid // Valid profile links contain a path with "/profile/" and no query parameters @@ -568,12 +549,12 @@ class App $module = $module->determineClass($this->args, $router, $this->config); // Let the module run it's internal process (init, get, post, ...) - $module->run($this->l10n, $this->baseURL, $this->logger, $_SERVER, $_POST); + $module->run($this->l10n, $this->baseURL, $this->logger, $this->profiler, $_SERVER, $_POST); } catch (HTTPException $e) { ModuleHTTPException::rawContent($e); } - $page->run($this, $this->baseURL, $this->mode, $module, $this->l10n, $this->config, $pconfig); + $page->run($this, $this->baseURL, $this->mode, $module, $this->l10n, $this->profiler, $this->config, $pconfig); } /** diff --git a/src/App/Arguments.php b/src/App/Arguments.php index e2f60b1956..de3fecf9ed 100644 --- a/src/App/Arguments.php +++ b/src/App/Arguments.php @@ -47,7 +47,7 @@ class Arguments */ private $argc; - public function __construct(string $queryString = '', string $command = '', array $argv = [Module::DEFAULT], int $argc = 1) + public function __construct(string $queryString = '', string $command = '', array $argv = [], int $argc = 0) { $this->queryString = $queryString; $this->command = $command; @@ -56,7 +56,7 @@ class Arguments } /** - * @return string The whole query string of this call + * @return string The whole query string of this call with url-encoded query parameters */ public function getQueryString() { @@ -121,50 +121,27 @@ class Arguments */ public function determine(array $server, array $get) { - $queryString = ''; + // removing leading / - maybe a nginx problem + $server['QUERY_STRING'] = ltrim($server['QUERY_STRING'] ?? '', '/'); - if (!empty($server['QUERY_STRING']) && strpos($server['QUERY_STRING'], 'pagename=') === 0) { - $queryString = urldecode(substr($server['QUERY_STRING'], 9)); - } elseif (!empty($server['QUERY_STRING']) && strpos($server['QUERY_STRING'], 'q=') === 0) { - $queryString = urldecode(substr($server['QUERY_STRING'], 2)); - } - - // eventually strip ZRL - $queryString = $this->stripZRLs($queryString); - - // eventually strip OWT - $queryString = $this->stripQueryParam($queryString, 'owt'); - - // removing trailing / - maybe a nginx problem - $queryString = ltrim($queryString, '/'); + $queryParameters = []; + parse_str($server['QUERY_STRING'], $queryParameters); if (!empty($get['pagename'])) { $command = trim($get['pagename'], '/\\'); + } elseif (!empty($queryParameters['pagename'])) { + $command = trim($queryParameters['pagename'], '/\\'); } elseif (!empty($get['q'])) { + // Legacy page name parameter, now conflicts with the search query parameter $command = trim($get['q'], '/\\'); } else { - $command = Module::DEFAULT; + $command = ''; } - - // fix query_string - if (!empty($command)) { - $queryString = str_replace( - $command . '&', - $command . '?', - $queryString - ); - } - - // unix style "homedir" - if (substr($command, 0, 1) === '~') { - $command = 'profile/' . substr($command, 1); - } - - // Diaspora style profile url - if (substr($command, 0, 2) === 'u/') { - $command = 'profile/' . substr($command, 2); - } + // Remove generated and one-time use parameters + unset($queryParameters['pagename']); + unset($queryParameters['zrl']); + unset($queryParameters['owt']); /* * Break the URL path into C style argc/argv style arguments for our @@ -173,41 +150,17 @@ class Arguments * [0] => 'module' * [1] => 'arg1' * [2] => 'arg2' - * - * - * There will always be one argument. If provided a naked domain - * URL, $this->argv[0] is set to "home". */ + if ($command) { + $argv = explode('/', $command); + } else { + $argv = []; + } - $argv = explode('/', $command); $argc = count($argv); + $queryString = $command . ($queryParameters ? '?' . http_build_query($queryParameters) : ''); return new Arguments($queryString, $command, $argv, $argc); } - - /** - * Strip zrl parameter from a string. - * - * @param string $queryString The input string. - * - * @return string The zrl. - */ - public function stripZRLs(string $queryString) - { - return preg_replace('/[?&]zrl=(.*?)(&|$)/ism', '$2', $queryString); - } - - /** - * Strip query parameter from a string. - * - * @param string $queryString The input string. - * @param string $param - * - * @return string The query parameter. - */ - public function stripQueryParam(string $queryString, string $param) - { - return preg_replace('/[?&]' . $param . '=(.*?)(&|$)/ism', '$2', $queryString); - } -} \ No newline at end of file +} diff --git a/src/App/Mode.php b/src/App/Mode.php index cc18373e9e..8aa812c93d 100644 --- a/src/App/Mode.php +++ b/src/App/Mode.php @@ -38,12 +38,26 @@ class Mode const DBCONFIGAVAILABLE = 4; const MAINTENANCEDISABLED = 8; + const UNDEFINED = 0; + const INDEX = 1; + const DAEMON = 2; + const WORKER = 3; + + const BACKEND_CONTENT_TYPES = ['application/jrd+json', 'text/xml', + 'application/rss+xml', 'application/atom+xml', 'application/activity+json']; + /*** * @var int The mode of this Application * */ private $mode; + /*** + * @var int Who executes this Application + * + */ + private $executor = self::UNDEFINED; + /** * @var bool True, if the call is a backend call */ @@ -134,8 +148,13 @@ class Mode */ public function determineRunMode(bool $isBackend, Module $module, array $server, MobileDetect $mobileDetect) { - $isBackend = $isBackend || - $module->isBackend(); + foreach (self::BACKEND_CONTENT_TYPES as $type) { + if (strpos(strtolower($server['HTTP_ACCEPT'] ?? ''), $type) !== false) { + $isBackend = true; + } + } + + $isBackend = $isBackend || $module->isBackend(); $isMobile = $mobileDetect->isMobile(); $isTablet = $mobileDetect->isTablet(); $isAjax = strtolower($server['HTTP_X_REQUESTED_WITH'] ?? '') == 'xmlhttprequest'; @@ -155,6 +174,31 @@ class Mode return ($this->mode & $mode) > 0; } + /** + * Set the execution mode + * + * @param integer $executor Execution Mode + * @return void + */ + public function setExecutor(int $executor) + { + $this->executor = $executor; + + // Daemon and worker are always backend + if (in_array($executor, [self::DAEMON, self::WORKER])) { + $this->isBackend = true; + } + } + + /*isBackend = true;* + * get the execution mode + * + * @return int Execution Mode + */ + public function getExecutor() + { + return $this->executor; + } /** * Install mode is when the local config file is missing or the DB schema hasn't been installed yet. diff --git a/src/App/Module.php b/src/App/Module.php index 4b9eb68bdd..fff7641b76 100644 --- a/src/App/Module.php +++ b/src/App/Module.php @@ -30,6 +30,7 @@ use Friendica\Module\HTTPException\MethodNotAllowed; use Friendica\Module\HTTPException\PageNotFound; use Friendica\Network\HTTPException\MethodNotAllowedException; use Friendica\Network\HTTPException\NotFoundException; +use Friendica\Util\Profiler; use Psr\Log\LoggerInterface; /** @@ -234,10 +235,10 @@ class Module * * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - public function run(Core\L10n $l10n, App\BaseURL $baseUrl, LoggerInterface $logger, array $server, array $post) + public function run(Core\L10n $l10n, App\BaseURL $baseUrl, LoggerInterface $logger, Profiler $profiler, array $server, array $post) { if ($this->printNotAllowedAddon) { - info($l10n->t("You must be logged in to use addons. ")); + notice($l10n->t("You must be logged in to use addons. ")); } /* The URL provided does not resolve to a valid module. @@ -266,10 +267,15 @@ class Module $placeholder = ''; + $profiler->set(microtime(true), 'ready'); + $timestamp = microtime(true); + Core\Hook::callAll($this->module . '_mod_init', $placeholder); call_user_func([$this->module_class, 'init'], $this->module_parameters); + $profiler->set(microtime(true) - $timestamp, 'init'); + if ($server['REQUEST_METHOD'] === 'POST') { Core\Hook::callAll($this->module . '_mod_post', $post); call_user_func([$this->module_class, 'post'], $this->module_parameters); diff --git a/src/App/Page.php b/src/App/Page.php index 50afac1b4f..af1f1810b7 100644 --- a/src/App/Page.php +++ b/src/App/Page.php @@ -36,6 +36,7 @@ use Friendica\Module\Special\HTTPException as ModuleHTTPException; use Friendica\Network\HTTPException; use Friendica\Util\Network; use Friendica\Util\Strings; +use Friendica\Util\Profiler; /** * Contains the page specific environment variables for the current Page @@ -165,11 +166,10 @@ class Page implements ArrayAccess * The path can be absolute or relative to the Friendica installation base folder. * * @param string $path - * + * @param string $media * @see Page::initHead() - * */ - public function registerStylesheet($path) + public function registerStylesheet($path, string $media = 'screen') { $path = Network::appendQueryParam($path, ['v' => FRIENDICA_VERSION]); @@ -177,7 +177,7 @@ class Page implements ArrayAccess $path = mb_substr($path, mb_strlen($this->basePath . DIRECTORY_SEPARATOR)); } - $this->stylesheets[] = trim($path, '/'); + $this->stylesheets[trim($path, '/')] = $media; } /** @@ -234,7 +234,7 @@ class Page implements ArrayAccess $touch_icon = $config->get('system', 'touch_icon'); if ($touch_icon == '') { - $touch_icon = 'images/friendica-128.png'; + $touch_icon = 'images/friendica-192.png'; } Hook::callAll('head', $this->page['htmlhead']); @@ -252,7 +252,7 @@ class Page implements ArrayAccess '$shortcut_icon' => $shortcut_icon, '$touch_icon' => $touch_icon, '$block_public' => intval($config->get('system', 'block_public')), - '$stylesheets' => array_unique($this->stylesheets), + '$stylesheets' => $this->stylesheets, ]) . $this->page['htmlhead']; } @@ -276,7 +276,7 @@ class Page implements ArrayAccess // If you're just visiting, let javascript take you home if (!empty($_SESSION['visitor_home'])) { $homebase = $_SESSION['visitor_home']; - } elseif (local_user()) { + } elseif (!empty($app->user['nickname'])) { $homebase = 'profile/' . $app->user['nickname']; } @@ -376,7 +376,7 @@ class Page implements ArrayAccess * * @throws HTTPException\InternalServerErrorException */ - public function run(App $app, BaseURL $baseURL, Mode $mode, Module $module, L10n $l10n, IConfig $config, IPConfig $pconfig) + public function run(App $app, BaseURL $baseURL, Mode $mode, Module $module, L10n $l10n, Profiler $profiler, IConfig $config, IPConfig $pconfig) { $moduleName = $module->getName(); @@ -385,7 +385,9 @@ class Page implements ArrayAccess * * Sets the $Page->page['content'] variable */ + $timestamp = microtime(true); $this->initContent($module, $mode); + $profiler->set(microtime(true) - $timestamp, 'content'); // Load current theme info after module has been initialized as theme could have been set in module $currentTheme = $app->getCurrentTheme(); diff --git a/src/App/Router.php b/src/App/Router.php index 8094e3b46d..78d8ab629f 100644 --- a/src/App/Router.php +++ b/src/App/Router.php @@ -26,6 +26,8 @@ use FastRoute\DataGenerator\GroupCountBased; use FastRoute\Dispatcher; use FastRoute\RouteCollector; use FastRoute\RouteParser\Std; +use Friendica\Core\Cache\Duration; +use Friendica\Core\Cache\ICache; use Friendica\Core\Hook; use Friendica\Core\L10n; use Friendica\Network\HTTPException; @@ -42,12 +44,18 @@ use Friendica\Network\HTTPException; */ class Router { - const POST = 'POST'; - const GET = 'GET'; + const DELETE = 'DELETE'; + const GET = 'GET'; + const PATCH = 'PATCH'; + const POST = 'POST'; + const PUT = 'PUT'; const ALLOWED_METHODS = [ - self::POST, + self::DELETE, self::GET, + self::PATCH, + self::POST, + self::PUT, ]; /** @var RouteCollector */ @@ -66,14 +74,24 @@ class Router /** @var L10n */ private $l10n; + /** @var ICache */ + private $cache; + + /** @var string */ + private $baseRoutesFilepath; + /** - * @param array $server The $_SERVER variable - * @param L10n $l10n - * @param RouteCollector|null $routeCollector Optional the loaded Route collector + * @param array $server The $_SERVER variable + * @param string $baseRoutesFilepath The path to a base routes file to leverage cache, can be empty + * @param L10n $l10n + * @param ICache $cache + * @param RouteCollector|null $routeCollector */ - public function __construct(array $server, L10n $l10n, RouteCollector $routeCollector = null) + public function __construct(array $server, string $baseRoutesFilepath, L10n $l10n, ICache $cache, RouteCollector $routeCollector = null) { + $this->baseRoutesFilepath = $baseRoutesFilepath; $this->l10n = $l10n; + $this->cache = $cache; $httpMethod = $server['REQUEST_METHOD'] ?? self::GET; $this->httpMethod = in_array($httpMethod, self::ALLOWED_METHODS) ? $httpMethod : self::GET; @@ -81,9 +99,16 @@ class Router $this->routeCollector = isset($routeCollector) ? $routeCollector : new RouteCollector(new Std(), new GroupCountBased()); + + if ($this->baseRoutesFilepath && !file_exists($this->baseRoutesFilepath)) { + throw new HTTPException\InternalServerErrorException('Routes file path does\'n exist.'); + } } /** + * This will be called either automatically if a base routes file path was submitted, + * or can be called manually with a custom route array. + * * @param array $routes The routes to add to the Router * * @return self The router instance with the loaded routes @@ -100,6 +125,9 @@ class Router $this->routeCollector = $routeCollector; + // Add routes from addons + Hook::callAll('route_collection', $this->routeCollector); + return $this; } @@ -191,12 +219,9 @@ class Router */ public function getModuleClass($cmd) { - // Add routes from addons - Hook::callAll('route_collection', $this->routeCollector); - $cmd = '/' . ltrim($cmd, '/'); - $dispatcher = new Dispatcher\GroupCountBased($this->routeCollector->getData()); + $dispatcher = new Dispatcher\GroupCountBased($this->getCachedDispatchData()); $moduleClass = null; $this->parameters = []; @@ -223,4 +248,64 @@ class Router { return $this->parameters; } + + /** + * If a base routes file path has been provided, we can load routes from it if the cache misses. + * + * @return array + * @throws HTTPException\InternalServerErrorException + */ + private function getDispatchData() + { + $dispatchData = []; + + if ($this->baseRoutesFilepath) { + $dispatchData = require $this->baseRoutesFilepath; + if (!is_array($dispatchData)) { + throw new HTTPException\InternalServerErrorException('Invalid base routes file'); + } + } + + $this->loadRoutes($dispatchData); + + return $this->routeCollector->getData(); + } + + /** + * We cache the dispatch data for speed, as computing the current routes (version 2020.09) + * takes about 850ms for each requests. + * + * The cached "routerDispatchData" lasts for a day, and must be cleared manually when there + * is any changes in the enabled addons list. + * + * Additionally, we check for the base routes file last modification time to automatically + * trigger re-computing the dispatch data. + * + * @return array|mixed + * @throws HTTPException\InternalServerErrorException + */ + private function getCachedDispatchData() + { + $routerDispatchData = $this->cache->get('routerDispatchData'); + $lastRoutesFileModifiedTime = $this->cache->get('lastRoutesFileModifiedTime'); + $forceRecompute = false; + + if ($this->baseRoutesFilepath) { + $routesFileModifiedTime = filemtime($this->baseRoutesFilepath); + $forceRecompute = $lastRoutesFileModifiedTime != $routesFileModifiedTime; + } + + if (!$forceRecompute && $routerDispatchData) { + return $routerDispatchData; + } + + $routerDispatchData = $this->getDispatchData(); + + $this->cache->set('routerDispatchData', $routerDispatchData, Duration::DAY); + if (!empty($routesFileModifiedTime)) { + $this->cache->set('lastRoutesFileMtime', $routesFileModifiedTime, Duration::MONTH); + } + + return $routerDispatchData; + } } diff --git a/src/BaseModel.php b/src/BaseModel.php index 8f9370bb09..41320d8bd5 100644 --- a/src/BaseModel.php +++ b/src/BaseModel.php @@ -141,7 +141,7 @@ abstract class BaseModel extends BaseEntity protected function checkValid() { - if (empty($this->data['id'])) { + if (!isset($this->data['id']) || is_null($this->data['id'])) { throw new HTTPException\InternalServerErrorException(static::class . ' record uninitialized'); } } diff --git a/src/BaseModule.php b/src/BaseModule.php index 1dbf3f38d5..c1f35533be 100644 --- a/src/BaseModule.php +++ b/src/BaseModule.php @@ -96,11 +96,11 @@ abstract class BaseModule * Functions used to protect against Cross-Site Request Forgery * The security token has to base on at least one value that an attacker can't know - here it's the session ID and the private key. * In this implementation, a security token is reusable (if the user submits a form, goes back and resubmits the form, maybe with small changes; - * or if the security token is used for ajax-calls that happen several times), but only valid for a certain amout of time (3hours). - * The "typename" seperates the security tokens of different types of forms. This could be relevant in the following case: - * A security token is used to protekt a link from CSRF (e.g. the "delete this profile"-link). + * or if the security token is used for ajax-calls that happen several times), but only valid for a certain amount of time (3hours). + * The "typename" separates the security tokens of different types of forms. This could be relevant in the following case: + * A security token is used to protect a link from CSRF (e.g. the "delete this profile"-link). * If the new page contains by any chance external elements, then the used security token is exposed by the referrer. - * Actually, important actions should not be triggered by Links / GET-Requests at all, but somethimes they still are, + * Actually, important actions should not be triggered by Links / GET-Requests at all, but sometimes they still are, * so this mechanism brings in some damage control (the attacker would be able to forge a request to a form of this type, but not to forms of other types). */ public static function getFormSecurityToken($typename = '') @@ -108,7 +108,7 @@ abstract class BaseModule $a = DI::app(); $timestamp = time(); - $sec_hash = hash('whirlpool', $a->user['guid'] . $a->user['prvkey'] . session_id() . $timestamp . $typename); + $sec_hash = hash('whirlpool', ($a->user['guid'] ?? '') . ($a->user['prvkey'] ?? '') . session_id() . $timestamp . $typename); return $timestamp . '.' . $sec_hash; } @@ -140,7 +140,7 @@ abstract class BaseModule return false; } - $sec_hash = hash('whirlpool', $a->user['guid'] . $a->user['prvkey'] . session_id() . $x[0] . $typename); + $sec_hash = hash('whirlpool', ($a->user['guid'] ?? '') . ($a->user['prvkey'] ?? '') . session_id() . $x[0] . $typename); return ($sec_hash == $x[1]); } @@ -171,4 +171,40 @@ abstract class BaseModule throw new \Friendica\Network\HTTPException\ForbiddenException(); } } + + protected static function getContactFilterTabs(string $baseUrl, string $current, bool $displayCommonTab) + { + $tabs = [ + [ + 'label' => DI::l10n()->t('All contacts'), + 'url' => $baseUrl . '/contacts', + 'sel' => !$current || $current == 'all' ? 'active' : '', + ], + [ + 'label' => DI::l10n()->t('Followers'), + 'url' => $baseUrl . '/contacts/followers', + 'sel' => $current == 'followers' ? 'active' : '', + ], + [ + 'label' => DI::l10n()->t('Following'), + 'url' => $baseUrl . '/contacts/following', + 'sel' => $current == 'following' ? 'active' : '', + ], + [ + 'label' => DI::l10n()->t('Mutual friends'), + 'url' => $baseUrl . '/contacts/mutuals', + 'sel' => $current == 'mutuals' ? 'active' : '', + ], + ]; + + if ($displayCommonTab) { + $tabs[] = [ + 'label' => DI::l10n()->t('Common'), + 'url' => $baseUrl . '/contacts/common', + 'sel' => $current == 'common' ? 'active' : '', + ]; + } + + return $tabs; + } } diff --git a/src/BaseRepository.php b/src/BaseRepository.php index 64a0d1c510..3e67cd5b20 100644 --- a/src/BaseRepository.php +++ b/src/BaseRepository.php @@ -97,37 +97,52 @@ abstract class BaseRepository extends BaseFactory * Populates the collection according to the condition. Retrieves a limited subset of models depending on the boundaries * and the limit. The total count of rows matching the condition is stored in the collection. * + * max_id and min_id are susceptible to the query order: + * - min_id alone only reliably works with ASC order + * - max_id alone only reliably works with DESC order + * If the wrong order is detected in either case, we inverse the query order and we reverse the model array after the query + * * Chainable. * * @param array $condition * @param array $params - * @param int? $max_id - * @param int? $since_id + * @param int? $min_id Retrieve models with an id no fewer than this, as close to it as possible + * @param int? $max_id Retrieve models with an id no greater than this, as close to it as possible * @param int $limit * @return BaseCollection * @throws \Exception */ - public function selectByBoundaries(array $condition = [], array $params = [], int $max_id = null, int $since_id = null, int $limit = self::LIMIT) + public function selectByBoundaries(array $condition = [], array $params = [], int $min_id = null, int $max_id = null, int $limit = self::LIMIT) { - $condition = DBA::collapseCondition($condition); + $totalCount = DBA::count(static::$table_name, $condition); $boundCondition = $condition; - if (isset($max_id)) { - $boundCondition[0] .= " AND `id` < ?"; - $boundCondition[] = $max_id; + $reverseModels = false; + + if (isset($min_id)) { + $boundCondition = DBA::mergeConditions($boundCondition, ['`id` > ?', $min_id]); + if (!isset($max_id) && isset($params['order']['id']) && ($params['order']['id'] === true || $params['order']['id'] === 'DESC')) { + $reverseModels = true; + $params['order']['id'] = 'ASC'; + } } - if (isset($since_id)) { - $boundCondition[0] .= " AND `id` > ?"; - $boundCondition[] = $since_id; + if (isset($max_id)) { + $boundCondition = DBA::mergeConditions($boundCondition, ['`id` < ?', $max_id]); + if (!isset($min_id) && (!isset($params['order']['id']) || $params['order']['id'] === false || $params['order']['id'] === 'ASC')) { + $reverseModels = true; + $params['order']['id'] = 'DESC'; + } } $params['limit'] = $limit; $models = $this->selectModels($boundCondition, $params); - $totalCount = DBA::count(static::$table_name, $condition); + if ($reverseModels) { + $models = array_reverse($models); + } return new static::$collection_class($models, $totalCount); } @@ -221,6 +236,8 @@ abstract class BaseRepository extends BaseFactory } } + $this->dba->close($result); + return $models; } diff --git a/src/Console/DatabaseStructure.php b/src/Console/DatabaseStructure.php index 6b1fa8d4d6..343c90023d 100644 --- a/src/Console/DatabaseStructure.php +++ b/src/Console/DatabaseStructure.php @@ -48,19 +48,25 @@ class DatabaseStructure extends \Asika\SimpleConsole\Console $help = << [-h|--help|-?] |-f|--force] [-v] + bin/console dbstructure [options] Commands - dryrun Show database update schema queries without running them - update Update database schema - dumpsql Dump database schema - toinnodb Convert all tables from MyISAM or InnoDB in the Antelope file format to InnoDB in the Barracuda file format + drop Show tables that aren't in use by Friendica anymore and can be dropped + -e|--execute Execute the dropping -Options + update Update database schema + -f|--force Force the update command (Even if the database structure matches) + -o|--override Override running or stalling updates + + dryrun Show database update schema queries without running them + dumpsql Dump database schema + toinnodb Convert all tables from MyISAM or InnoDB in the Antelope file format to InnoDB in the Barracuda file format + initial Set needed initial values in the tables + version Set the database to a given number + +General Options -h|--help|-? Show help information -v Show more debug information. - -f|--force Force the update command (Even if the database structure matches) - -o|--override Override running or stalling updates HELP; return $help; } @@ -86,8 +92,10 @@ HELP; return 0; } - if (count($this->args) > 1) { + if ((count($this->args) > 1) && ($this->getArgument(0) != 'version')) { throw new \Asika\SimpleConsole\CommandArgsException('Too many arguments'); + } elseif ((count($this->args) != 2) && ($this->getArgument(0) == 'version')) { + throw new \Asika\SimpleConsole\CommandArgsException('This command needs two arguments'); } if (!$this->dba->isConnected()) { @@ -105,6 +113,12 @@ HELP; $override = $this->getOption(['o', 'override'], false); $output = Update::run($basePath, $force, $override,true, false); break; + case "drop": + $execute = $this->getOption(['e', 'execute'], false); + ob_start(); + DBStructure::dropTables($execute); + $output = ob_get_clean(); + break; case "dumpsql": ob_start(); DBStructure::printStructure($basePath); @@ -115,11 +129,21 @@ HELP; DBStructure::convertToInnoDB(); $output = ob_get_clean(); break; + case "version": + ob_start(); + DBStructure::setDatabaseVersion($this->getArgument(1)); + $output = ob_get_clean(); + break; + case "initial": + ob_start(); + DBStructure::checkInitialValues(true); + $output = ob_get_clean(); + break; default: $output = 'Unknown command: ' . $this->getArgument(0); } - $this->out($output); + $this->out(trim($output)); return 0; } diff --git a/src/Console/FixAPDeliveryWorkerTaskParameters.php b/src/Console/FixAPDeliveryWorkerTaskParameters.php new file mode 100644 index 0000000000..9023d84ac2 --- /dev/null +++ b/src/Console/FixAPDeliveryWorkerTaskParameters.php @@ -0,0 +1,163 @@ +. + * + */ + +namespace Friendica\Console; + +use Friendica\App; +use Friendica\Database\Database; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Contact; +use Friendica\Util\Strings; +use RuntimeException; + +/** + * License: AGPLv3 or later, same as Friendica + */ +class FixAPDeliveryWorkerTaskParameters extends \Asika\SimpleConsole\Console +{ + protected $helpOptions = ['h', 'help', '?']; + + /** + * @var App\Mode + */ + private $appMode; + /** + * @var Database + */ + private $dba; + /** + * @var int + */ + private $examined; + /** + * @var int + */ + private $processed; + /** + * @var int + */ + private $errored; + + protected function getHelp() + { + $help = <<appMode = $appMode; + $this->dba = $dba; + $this->l10n = $l10n; + } + + protected function doExecute() + { + if ($this->getOption('v')) { + $this->out('Class: ' . __CLASS__); + $this->out('Arguments: ' . var_export($this->args, true)); + $this->out('Options: ' . var_export($this->options, true)); + } + + if (count($this->args) > 0) { + throw new \Asika\SimpleConsole\CommandArgsException('Too many arguments'); + } + + if ($this->appMode->isInstall()) { + throw new RuntimeException('Friendica isn\'t properly installed yet.'); + } + + $this->examined = 0; + $this->processed = 0; + $this->errored = 0; + + do { + $result = $this->dba->p('SELECT `id`, `parameter` FROM `workerqueue` WHERE `command` = "APDelivery" AND `parameter` LIKE "[\"%\",\"\",%" LIMIT ' . $this->examined . ', 100'); + while ($row = $this->dba->fetch($result)) { + $this->examined++; + $this->processRow($row); + } + } while ($this->dba->isResult($result)); + + if ($this->getOption('v')) { + $this->out('Examined: ' . $this->examined); + $this->out('Processed: ' . $this->processed); + $this->out('Errored: ' . $this->errored); + } + + return 0; + } + + private function processRow(array $workerqueueItem) + { + $parameters = json_decode($workerqueueItem['parameter'], true); + + if (!$parameters) { + $this->errored++; + if ($this->getOption('v')) { + $this->out('Unabled to parse parameter JSON of the row with id ' . $workerqueueItem['id']); + $this->out('JSON: ' . var_export($workerqueueItem['parameter'], true)); + } + } + + if ($parameters[1] !== '' && !is_array($parameters[2])) { + // Nothing to do, we save a write + return; + } + + if ($parameters[1] === '') { + $parameters[1] = 0; + } + + if (is_array($parameters[2])) { + $parameters[4] = $parameters[2]; + $contact = Contact::getById(current($parameters[2]), ['url']); + $parameters[2] = $contact['url']; + } + + $fields = ['parameter' => json_encode($parameters)]; + if ($this->dba->update('workerqueue', $fields, ['id' => $workerqueueItem['id']])) { + $this->processed++; + } else { + $this->errored++; + if ($this->getOption('v')) { + $this->out('Unabled to update the row with id ' . $workerqueueItem['id']); + $this->out('Fields: ' . var_export($fields, true)); + } + } + } +} diff --git a/src/Console/GlobalCommunitySilence.php b/src/Console/GlobalCommunitySilence.php index bb381d99ae..26f5f83502 100644 --- a/src/Console/GlobalCommunitySilence.php +++ b/src/Console/GlobalCommunitySilence.php @@ -50,7 +50,7 @@ class GlobalCommunitySilence extends \Asika\SimpleConsole\Console protected function getHelp() { $help = << [-h|--help|-?] [-v] diff --git a/src/Console/Relay.php b/src/Console/Relay.php new file mode 100644 index 0000000000..f417b51924 --- /dev/null +++ b/src/Console/Relay.php @@ -0,0 +1,139 @@ +. + * + */ + +namespace Friendica\Console; + +use Asika\SimpleConsole\CommandArgsException; +use Friendica\Model\APContact; +use Friendica\Model\Contact; +use Friendica\Protocol\ActivityPub\Transmitter; + +/** + * tool to control the list of ActivityPub relay servers from the CLI + * + * With this script you can access the relay servers of your node from + * the CLI. + */ +class Relay extends \Asika\SimpleConsole\Console +{ + protected $helpOptions = ['h', 'help', '?']; + + /** + * @var $dba Friendica\Database\Database + */ + private $dba; + + + protected function getHelp() + { + $help = << [-h|--help|-?] [-v] + bin/console relay remove [-f|--force] [-h|--help|-?] [-v] + +Description + bin/console relay list + Lists all active relay servers + + bin/console relay add + Add a relay actor in the format https://relayserver.tld/actor + + bin/console relay remove + Remove a relay actor in the format https://relayserver.tld/actor + +Options + -f|--force Change the relay status in the system even if the unsubscribe message failed + -h|--help|-? Show help information + -v Show more debug information. +HELP; + return $help; + } + + public function __construct(\Friendica\Database\Database $dba, array $argv = null) + { + parent::__construct($argv); + + $this->dba = $dba; + } + + protected function doExecute() + { + if ($this->getOption('v')) { + $this->out('Executable: ' . $this->executable); + $this->out('Class: ' . __CLASS__); + $this->out('Arguments: ' . var_export($this->args, true)); + $this->out('Options: ' . var_export($this->options, true)); + } + + if (count($this->args) > 2) { + throw new CommandArgsException('Too many arguments'); + } + + if ((count($this->args) == 1) && ($this->getArgument(0) == 'list')) { + $contacts = $this->dba->select('apcontact', ['url'], + ["`type` = ? AND `url` IN (SELECT `url` FROM `contact` WHERE `uid` = ? AND `rel` = ?)", + 'Application', 0, Contact::FRIEND]); + while ($contact = $this->dba->fetch($contacts)) { + $this->out($contact['url']); + } + $this->dba->close($contacts); + } elseif (count($this->args) == 0) { + throw new CommandArgsException('too few arguments'); + } elseif (count($this->args) == 1) { + throw new CommandArgsException($this->getArgument(0) . ' is no valid command'); + } + + if (count($this->args) == 2) { + $mode = $this->getArgument(0); + $actor = $this->getArgument(1); + + $apcontact = APContact::getByURL($actor); + if (empty($apcontact) || ($apcontact['type'] != 'Application')) { + $this->out($actor . ' is no relay actor'); + return 1; + } + + if ($mode == 'add') { + if (Transmitter::sendRelayFollow($actor)) { + $this->out('Successfully added ' . $actor); + } else { + $this->out($actor . " couldn't be added"); + } + } elseif ($mode == 'remove') { + $force = $this->getOption(['f', 'force'], false); + + if (Transmitter::sendRelayUndoFollow($actor, $force)) { + $this->out('Successfully removed ' . $actor); + } elseif (!$force) { + $this->out($actor . " couldn't be removed"); + } else { + $this->out($actor . " is forcefully removed"); + } + } else { + throw new CommandArgsException($mode . ' is no valid command'); + } + } + + return 0; + } +} diff --git a/src/Console/ServerBlock.php b/src/Console/ServerBlock.php index ada4f22132..4d8c930c58 100644 --- a/src/Console/ServerBlock.php +++ b/src/Console/ServerBlock.php @@ -48,14 +48,18 @@ class ServerBlock extends Console $help = << [-h|--help|-?] [-v] - bin/console serverblock remove [-h|--help|-?] [-v] + bin/console serverblock [-h|--help|-?] [-v] + bin/console serverblock add [-h|--help|-?] [-v] + bin/console serverblock remove [-h|--help|-?] [-v] + bin/console serverblock export + bin/console serverblock import Description - With this tool, you can list the current blocked server domain patterns + With this tool, you can list the current blocked server domain patterns or you can add / remove a blocked server domain pattern from the list. - + Using the export and import options you can share your server blocklist + with other node admins by CSV files. + Patterns are case-insensitive shell wildcard comprising the following special characters: - * : Any number of characters - ? : Any single character @@ -87,12 +91,79 @@ HELP; return $this->addBlockedServer($this->config); case 'remove': return $this->removeBlockedServer($this->config); + case 'export': + return $this->exportBlockedServers($this->config); + case 'import': + return $this->importBlockedServers($this->config); default: throw new CommandArgsException('Unknown command.'); break; } } + /** + * Exports the list of blocked domains including the reason for the + * block to a CSV file. + * + * @param IConfig $config + */ + private function exportBlockedServers(IConfig $config) + { + $filename = $this->getArgument(1); + $blocklist = $config->get('system', 'blocklist', []); + $fp = fopen($filename, 'w'); + if (!$fp) { + throw new Exception(sprintf('The file "%s" could not be created.', $filename)); + } + foreach ($blocklist as $domain) { + fputcsv($fp, $domain); + } + } + /** + * Imports a list of domains and a reason for the block from a CSV + * file, e.g. created with the export function. + * + * @param IConfig $config + */ + private function importBlockedServers(IConfig $config) + { + $filename = $this->getArgument(1); + $currBlockList = $config->get('system', 'blocklist', []); + $newBlockList = []; + if (($fp = fopen($filename, 'r')) !== false) { + while (($data = fgetcsv($fp, 1000, ',')) !== false) { + $domain = $data[0]; + if (count($data) == 0) { + $reason = self::DEFAULT_REASON; + } else { + $reason = $data[1]; + } + $data = [ + 'domain' => $domain, + 'reason' => $reason + ]; + if (!in_array($data, $newBlockList)) { + $newBlockList[] = $data; + } + } + foreach ($currBlockList as $blocked) { + if (!in_array($blocked, $newBlockList)) { + $newBlockList[] = $blocked; + } + } + if ($config->set('system', 'blocklist', $newBlockList)) { + $this->out(sprintf("Entries from %s that were not blocked before are now blocked", $filename)); + return 0; + } else { + $this->out(sprintf("Couldn't save '%s' as blocked server", $domain)); + return 1; + } + + } else { + throw new Exception(sprintf('The file "%s" could not be opened for importing', $filename)); + } + } + /** * Prints the whole list of blocked domains including the reason * @@ -127,9 +198,9 @@ HELP; $update = false; - $currBlocklist = $config->get('system', 'blocklist', []); + $currBlockList = $config->get('system', 'blocklist', []); $newBlockList = []; - foreach ($currBlocklist as $blocked) { + foreach ($currBlockList as $blocked) { if ($blocked['domain'] === $domain) { $update = true; $newBlockList[] = [ @@ -178,9 +249,9 @@ HELP; $found = false; - $currBlocklist = $config->get('system', 'blocklist', []); + $currBlockList = $config->get('system', 'blocklist', []); $newBlockList = []; - foreach ($currBlocklist as $blocked) { + foreach ($currBlockList as $blocked) { if ($blocked['domain'] === $domain) { $found = true; } else { diff --git a/src/Console/Storage.php b/src/Console/Storage.php index 09e062049f..d0a2a66637 100644 --- a/src/Console/Storage.php +++ b/src/Console/Storage.php @@ -106,7 +106,7 @@ HELP; $isregisterd = false; foreach ($this->storageManager->listBackends() as $name => $class) { $issel = ' '; - if ($current::getName() == $name) { + if ($current && $current::getName() == $name) { $issel = '*'; $isregisterd = true; }; diff --git a/src/Console/User.php b/src/Console/User.php index d976620382..a9378a61e9 100644 --- a/src/Console/User.php +++ b/src/Console/User.php @@ -59,7 +59,7 @@ console user - Modify user settings per console commands. Usage bin/console user password [] [-h|--help|-?] [-v] bin/console user add [ [ [ []]]] [-h|--help|-?] [-v] - bin/console user delete [] [-q] [-h|--help|-?] [-v] + bin/console user delete [] [-y] [-h|--help|-?] [-v] bin/console user allow [] [-h|--help|-?] [-v] bin/console user deny [] [-h|--help|-?] [-v] bin/console user block [] [-h|--help|-?] [-v] @@ -78,8 +78,8 @@ Description Options -h|--help|-? Show help information - -v Show more debug information. - -q Quiet mode (don't ask for a command). + -v Show more debug information + -y Non-interactive mode, assume "yes" as answer to the user deletion prompt HELP; return $help; } @@ -304,19 +304,24 @@ HELP; } } - $user = $this->dba->selectFirst('user', ['uid'], ['nickname' => $nick]); + $user = $this->dba->selectFirst('user', ['uid', 'account_removed'], ['nickname' => $nick]); if (empty($user)) { throw new RuntimeException($this->l10n->t('User not found')); } - if (!$this->getOption('q')) { + if (!empty($user['account_removed'])) { + $this->out($this->l10n->t('User has already been marked for deletion.')); + return true; + } + + if (!$this->getOption('y')) { $this->out($this->l10n->t('Type "yes" to delete %s', $nick)); if (CliPrompt::prompt() !== 'yes') { - throw new RuntimeException('Delete abort.'); + throw new RuntimeException($this->l10n->t('Deletion aborted.')); } } - return UserModel::remove($user['uid'] ?? -1); + return UserModel::remove($user['uid']); } /** @@ -404,7 +409,7 @@ HELP; case 'guid': $user = UserModel::getByGuid($param, $fields); break; - case 'email': + case 'mail': $user = UserModel::getByEmail($param, $fields); break; case 'nick': diff --git a/src/Content/BoundariesPager.php b/src/Content/BoundariesPager.php index 8bbbde2b47..ebdd72c911 100644 --- a/src/Content/BoundariesPager.php +++ b/src/Content/BoundariesPager.php @@ -27,7 +27,7 @@ use Friendica\Util\Network; use Friendica\Util\Strings; /** - * This pager should be used by lists using the since_id†/max_id† parameters + * This pager should be used by lists using the min_id†/max_id† parameters * * This pager automatically identifies if the sorting is done increasingly or decreasingly if the first item id† * and last item id† are different. Otherwise it defaults to decreasingly like reverse chronological lists. @@ -60,9 +60,9 @@ class BoundariesPager extends Pager if (!empty($parsed['query'])) { parse_str($parsed['query'], $queryParameters); - $this->first_page = !($queryParameters['since_id'] ?? null) && !($queryParameters['max_id'] ?? null); + $this->first_page = !($queryParameters['min_id'] ?? null) && !($queryParameters['max_id'] ?? null); - unset($queryParameters['since_id']); + unset($queryParameters['min_id']); unset($queryParameters['max_id']); $parsed['query'] = http_build_query($queryParameters); @@ -111,7 +111,7 @@ class BoundariesPager extends Pager 'prev' => [ 'url' => Strings::ensureQueryParameter($this->baseQueryString . ($this->first_item_id >= $this->last_item_id ? - '&since_id=' . $this->first_item_id : '&max_id=' . $this->first_item_id) + '&min_id=' . $this->first_item_id : '&max_id=' . $this->first_item_id) ), 'text' => $this->l10n->t('newer'), 'class' => 'previous' . ($this->first_page ? ' disabled' : '') @@ -119,7 +119,7 @@ class BoundariesPager extends Pager 'next' => [ 'url' => Strings::ensureQueryParameter($this->baseQueryString . ($this->first_item_id >= $this->last_item_id ? - '&max_id=' . $this->last_item_id : '&since_id=' . $this->last_item_id) + '&max_id=' . $this->last_item_id : '&min_id=' . $this->last_item_id) ), 'text' => $this->l10n->t('older'), 'class' => 'next' . ($displayedItemCount < $this->getItemsPerPage() ? ' disabled' : '') diff --git a/src/Content/ContactSelector.php b/src/Content/ContactSelector.php index c834f8c514..ec7bcab95c 100644 --- a/src/Content/ContactSelector.php +++ b/src/Content/ContactSelector.php @@ -76,14 +76,6 @@ class ContactSelector $server_url = Strings::normaliseLink($contact['baseurl']); } - if (empty($server_url)) { - // Fetch the server url from the gcontact table - $gcontact = DBA::selectFirst('gcontact', ['server_url'], ['nurl' => Strings::normaliseLink($profile)]); - if (!empty($gcontact) && !empty($gcontact['server_url'])) { - $server_url = Strings::normaliseLink($gcontact['server_url']); - } - } - if (empty($server_url)) { // Create the server url out of the profile url $parts = parse_url($profile); diff --git a/src/Content/Feature.php b/src/Content/Feature.php index 880a6706bc..0f3493ab29 100644 --- a/src/Content/Feature.php +++ b/src/Content/Feature.php @@ -96,7 +96,6 @@ class Feature DI::l10n()->t('General Features'), //array('expire', DI::l10n()->t('Content Expiration'), DI::l10n()->t('Remove old posts/comments after a period of time')), ['photo_location', DI::l10n()->t('Photo Location'), DI::l10n()->t("Photo metadata is normally stripped. This extracts the location \x28if present\x29 prior to stripping metadata and links it to a map."), false, DI::config()->get('feature_lock', 'photo_location', false)], - ['export_calendar', DI::l10n()->t('Export Public Calendar'), DI::l10n()->t('Ability for visitors to download the public calendar'), false, DI::config()->get('feature_lock', 'export_calendar', false)], ['trending_tags', DI::l10n()->t('Trending Tags'), DI::l10n()->t('Show a community page widget with a list of the most popular tags in recent public posts.'), false, DI::config()->get('feature_lock', 'trending_tags', false)], ], @@ -107,20 +106,6 @@ class Feature ['explicit_mentions', DI::l10n()->t('Explicit Mentions'), DI::l10n()->t('Add explicit mentions to comment box for manual control over who gets mentioned in replies.'), false, DI::config()->get('feature_lock', 'explicit_mentions', false)], ], - // Network sidebar widgets - 'widgets' => [ - DI::l10n()->t('Network Sidebar'), - ['archives', DI::l10n()->t('Archives'), DI::l10n()->t('Ability to select posts by date ranges'), false, DI::config()->get('feature_lock', 'archives', false)], - ['networks', DI::l10n()->t('Protocol Filter'), DI::l10n()->t('Enable widget to display Network posts only from selected protocols'), false, DI::config()->get('feature_lock', 'networks', false)], - ], - - // Network tabs - 'net_tabs' => [ - DI::l10n()->t('Network Tabs'), - ['new_tab', DI::l10n()->t('Network New Tab'), DI::l10n()->t("Enable tab to display only new Network posts \x28from the last 12 hours\x29"), false, DI::config()->get('feature_lock', 'new_tab', false)], - ['link_tab', DI::l10n()->t('Network Shared Links Tab'), DI::l10n()->t('Enable tab to display only Network posts with links in them'), false, DI::config()->get('feature_lock', 'link_tab', false)], - ], - // Item tools 'tools' => [ DI::l10n()->t('Post/Comment Tools'), diff --git a/src/Content/ForumManager.php b/src/Content/ForumManager.php index 7d3cb89a71..41f3a650ab 100644 --- a/src/Content/ForumManager.php +++ b/src/Content/ForumManager.php @@ -27,7 +27,6 @@ use Friendica\Core\Renderer; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; -use Friendica\Util\Proxy as ProxyUtils; /** * This class handles methods related to the forum functionality @@ -72,7 +71,7 @@ class ForumManager $forumlist = []; - $fields = ['id', 'url', 'name', 'micro', 'thumb']; + $fields = ['id', 'url', 'name', 'micro', 'thumb', 'avatar']; $condition = [$condition_str, Protocol::DFRN, Protocol::ACTIVITYPUB, $uid]; $contacts = DBA::select('contact', $fields, $condition, $params); if (!$contacts) { @@ -100,13 +99,14 @@ class ForumManager * Sidebar widget to show subcribed friendica forums. If activated * in the settings, it appears at the notwork page sidebar * - * @param int $uid The ID of the User - * @param int $cid The contact id which is used to mark a forum as "selected" + * @param string $baseurl Base module path + * @param int $uid The ID of the User + * @param int $cid The contact id which is used to mark a forum as "selected" * @return string * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function widget($uid, $cid = 0) + public static function widget(string $baseurl, int $uid, int $cid = 0) { $o = ''; @@ -126,12 +126,12 @@ class ForumManager $selected = (($cid == $contact['id']) ? ' forum-selected' : ''); $entry = [ - 'url' => 'network?cid=' . $contact['id'], + 'url' => $baseurl . '/' . $contact['id'], 'external_url' => Contact::magicLink($contact['url']), 'name' => $contact['name'], 'cid' => $contact['id'], 'selected' => $selected, - 'micro' => DI::baseUrl()->remove(ProxyUtils::proxifyUrl($contact['micro'], false, ProxyUtils::SIZE_MICRO)), + 'micro' => DI::baseUrl()->remove(Contact::getMicro($contact)), 'id' => ++$id, ]; $entries[] = $entry; @@ -147,6 +147,7 @@ class ForumManager '$link_desc' => DI::l10n()->t('External link to forum'), '$total' => $total, '$visible_forums' => $visible_forums, + '$showless' => DI::l10n()->t('show less'), '$showmore' => DI::l10n()->t('show more')] ); } diff --git a/src/Content/Item.php b/src/Content/Item.php index 51a14435ef..c0b1d3b49a 100644 --- a/src/Content/Item.php +++ b/src/Content/Item.php @@ -21,7 +21,10 @@ namespace Friendica\Content; +use Friendica\Database\DBA; +use Friendica\Model\Contact; use Friendica\Model\FileTag; +use Friendica\Model\Tag; /** * A content helper class for displaying items @@ -100,4 +103,129 @@ class Item return [$categories, $folders]; } + + /** + * This function removes the tag $tag from the text $body and replaces it with + * the appropriate link. + * + * @param string $body the text to replace the tag in + * @param string $inform a comma-seperated string containing everybody to inform + * @param integer $profile_uid the user id to replace the tag for (0 = anyone) + * @param string $tag the tag to replace + * @param string $network The network of the post + * + * @return array|bool ['replaced' => $replaced, 'contact' => $contact]; + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public static function replaceTag(&$body, &$inform, $profile_uid, $tag, $network = '') + { + $replaced = false; + + //is it a person tag? + if (Tag::isType($tag, Tag::MENTION, Tag::IMPLICIT_MENTION, Tag::EXCLUSIVE_MENTION)) { + $tag_type = substr($tag, 0, 1); + //is it already replaced? + if (strpos($tag, '[url=')) { + // Checking for the alias that is used for OStatus + $pattern = '/[@!]\[url\=(.*?)\](.*?)\[\/url\]/ism'; + if (preg_match($pattern, $tag, $matches)) { + $data = Contact::getByURL($matches[1], false, ['alias', 'nick']); + + if ($data['alias'] != '') { + $newtag = '@[url=' . $data['alias'] . ']' . $data['nick'] . '[/url]'; + } + } + + return $replaced; + } + + //get the person's name + $name = substr($tag, 1); + + // Sometimes the tag detection doesn't seem to work right + // This is some workaround + $nameparts = explode(' ', $name); + $name = $nameparts[0]; + + // Try to detect the contact in various ways + if (strpos($name, 'http://') || strpos($name, '@')) { + $contact = Contact::getByURLForUser($name, $profile_uid); + } else { + $contact = false; + $fields = ['id', 'url', 'nick', 'name', 'alias', 'network', 'forum', 'prv']; + + if (strrpos($name, '+')) { + // Is it in format @nick+number? + $tagcid = intval(substr($name, strrpos($name, '+') + 1)); + $contact = DBA::selectFirst('contact', $fields, ['id' => $tagcid, 'uid' => $profile_uid]); + } + + // select someone by nick in the current network + if (!DBA::isResult($contact) && ($network != '')) { + $condition = ["`nick` = ? AND `network` = ? AND `uid` = ?", + $name, $network, $profile_uid]; + $contact = DBA::selectFirst('contact', $fields, $condition); + } + + // select someone by attag in the current network + if (!DBA::isResult($contact) && ($network != '')) { + $condition = ["`attag` = ? AND `network` = ? AND `uid` = ?", + $name, $network, $profile_uid]; + $contact = DBA::selectFirst('contact', $fields, $condition); + } + + //select someone by name in the current network + if (!DBA::isResult($contact) && ($network != '')) { + $condition = ['name' => $name, 'network' => $network, 'uid' => $profile_uid]; + $contact = DBA::selectFirst('contact', $fields, $condition); + } + + // select someone by nick in any network + if (!DBA::isResult($contact)) { + $condition = ["`nick` = ? AND `uid` = ?", $name, $profile_uid]; + $contact = DBA::selectFirst('contact', $fields, $condition); + } + + // select someone by attag in any network + if (!DBA::isResult($contact)) { + $condition = ["`attag` = ? AND `uid` = ?", $name, $profile_uid]; + $contact = DBA::selectFirst('contact', $fields, $condition); + } + + // select someone by name in any network + if (!DBA::isResult($contact)) { + $condition = ['name' => $name, 'uid' => $profile_uid]; + $contact = DBA::selectFirst('contact', $fields, $condition); + } + } + + // Check if $contact has been successfully loaded + if (DBA::isResult($contact)) { + if (strlen($inform) && (isset($contact['notify']) || isset($contact['id']))) { + $inform .= ','; + } + + if (isset($contact['id'])) { + $inform .= 'cid:' . $contact['id']; + } elseif (isset($contact['notify'])) { + $inform .= $contact['notify']; + } + + $profile = $contact['url']; + $newname = ($contact['name'] ?? '') ?: $contact['nick']; + } + + //if there is an url for this persons profile + if (isset($profile) && ($newname != '')) { + $replaced = true; + // create profile link + $profile = str_replace(',', '%2c', $profile); + $newtag = $tag_type.'[url=' . $profile . ']' . $newname . '[/url]'; + $body = str_replace($tag_type . $name, $newtag, $body); + } + } + + return ['replaced' => $replaced, 'contact' => $contact]; + } } diff --git a/src/Content/Nav.php b/src/Content/Nav.php index c3e0218573..9e34cefc75 100644 --- a/src/Content/Nav.php +++ b/src/Content/Nav.php @@ -27,6 +27,7 @@ use Friendica\Core\Renderer; use Friendica\Core\Session; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Model\Contact; use Friendica\Model\Profile; use Friendica\Model\User; @@ -171,20 +172,24 @@ class Nav } if (local_user()) { - // user menu - $nav['usermenu'][] = ['profile/' . $a->user['nickname'], DI::l10n()->t('Status'), '', DI::l10n()->t('Your posts and conversations')]; - $nav['usermenu'][] = ['profile/' . $a->user['nickname'] . '/profile', DI::l10n()->t('Profile'), '', DI::l10n()->t('Your profile page')]; - $nav['usermenu'][] = ['photos/' . $a->user['nickname'], DI::l10n()->t('Photos'), '', DI::l10n()->t('Your photos')]; - $nav['usermenu'][] = ['videos/' . $a->user['nickname'], DI::l10n()->t('Videos'), '', DI::l10n()->t('Your videos')]; - $nav['usermenu'][] = ['events/', DI::l10n()->t('Events'), '', DI::l10n()->t('Your events')]; - $nav['usermenu'][] = ['notes/', DI::l10n()->t('Personal notes'), '', DI::l10n()->t('Your personal notes')]; + if (!empty($a->user)) { + // user menu + $nav['usermenu'][] = ['profile/' . $a->user['nickname'], DI::l10n()->t('Status'), '', DI::l10n()->t('Your posts and conversations')]; + $nav['usermenu'][] = ['profile/' . $a->user['nickname'] . '/profile', DI::l10n()->t('Profile'), '', DI::l10n()->t('Your profile page')]; + $nav['usermenu'][] = ['photos/' . $a->user['nickname'], DI::l10n()->t('Photos'), '', DI::l10n()->t('Your photos')]; + $nav['usermenu'][] = ['videos/' . $a->user['nickname'], DI::l10n()->t('Videos'), '', DI::l10n()->t('Your videos')]; + $nav['usermenu'][] = ['events/', DI::l10n()->t('Events'), '', DI::l10n()->t('Your events')]; + $nav['usermenu'][] = ['notes/', DI::l10n()->t('Personal notes'), '', DI::l10n()->t('Your personal notes')]; - // user info - $contact = DBA::selectFirst('contact', ['micro'], ['uid' => $a->user['uid'], 'self' => true]); - $userinfo = [ - 'icon' => (DBA::isResult($contact) ? DI::baseUrl()->remove($contact['micro']) : 'images/person-48.jpg'), - 'name' => $a->user['username'], - ]; + // user info + $contact = DBA::selectFirst('contact', ['micro'], ['uid' => $a->user['uid'], 'self' => true]); + $userinfo = [ + 'icon' => (DBA::isResult($contact) ? DI::baseUrl()->remove($contact['micro']) : Contact::DEFAULT_AVATAR_MICRO), + 'name' => $a->user['username'], + ]; + } else { + DI::logger()->warning('Empty $a->user for local user', ['local_user' => local_user(), '$a' => $a]); + } } // "Home" should also take you home from an authenticated remote profile connection @@ -252,7 +257,7 @@ class Nav } // The following nav links are only show to logged in users - if (local_user()) { + if (local_user() && !empty($a->user)) { $nav['network'] = ['network', DI::l10n()->t('Network'), '', DI::l10n()->t('Conversations from your friends')]; $nav['home'] = ['profile/' . $a->user['nickname'], DI::l10n()->t('Home'), '', DI::l10n()->t('Your posts and conversations')]; diff --git a/src/Content/OEmbed.php b/src/Content/OEmbed.php index db467a2630..355dda3fc1 100644 --- a/src/Content/OEmbed.php +++ b/src/Content/OEmbed.php @@ -29,6 +29,7 @@ use Exception; use Friendica\Core\Cache\Duration; use Friendica\Core\Hook; use Friendica\Core\Renderer; +use Friendica\Database\Database; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Util\DateTimeFormat; @@ -95,7 +96,7 @@ class OEmbed if (!in_array($ext, $noexts)) { // try oembed autodiscovery - $html_text = Network::fetchUrl($embedurl, false, 15, 'text/*'); + $html_text = DI::httpRequest()->fetch($embedurl, 15, 'text/*'); if ($html_text) { $dom = @DOMDocument::loadHTML($html_text); if ($dom) { @@ -103,14 +104,14 @@ class OEmbed $entries = $xpath->query("//link[@type='application/json+oembed']"); foreach ($entries as $e) { $href = $e->getAttributeNode('href')->nodeValue; - $json_string = Network::fetchUrl($href . '&maxwidth=' . $a->videowidth); + $json_string = DI::httpRequest()->fetch($href . '&maxwidth=' . $a->videowidth); break; } $entries = $xpath->query("//link[@type='text/json+oembed']"); foreach ($entries as $e) { $href = $e->getAttributeNode('href')->nodeValue; - $json_string = Network::fetchUrl($href . '&maxwidth=' . $a->videowidth); + $json_string = DI::httpRequest()->fetch($href . '&maxwidth=' . $a->videowidth); break; } } @@ -131,7 +132,7 @@ class OEmbed 'maxwidth' => $a->videowidth, 'content' => $json_string, 'created' => DateTimeFormat::utcNow() - ], true); + ], Database::INSERT_UPDATE); $cache_ttl = Duration::DAY; } else { $cache_ttl = Duration::FIVE_MINUTES; diff --git a/src/Content/PageInfo.php b/src/Content/PageInfo.php new file mode 100644 index 0000000000..5396bc1bbe --- /dev/null +++ b/src/Content/PageInfo.php @@ -0,0 +1,310 @@ +. + * + */ + +namespace Friendica\Content; + +use Friendica\Core\Hook; +use Friendica\Core\Logger; +use Friendica\DI; +use Friendica\Network\HTTPException; +use Friendica\Util\ParseUrl; +use Friendica\Util\Strings; + +/** + * Extracts trailing URLs from post bodies to transform them in enriched attachment tags through Site Info query + */ +class PageInfo +{ + /** + * @param string $body + * @param bool $searchNakedUrls + * @param bool $no_photos + * @return string + * @throws HTTPException\InternalServerErrorException + */ + public static function searchAndAppendToBody(string $body, bool $searchNakedUrls = false, bool $no_photos = false) + { + Logger::info('add_page_info_to_body: fetch page info for body', ['body' => $body]); + + $url = self::getRelevantUrlFromBody($body, $searchNakedUrls); + if (!$url) { + return $body; + } + + $data = self::queryUrl($url); + if (!$data) { + return $body; + } + + return self::appendDataToBody($body, $data, $no_photos); + } + + /** + * @param string $body + * @param array $data + * @param bool $no_photos + * @return string + * @throws HTTPException\InternalServerErrorException + */ + public static function appendDataToBody(string $body, array $data, bool $no_photos = false) + { + // Only one [attachment] tag per body is allowed + $existingAttachmentPos = strpos($body, '[attachment'); + if ($existingAttachmentPos !== false) { + $linkTitle = $data['title'] ?: $data['url']; + // Additional link attachments are prepended before the existing [attachment] tag + $body = substr_replace($body, "\n[bookmark=" . $data['url'] . ']' . $linkTitle . "[/bookmark]\n", $existingAttachmentPos, 0); + } else { + $footer = PageInfo::getFooterFromData($data, $no_photos); + $body = self::stripTrailingUrlFromBody($body, $data['url']); + $body .= "\n" . $footer; + } + + return $body; + } + + /** + * @param string $url + * @param bool $no_photos + * @param string $photo + * @param bool $keywords + * @param string $keyword_denylist + * @return string + * @throws HTTPException\InternalServerErrorException + */ + public static function getFooterFromUrl(string $url, bool $no_photos = false, string $photo = '', bool $keywords = false, string $keyword_denylist = '') + { + $data = self::queryUrl($url, $photo, $keywords, $keyword_denylist); + + return self::getFooterFromData($data, $no_photos); + } + + /** + * @param array $data + * @param bool $no_photos + * @return string + * @throws HTTPException\InternalServerErrorException + */ + public static function getFooterFromData(array $data, bool $no_photos = false) + { + Hook::callAll('page_info_data', $data); + + if (empty($data['type'])) { + return ''; + } + + // It maybe is a rich content, but if it does have everything that a link has, + // then treat it that way + if (($data['type'] == 'rich') && is_string($data['title']) && + is_string($data['text']) && !empty($data['images'])) { + $data['type'] = 'link'; + } + + $data['title'] = $data['title'] ?? ''; + + if ((($data['type'] != 'link') && ($data['type'] != 'video') && ($data['type'] != 'photo')) || ($data['title'] == $data['url'])) { + return ''; + } + + if ($no_photos && ($data['type'] == 'photo')) { + return ''; + } + + // Escape some bad characters + $data['url'] = str_replace(['[', ']'], ['[', ']'], htmlentities($data['url'], ENT_QUOTES, 'UTF-8', false)); + $data['title'] = str_replace(['[', ']'], ['[', ']'], htmlentities($data['title'], ENT_QUOTES, 'UTF-8', false)); + + $text = "[attachment type='" . $data['type'] . "'"; + + if (!empty($data['url'])) { + $text .= " url='" . $data['url'] . "'"; + } + + if (!empty($data['title'])) { + $text .= " title='" . $data['title'] . "'"; + } + + if (empty($data['text'])) { + $data['text'] = ''; + } + + // Only embedd a picture link when it seems to be a valid picture ("width" is set) + if (!empty($data['images']) && !empty($data['images'][0]['width'])) { + $preview = str_replace(['[', ']'], ['[', ']'], htmlentities($data['images'][0]['src'], ENT_QUOTES, 'UTF-8', false)); + // if the preview picture is larger than 500 pixels then show it in a larger mode + // But only, if the picture isn't higher than large (To prevent huge posts) + if (!DI::config()->get('system', 'always_show_preview') && ($data['images'][0]['width'] >= 500) + && ($data['images'][0]['width'] >= $data['images'][0]['height'])) { + $text .= " image='" . $preview . "'"; + } else { + $text .= " preview='" . $preview . "'"; + + if (empty($data['text'])) { + $data['text'] = $data['title']; + } + + if (empty($data['text'])) { + $data['text'] = $data['url']; + } + } + } + + $text .= ']' . $data['text'] . '[/attachment]'; + + $hashtags = ''; + if (!empty($data['keywords'])) { + $hashtags = "\n"; + foreach ($data['keywords'] as $keyword) { + /// @TODO make a positive list of allowed characters + $hashtag = str_replace([' ', '+', '/', '.', '#', '@', "'", '"', '’', '`', '(', ')', '„', '“'], '', $keyword); + $hashtags .= '#[url=' . DI::baseUrl() . '/search?tag=' . $hashtag . ']' . $hashtag . '[/url] '; + } + } + + return $text . $hashtags; + } + + /** + * @param string $url + * @param string $photo + * @param bool $keywords + * @param string $keyword_denylist + * @return array|bool + * @throws HTTPException\InternalServerErrorException + */ + public static function queryUrl(string $url, string $photo = '', bool $keywords = false, string $keyword_denylist = '') + { + $data = ParseUrl::getSiteinfoCached($url, true); + + if ($photo != '') { + $data['images'][0]['src'] = $photo; + } + + if (!$keywords) { + unset($data['keywords']); + } elseif ($keyword_denylist && !empty($data['keywords'])) { + $list = explode(', ', $keyword_denylist); + + foreach ($list as $keyword) { + $keyword = trim($keyword); + + $index = array_search($keyword, $data['keywords']); + if ($index !== false) { + unset($data['keywords'][$index]); + } + } + } + + Logger::info('fetch page info for URL', ['url' => $url, 'data' => $data]); + + return $data; + } + + /** + * @param string $url + * @param string $photo + * @param string $keyword_denylist + * @return array + * @throws HTTPException\InternalServerErrorException + */ + public static function getTagsFromUrl(string $url, string $photo = '', string $keyword_denylist = '') + { + $data = self::queryUrl($url, $photo, true, $keyword_denylist); + + if (empty($data['keywords'])) { + return []; + } + + $taglist = []; + foreach ($data['keywords'] as $keyword) { + $hashtag = str_replace([' ', '+', '/', '.', '#', "'"], + ['', '', '', '', '', ''], $keyword); + + $taglist[] = $hashtag; + } + + return $taglist; + } + + /** + * Picks a non-hashtag, non-mention, schemeful URL at the end of the provided body string to be converted into Page Info. + * + * @param string $body + * @param bool $searchNakedUrls Whether we should pick a naked URL (outside of BBCode tags) as a last resort + * @return string|null + */ + protected static function getRelevantUrlFromBody(string $body, bool $searchNakedUrls = false) + { + $URLSearchString = 'https?://[^\[\]]*'; + + // Fix for Mastodon where the mentions are in a different format + $body = preg_replace("~\[url=($URLSearchString)]([#!@])(.*?)\[/url]~is", '$2[url=$1]$3[/url]', $body); + + preg_match("~(? '', - 'text' => '', - 'after' => '', - 'image' => null, - 'url' => '', - 'title' => '', - 'description' => '', + 'type' => '', + 'text' => '', + 'after' => '', + 'image' => null, + 'url' => '', + 'provider_name' => '', + 'provider_url' => '', + 'title' => '', + 'description' => '', ]; if (!preg_match("/(.*)\[attachment(.*?)\](.*?)\[\/attachment\](.*)/ism", $body, $match)) { @@ -243,6 +258,16 @@ class BBCode $data['after'] = trim($match[4]); + $parts = parse_url($data['url']); + if (!empty($parts['scheme']) && !empty($parts['host'])) { + $data['provider_name'] = $parts['host']; + $data['provider_url'] = $parts['scheme'] . '://' . $parts['host']; + + if (!empty($parts['port'])) { + $data['provider_url'] .= ':' . $parts['port']; + } + } + return $data; } @@ -434,15 +459,15 @@ class BBCode */ public static function toPlaintext($text, $keep_urls = true) { - $naked_text = HTML::toPlaintext(BBCode::convert($text, false, 0, true), 0, !$keep_urls); + $naked_text = HTML::toPlaintext(self::convert($text, false, 0, true), 0, !$keep_urls); return $naked_text; } - private static function proxyUrl($image, $simplehtml = false) + private static function proxyUrl($image, $simplehtml = self::INTERNAL) { // Only send proxied pictures to API and for internal display - if (in_array($simplehtml, [false, 2])) { + if (in_array($simplehtml, [self::INTERNAL, self::API])) { return ProxyUtils::proxifyUrl($image); } else { return $image; @@ -475,7 +500,7 @@ class BBCode continue; } - $curlResult = Network::curl($mtch[1], true); + $curlResult = DI::httpRequest()->get($mtch[1]); if (!$curlResult->isSuccess()) { continue; } @@ -494,14 +519,14 @@ class BBCode $Image->scaleDown(640); $new_width = $Image->getWidth(); $new_height = $Image->getHeight(); - Logger::log('scale_external_images: ' . $orig_width . '->' . $new_width . 'w ' . $orig_height . '->' . $new_height . 'h' . ' match: ' . $mtch[0], Logger::DEBUG); + Logger::info('External images scaled', ['orig_width' => $orig_width, 'new_width' => $new_width, 'orig_height' => $orig_height, 'new_height' => $new_height, 'match' => $mtch[0]]); $s = str_replace( $mtch[0], '[img=' . $new_width . 'x' . $new_height. ']' . $mtch[1] . '[/img]' . "\n", $s ); - Logger::log('scale_external_images: new string: ' . $s, Logger::DEBUG); + Logger::info('New string', ['image' => $s]); } } } @@ -529,7 +554,7 @@ class BBCode // than the maximum, then don't waste time looking for the images if ($maxlen && (strlen($body) > $maxlen)) { - Logger::log('the total body length exceeds the limit', Logger::DEBUG); + Logger::info('the total body length exceeds the limit', ['maxlen' => $maxlen, 'body_len' => strlen($body)]); $orig_body = $body; $new_body = ''; @@ -549,7 +574,7 @@ class BBCode if (($textlen + $img_start) > $maxlen) { if ($textlen < $maxlen) { - Logger::log('the limit happens before an embedded image', Logger::DEBUG); + Logger::info('the limit happens before an embedded image'); $new_body = $new_body . substr($orig_body, 0, $maxlen - $textlen); $textlen = $maxlen; } @@ -563,7 +588,7 @@ class BBCode if (($textlen + $img_end) > $maxlen) { if ($textlen < $maxlen) { - Logger::log('the limit happens before the end of a non-embedded image', Logger::DEBUG); + Logger::info('the limit happens before the end of a non-embedded image'); $new_body = $new_body . substr($orig_body, 0, $maxlen - $textlen); $textlen = $maxlen; } @@ -586,11 +611,11 @@ class BBCode if (($textlen + strlen($orig_body)) > $maxlen) { if ($textlen < $maxlen) { - Logger::log('the limit happens after the end of the last image', Logger::DEBUG); + Logger::info('the limit happens after the end of the last image'); $new_body = $new_body . substr($orig_body, 0, $maxlen - $textlen); } } else { - Logger::log('the text size with embedded images extracted did not violate the limit', Logger::DEBUG); + Logger::info('the text size with embedded images extracted did not violate the limit'); $new_body = $new_body . $orig_body; } @@ -605,13 +630,13 @@ class BBCode * * Note: Can produce a [bookmark] tag in the returned string * - * @param string $text - * @param bool|int $simplehtml - * @param bool $tryoembed + * @param string $text + * @param integer $simplehtml + * @param bool $tryoembed * @return string * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - private static function convertAttachment($text, $simplehtml = false, $tryoembed = true) + private static function convertAttachment($text, $simplehtml = self::INTERNAL, $tryoembed = true) { $data = self::getAttachmentData($text); if (empty($data) || empty($data['url'])) { @@ -640,7 +665,7 @@ class BBCode } catch (Exception $e) { $data['title'] = ($data['title'] ?? '') ?: $data['url']; - if ($simplehtml != 4) { + if ($simplehtml != self::CONNECTORS) { $return = sprintf('
', $data['type']); } @@ -649,9 +674,9 @@ class BBCode $return .= sprintf('', $data['url'], self::proxyUrl($data['image'], $simplehtml), $data['title']); } else { if (!empty($data['image'])) { - $return .= sprintf('
', $data['url'], self::proxyUrl($data['image'], $simplehtml), $data['title']); + $return .= sprintf('
', $data['url'], self::proxyUrl($data['image'], $simplehtml), $data['title']); } elseif (!empty($data['preview'])) { - $return .= sprintf('
', $data['url'], self::proxyUrl($data['preview'], $simplehtml), $data['title']); + $return .= sprintf('
', $data['url'], self::proxyUrl($data['preview'], $simplehtml), $data['title']); } $return .= sprintf('

%s

', $data['url'], $data['title']); } @@ -667,7 +692,7 @@ class BBCode $return .= sprintf('%s', $data['url'], parse_url($data['url'], PHP_URL_HOST)); } - if ($simplehtml != 4) { + if ($simplehtml != self::CONNECTORS) { $return .= '
'; } } @@ -966,27 +991,12 @@ class BBCode function ($match) use ($callback) { $attribute_string = $match[2]; $attributes = []; - foreach (['author', 'profile', 'avatar', 'link', 'posted'] as $field) { + foreach (['author', 'profile', 'avatar', 'link', 'posted', 'guid'] as $field) { preg_match("/$field=(['\"])(.+?)\\1/ism", $attribute_string, $matches); $attributes[$field] = html_entity_decode($matches[2] ?? '', ENT_QUOTES, 'UTF-8'); } - // We only call this so that a previously unknown contact can be added. - // This is important for the function "Model\Contact::getDetailsByURL()". - // This function then can fetch an entry from the contact table. - $default['url'] = $attributes['profile']; - - if (!empty($attributes['author'])) { - $default['name'] = $attributes['author']; - } - - if (!empty($attributes['avatar'])) { - $default['photo'] = $attributes['avatar']; - } - - Contact::getIdForURL($attributes['profile'], 0, true, $default); - - $author_contact = Contact::getDetailsByURL($attributes['profile']); + $author_contact = Contact::getByURL($attributes['profile'], false, ['url', 'addr', 'name', 'micro']); $author_contact['url'] = ($author_contact['url'] ?? $attributes['profile']); $author_contact['addr'] = ($author_contact['addr'] ?? '') ?: Protocol::getAddrFromProfileUrl($attributes['profile']); @@ -998,7 +1008,9 @@ class BBCode $attributes['avatar'] = ProxyUtils::proxifyUrl($attributes['avatar'], false, ProxyUtils::SIZE_THUMB); } - return $match[1] . $callback($attributes, $author_contact, $match[3], trim($match[1]) != ''); + $content = preg_replace(Strings::autoLinkRegEx(), '$1', $match[3]); + + return $match[1] . $callback($attributes, $author_contact, $content, trim($match[1]) != ''); }, $text ); @@ -1025,13 +1037,10 @@ class BBCode $mention = Protocol::formatMention($attributes['profile'], $attributes['author']); switch ($simplehtml) { - case 1: - $text = ($is_quote_share? '
' : '') . '

' . html_entity_decode('♲ ', ENT_QUOTES, 'UTF-8') . ' ' . $mention . ':

' . "\n" . '«' . $content . '»'; + case self::API: + $text = ($is_quote_share? '
' : '') . '

' . html_entity_decode('♲ ', ENT_QUOTES, 'UTF-8') . ' ' . $author_contact['addr'] . ':

' . "\n" . $content; break; - case 2: - $text = ($is_quote_share? '
' : '') . '

' . html_entity_decode('♲ ', ENT_QUOTES, 'UTF-8') . ' ' . $author_contact['addr'] . ':

' . "\n" . $content; - break; - case 3: // Diaspora + case self::DIASPORA: if (stripos(Strings::normaliseLink($attributes['link']), 'http://twitter.com/') === 0) { $text = ($is_quote_share? '
' : '') . '

' . $attributes['link'] . '

' . "\n"; } else { @@ -1049,7 +1058,7 @@ class BBCode } break; - case 4: + case self::CONNECTORS: $headline = '

' . html_entity_decode('♲ ', ENT_QUOTES, 'UTF-8'); $headline .= DI::l10n()->t('%2$s %3$s', $attributes['link'], $mention, $attributes['posted']); $headline .= ':

' . "\n"; @@ -1057,37 +1066,32 @@ class BBCode $text = ($is_quote_share? '
' : '') . $headline . '
' . trim($content) . '
' . "\n"; break; - case 5: - $text = ($is_quote_share? '
' : '') . '

' . html_entity_decode('♲ ', ENT_QUOTES, 'UTF-8') . ' ' . $author_contact['addr'] . ':

' . "\n" . $content; + case self::OSTATUS: + $text = ($is_quote_share? '
' : '') . '

' . html_entity_decode('♲ ', ENT_QUOTES, 'UTF-8') . ' @' . $author_contact['addr'] . ': ' . $content . '

' . "\n"; break; - case 7: // statusnet/GNU Social - $text = ($is_quote_share? '
' : '') . '

' . html_entity_decode('♲ ', ENT_QUOTES, 'UTF-8') . ' @' . $author_contact['addr'] . ': ' . $content . '

' . "\n"; - break; - case 9: // ActivityPub + case self::ACTIVITYPUB: $author = '@' . $author_contact['addr'] . ':'; $text = '' . "\n"; break; default: - // Transforms quoted tweets in rich attachments to avoid nested tweets - if (stripos(Strings::normaliseLink($attributes['link']), 'http://twitter.com/') === 0 && OEmbed::isAllowedURL($attributes['link'])) { - try { - $text = ($is_quote_share? '
' : '') . OEmbed::getHTML($attributes['link']); - } catch (Exception $e) { - $text = ($is_quote_share? '
' : '') . sprintf('[bookmark=%s]%s[/bookmark]', $attributes['link'], $content); - } - } else { - $text = ($is_quote_share? "\n" : ''); + $text = ($is_quote_share? "\n" : ''); - $tpl = Renderer::getMarkupTemplate('shared_content.tpl'); - $text .= Renderer::replaceMacros($tpl, [ - '$profile' => $attributes['profile'], - '$avatar' => $attributes['avatar'], - '$author' => $attributes['author'], - '$link' => $attributes['link'], - '$posted' => $attributes['posted'], - '$content' => trim($content) - ]); - } + $contact = Contact::getByURL($attributes['profile'], false, ['network']); + $network = $contact['network'] ?? Protocol::PHANTOM; + + $tpl = Renderer::getMarkupTemplate('shared_content.tpl'); + $text .= Renderer::replaceMacros($tpl, [ + '$profile' => $attributes['profile'], + '$avatar' => $attributes['avatar'], + '$author' => $attributes['author'], + '$link' => $attributes['link'], + '$link_title' => DI::l10n()->t('link to source'), + '$posted' => $attributes['posted'], + '$guid' => $attributes['guid'], + '$network_name' => ContactSelector::networkToName($network, $attributes['profile']), + '$network_icon' => ContactSelector::networkToIcon($network, $attributes['profile']), + '$content' => self::setMentions(trim($content), 0, $network), + ]); break; } @@ -1107,11 +1111,11 @@ class BBCode $ch = @curl_init($match[1]); @curl_setopt($ch, CURLOPT_NOBODY, true); @curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - @curl_setopt($ch, CURLOPT_USERAGENT, $a->getUserAgent()); + @curl_setopt($ch, CURLOPT_USERAGENT, DI::httpRequest()->getUserAgent()); @curl_exec($ch); $curl_info = @curl_getinfo($ch); - DI::profiler()->saveTimestamp($stamp1, "network", System::callstack()); + DI::profiler()->saveTimestamp($stamp1, "network"); if (substr($curl_info['content_type'], 0, 6) == 'image/') { $text = "[url=" . $match[1] . ']' . $match[1] . "[/url]"; @@ -1119,7 +1123,7 @@ class BBCode $text = "[url=" . $match[2] . ']' . $match[2] . "[/url]"; // if its not a picture then look if its a page that contains a picture link - $body = Network::fetchUrl($match[1]); + $body = DI::httpRequest()->fetch($match[1]); $doc = new DOMDocument(); @$doc->loadHTML($body); @@ -1181,11 +1185,11 @@ class BBCode $ch = @curl_init($match[1]); @curl_setopt($ch, CURLOPT_NOBODY, true); @curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - @curl_setopt($ch, CURLOPT_USERAGENT, $a->getUserAgent()); + @curl_setopt($ch, CURLOPT_USERAGENT, DI::httpRequest()->getUserAgent()); @curl_exec($ch); $curl_info = @curl_getinfo($ch); - DI::profiler()->saveTimestamp($stamp1, "network", System::callstack()); + DI::profiler()->saveTimestamp($stamp1, "network"); // if its a link to a picture then embed this picture if (substr($curl_info['content_type'], 0, 6) == 'image/') { @@ -1198,7 +1202,7 @@ class BBCode } // if its not a picture then look if its a page that contains a picture link - $body = Network::fetchUrl($match[1]); + $body = DI::httpRequest()->fetch($match[1]); $doc = new DOMDocument(); @$doc->loadHTML($body); @@ -1233,6 +1237,17 @@ class BBCode return $return; } + public static function removeLinks(string $bbcode) + { + $bbcode = preg_replace("/\[img\=(.*?)\](.*?)\[\/img\]/ism", ' $1 ', $bbcode); + $bbcode = preg_replace("/\[img.*?\[\/img\]/ism", ' ', $bbcode); + + $bbcode = preg_replace('/[@!#]\[url\=.*?\].*?\[\/url\]/ism', '', $bbcode); + $bbcode = preg_replace("/\[url=[^\[\]]*\](.*)\[\/url\]/Usi", ' $1 ', $bbcode); + $bbcode = preg_replace('/[@!#]?\[url.*?\[\/url\]/ism', '', $bbcode); + return $bbcode; + } + /** * Converts a BBCode message to HTML message * @@ -1258,679 +1273,629 @@ class BBCode * @return string * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - public static function convert($text, $try_oembed = true, $simple_html = 0, $for_plaintext = false) + public static function convert(string $text = null, $try_oembed = true, $simple_html = self::INTERNAL, $for_plaintext = false) { - $a = DI::app(); - - /* - * preg_match_callback function to replace potential Oembed tags with Oembed content - * - * $match[0] = [tag]$url[/tag] or [tag=$url]$title[/tag] - * $match[1] = $url - * $match[2] = $title or absent - */ - $try_oembed_callback = function ($match) - { - $url = $match[1]; - $title = $match[2] ?? null; - - try { - $return = OEmbed::getHTML($url, $title); - } catch (Exception $ex) { - $return = $match[0]; - } - - return $return; - }; - - // Extracting code blocks before the whitespace processing and the autolinker - $codeblocks = []; - - $text = preg_replace_callback("#\[code(?:=([^\]]*))?\](.*?)\[\/code\]#ism", - function ($matches) use (&$codeblocks) { - $return = '#codeblock-' . count($codeblocks) . '#'; - if (strpos($matches[2], "\n") !== false) { - $codeblocks[] = '
' . htmlspecialchars(trim($matches[2], "\n\r"), ENT_NOQUOTES, 'UTF-8') . '
'; - } else { - $codeblocks[] = '' . htmlspecialchars($matches[2], ENT_NOQUOTES, 'UTF-8') . ''; - } - - return $return; - }, - $text - ); - - // Hide all [noparse] contained bbtags by spacefying them - // POSSIBLE BUG --> Will the 'preg' functions crash if there's an embedded image? - - $text = preg_replace_callback("/\[noparse\](.*?)\[\/noparse\]/ism", 'self::escapeNoparseCallback', $text); - $text = preg_replace_callback("/\[nobb\](.*?)\[\/nobb\]/ism", 'self::escapeNoparseCallback', $text); - $text = preg_replace_callback("/\[pre\](.*?)\[\/pre\]/ism", 'self::escapeNoparseCallback', $text); - - // Remove the abstract element. It is a non visible element. - $text = self::stripAbstract($text); - - // Move all spaces out of the tags - $text = preg_replace("/\[(\w*)\](\s*)/ism", '$2[$1]', $text); - $text = preg_replace("/(\s*)\[\/(\w*)\]/ism", '[/$2]$1', $text); - - // Extract the private images which use data urls since preg has issues with - // large data sizes. Stash them away while we do bbcode conversion, and then put them back - // in after we've done all the regex matching. We cannot use any preg functions to do this. - - $extracted = self::extractImagesFromItemBody($text); - $text = $extracted['body']; - $saved_image = $extracted['images']; - - // If we find any event code, turn it into an event. - // After we're finished processing the bbcode we'll - // replace all of the event code with a reformatted version. - - $ev = Event::fromBBCode($text); - - // Replace any html brackets with HTML Entities to prevent executing HTML or script - // Don't use strip_tags here because it breaks [url] search by replacing & with amp - - $text = str_replace("<", "<", $text); - $text = str_replace(">", ">", $text); - - // remove some newlines before the general conversion - $text = preg_replace("/\s?\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "[share$1]$2[/share]", $text); - $text = preg_replace("/\s?\[quote(.*?)\]\s?(.*?)\s?\[\/quote\]\s?/ism", "[quote$1]$2[/quote]", $text); - - // when the content is meant exporting to other systems then remove the avatar picture since this doesn't really look good on these systems - if (!$try_oembed) { - $text = preg_replace("/\[share(.*?)avatar\s?=\s?'.*?'\s?(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "\n[share$1$2]$3[/share]", $text); + // Accounting for null default column values + if (is_null($text) || $text === '') { + return ''; } - // Convert new line chars to html
tags - - // nlbr seems to be hopelessly messed up - // $Text = nl2br($Text); - - // We'll emulate it. - - $text = trim($text); - $text = str_replace("\r\n", "\n", $text); - - // Remove linefeeds inside of the table elements. See issue #6799 - $search = ["\n[th]", "[th]\n", " [th]", "\n[/th]", "[/th]\n", "[/th] ", - "\n[td]", "[td]\n", " [td]", "\n[/td]", "[/td]\n", "[/td] ", - "\n[tr]", "[tr]\n", " [tr]", "[tr] ", "\n[/tr]", "[/tr]\n", " [/tr]", "[/tr] ", - "[table]\n", "[table] ", " [table]", "\n[/table]", " [/table]", "[/table] "]; - $replace = ["[th]", "[th]", "[th]", "[/th]", "[/th]", "[/th]", - "[td]", "[td]", "[td]", "[/td]", "[/td]", "[/td]", - "[tr]", "[tr]", "[tr]", "[tr]", "[/tr]", "[/tr]", "[/tr]", "[/tr]", - "[table]", "[table]", "[table]", "[/table]", "[/table]", "[/table]"]; - do { - $oldtext = $text; - $text = str_replace($search, $replace, $text); - } while ($oldtext != $text); - - // Replace these here only once - $search = ["\n[table]", "[/table]\n"]; - $replace = ["[table]", "[/table]"]; - $text = str_replace($search, $replace, $text); - - // removing multiplicated newlines - if (DI::config()->get('system', 'remove_multiplicated_lines')) { - $search = ["\n\n\n", "\n ", " \n", "[/quote]\n\n", "\n[/quote]", "[/li]\n", "\n[li]", "\n[ul]", "[/ul]\n", "\n\n[share ", "[/attachment]\n", - "\n[h1]", "[/h1]\n", "\n[h2]", "[/h2]\n", "\n[h3]", "[/h3]\n", "\n[h4]", "[/h4]\n", "\n[h5]", "[/h5]\n", "\n[h6]", "[/h6]\n"]; - $replace = ["\n\n", "\n", "\n", "[/quote]\n", "[/quote]", "[/li]", "[li]", "[ul]", "[/ul]", "\n[share ", "[/attachment]", - "[h1]", "[/h1]", "[h2]", "[/h2]", "[h3]", "[/h3]", "[h4]", "[/h4]", "[h5]", "[/h5]", "[h6]", "[/h6]"]; - do { - $oldtext = $text; - $text = str_replace($search, $replace, $text); - } while ($oldtext != $text); - } - - /// @todo Have a closer look at the different html modes - // Handle attached links or videos - if (in_array($simple_html, [9])) { - $text = self::removeAttachment($text); - } elseif (!in_array($simple_html, [0, 4])) { - $text = self::removeAttachment($text, true); - } else { - $text = self::convertAttachment($text, $simple_html, $try_oembed); - } - - // leave open the posibility of [map=something] - // this is replaced in Item::prepareBody() which has knowledge of the item location - if (strpos($text, '[/map]') !== false) { - $text = preg_replace_callback( - "/\[map\](.*?)\[\/map\]/ism", - function ($match) use ($simple_html) { - return str_replace($match[0], '

' . Map::byLocation($match[1], $simple_html) . '

', $match[0]); - }, - $text - ); - } - - if (strpos($text, '[map=') !== false) { - $text = preg_replace_callback( - "/\[map=(.*?)\]/ism", - function ($match) use ($simple_html) { - return str_replace($match[0], '

' . Map::byCoordinates(str_replace('/', ' ', $match[1]), $simple_html) . '

', $match[0]); - }, - $text - ); - } - - if (strpos($text, '[map]') !== false) { - $text = preg_replace("/\[map\]/", '

', $text); - } - - // Check for headers - $text = preg_replace("(\[h1\](.*?)\[\/h1\])ism", '

$1

', $text); - $text = preg_replace("(\[h2\](.*?)\[\/h2\])ism", '

$1

', $text); - $text = preg_replace("(\[h3\](.*?)\[\/h3\])ism", '

$1

', $text); - $text = preg_replace("(\[h4\](.*?)\[\/h4\])ism", '

$1

', $text); - $text = preg_replace("(\[h5\](.*?)\[\/h5\])ism", '
$1
', $text); - $text = preg_replace("(\[h6\](.*?)\[\/h6\])ism", '
$1
', $text); - - // Check for paragraph - $text = preg_replace("(\[p\](.*?)\[\/p\])ism", '

$1

', $text); - - // Check for bold text - $text = preg_replace("(\[b\](.*?)\[\/b\])ism", '$1', $text); - - // Check for Italics text - $text = preg_replace("(\[i\](.*?)\[\/i\])ism", '$1', $text); - - // Check for Underline text - $text = preg_replace("(\[u\](.*?)\[\/u\])ism", '$1', $text); - - // Check for strike-through text - $text = preg_replace("(\[s\](.*?)\[\/s\])ism", '$1', $text); - - // Check for over-line text - $text = preg_replace("(\[o\](.*?)\[\/o\])ism", '$1', $text); - - // Check for colored text - $text = preg_replace("(\[color=(.*?)\](.*?)\[\/color\])ism", "$2", $text); - - // Check for sized text - // [size=50] --> font-size: 50px (with the unit). - if ($simple_html != 3) { - $text = preg_replace("(\[size=(\d*?)\](.*?)\[\/size\])ism", "$2", $text); - $text = preg_replace("(\[size=(.*?)\](.*?)\[\/size\])ism", "$2", $text); - } else { - // Issue 2199: Diaspora doesn't interpret the construct above, nor the or element - $text = preg_replace("(\[size=(.*?)\](.*?)\[\/size\])ism", "$2", $text); - } - - - // Check for centered text - $text = preg_replace("(\[center\](.*?)\[\/center\])ism", "
$1
", $text); - - // Check for list text - $text = str_replace("[*]", "
  • ", $text); - - // Check for style sheet commands - $text = preg_replace_callback( - "(\[style=(.*?)\](.*?)\[\/style\])ism", - function ($match) { - return "" . $match[2] . ""; - }, - $text - ); - - // Check for CSS classes - $text = preg_replace_callback( - "(\[class=(.*?)\](.*?)\[\/class\])ism", - function ($match) { - return "" . $match[2] . ""; - }, - $text - ); - - // handle nested lists - $endlessloop = 0; - - while ((((strpos($text, "[/list]") !== false) && (strpos($text, "[list") !== false)) || - ((strpos($text, "[/ol]") !== false) && (strpos($text, "[ol]") !== false)) || - ((strpos($text, "[/ul]") !== false) && (strpos($text, "[ul]") !== false)) || - ((strpos($text, "[/li]") !== false) && (strpos($text, "[li]") !== false))) && (++$endlessloop < 20)) { - $text = preg_replace("/\[list\](.*?)\[\/list\]/ism", '
      $1
    ', $text); - $text = preg_replace("/\[list=\](.*?)\[\/list\]/ism", '
      $1
    ', $text); - $text = preg_replace("/\[list=1\](.*?)\[\/list\]/ism", '
      $1
    ', $text); - $text = preg_replace("/\[list=((?-i)i)\](.*?)\[\/list\]/ism", '
      $2
    ', $text); - $text = preg_replace("/\[list=((?-i)I)\](.*?)\[\/list\]/ism", '
      $2
    ', $text); - $text = preg_replace("/\[list=((?-i)a)\](.*?)\[\/list\]/ism", '
      $2
    ', $text); - $text = preg_replace("/\[list=((?-i)A)\](.*?)\[\/list\]/ism", '
      $2
    ', $text); - $text = preg_replace("/\[ul\](.*?)\[\/ul\]/ism", '
      $1
    ', $text); - $text = preg_replace("/\[ol\](.*?)\[\/ol\]/ism", '
      $1
    ', $text); - $text = preg_replace("/\[li\](.*?)\[\/li\]/ism", '
  • $1
  • ', $text); - } - - $text = preg_replace("/\[th\](.*?)\[\/th\]/sm", '$1', $text); - $text = preg_replace("/\[td\](.*?)\[\/td\]/sm", '$1', $text); - $text = preg_replace("/\[tr\](.*?)\[\/tr\]/sm", '$1', $text); - $text = preg_replace("/\[table\](.*?)\[\/table\]/sm", '$1
    ', $text); - - $text = preg_replace("/\[table border=1\](.*?)\[\/table\]/sm", '$1
    ', $text); - $text = preg_replace("/\[table border=0\](.*?)\[\/table\]/sm", '$1
    ', $text); - - $text = str_replace('[hr]', '
    ', $text); - - if (!$for_plaintext) { - $escaped = []; - - // Escaping BBCodes susceptible to contain rogue URL we don'' want the autolinker to catch - $text = preg_replace_callback('#\[(url|img|audio|video|youtube|vimeo|share|attachment|iframe|bookmark).+?\[/\1\]#ism', - function ($matches) use (&$escaped) { - $return = '{escaped-' . count($escaped) . '}'; - $escaped[] = $matches[0]; - - return $return; - }, - $text - ); - - // Autolinker for isolated URLs - $text = preg_replace(Strings::autoLinkRegEx(), '[url]$1[/url]', $text); - - // Restoring escaped blocks - $text = preg_replace_callback('/{escaped-([0-9]+)}/iU', - function ($matches) use ($escaped) { - return $escaped[intval($matches[1])] ?? $matches[0]; - }, - $text - ); - } - - // This is actually executed in Item::prepareBody() - - $nosmile = strpos($text, '[nosmile]') !== false; - $text = str_replace('[nosmile]', '', $text); - - // Check for font change text - $text = preg_replace("/\[font=(.*?)\](.*?)\[\/font\]/sm", "$2", $text); - - // Declare the format for [spoiler] layout - $SpoilerLayout = '
    ' . DI::l10n()->t('Click to open/close') . '$1
    '; - - // Check for [spoiler] text - // handle nested quotes - $endlessloop = 0; - while ((strpos($text, "[/spoiler]") !== false) && (strpos($text, "[spoiler]") !== false) && (++$endlessloop < 20)) { - $text = preg_replace("/\[spoiler\](.*?)\[\/spoiler\]/ism", $SpoilerLayout, $text); - } - - // Check for [spoiler=Title] text - - // handle nested quotes - $endlessloop = 0; - while ((strpos($text, "[/spoiler]")!== false) && (strpos($text, "[spoiler=") !== false) && (++$endlessloop < 20)) { - $text = preg_replace("/\[spoiler=[\"\']*(.*?)[\"\']*\](.*?)\[\/spoiler\]/ism", - '
    $1$2
    ', - $text); - } - - // Declare the format for [quote] layout - $QuoteLayout = '
    $1
    '; - - // Check for [quote] text - // handle nested quotes - $endlessloop = 0; - while ((strpos($text, "[/quote]") !== false) && (strpos($text, "[quote]") !== false) && (++$endlessloop < 20)) { - $text = preg_replace("/\[quote\](.*?)\[\/quote\]/ism", "$QuoteLayout", $text); - } - - // Check for [quote=Author] text - - $t_wrote = DI::l10n()->t('$1 wrote:'); - - // handle nested quotes - $endlessloop = 0; - while ((strpos($text, "[/quote]")!== false) && (strpos($text, "[quote=") !== false) && (++$endlessloop < 20)) { - $text = preg_replace("/\[quote=[\"\']*(.*?)[\"\']*\](.*?)\[\/quote\]/ism", - "

    " . $t_wrote . "

    $2
    ", - $text); - } - - - // [img=widthxheight]image source[/img] - $text = preg_replace_callback( - "/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", - function ($matches) use ($simple_html) { - if (strpos($matches[3], "data:image/") === 0) { - return $matches[0]; - } - - $matches[3] = self::proxyUrl($matches[3], $simple_html); - return "[img=" . $matches[1] . "x" . $matches[2] . "]" . $matches[3] . "[/img]"; - }, - $text - ); - - $text = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '', $text); - $text = preg_replace("/\[zmg\=([0-9]*)x([0-9]*)\](.*?)\[\/zmg\]/ism", '', $text); - - $text = preg_replace_callback("/\[img\=(.*?)\](.*?)\[\/img\]/ism", - function ($matches) use ($simple_html) { - $matches[1] = self::proxyUrl($matches[1], $simple_html); - $matches[2] = htmlspecialchars($matches[2], ENT_COMPAT); - return '' . $matches[2] . ''; - }, - $text); - - // Images - // [img]pathtoimage[/img] - $text = preg_replace_callback( - "/\[img\](.*?)\[\/img\]/ism", - function ($matches) use ($simple_html) { - if (strpos($matches[1], "data:image/") === 0) { - return $matches[0]; - } - - $matches[1] = self::proxyUrl($matches[1], $simple_html); - return "[img]" . $matches[1] . "[/img]"; - }, - $text - ); - - $text = preg_replace("/\[img\](.*?)\[\/img\]/ism", '' . DI::l10n()->t('Image/photo') . '', $text); - $text = preg_replace("/\[zmg\](.*?)\[\/zmg\]/ism", '' . DI::l10n()->t('Image/photo') . '', $text); - - $text = preg_replace("/\[crypt\](.*?)\[\/crypt\]/ism", '
    ' . DI::l10n()->t('Encrypted content') . '
    ', $text); - $text = preg_replace("/\[crypt(.*?)\](.*?)\[\/crypt\]/ism", '
    ' . DI::l10n()->t('Encrypted content') . '
    ', $text); - //$Text = preg_replace("/\[crypt=(.*?)\](.*?)\[\/crypt\]/ism", '
    ' . DI::l10n()->t('Encrypted content') . '
    ', $Text); - - // Simplify "video" element - $text = preg_replace('(\[video.*?\ssrc\s?=\s?([^\s\]]+).*?\].*?\[/video\])ism', '[video]$1[/video]', $text); - - // Try to Oembed - if ($try_oembed) { - $text = preg_replace("/\[video\](.*?\.(ogg|ogv|oga|ogm|webm|mp4).*?)\[\/video\]/ism", '', $text); - $text = preg_replace("/\[audio\](.*?)\[\/audio\]/ism", '', $text); - - $text = preg_replace_callback("/\[video\](.*?)\[\/video\]/ism", $try_oembed_callback, $text); - $text = preg_replace_callback("/\[audio\](.*?)\[\/audio\]/ism", $try_oembed_callback, $text); - } else { - $text = preg_replace("/\[video\](.*?)\[\/video\]/ism", - '$1', $text); - $text = preg_replace("/\[audio\](.*?)\[\/audio\]/ism", - '$1', $text); - } - - // html5 video and audio - - - if ($try_oembed) { - $text = preg_replace("/\[iframe\](.*?)\[\/iframe\]/ism", '', $text); - } else { - $text = preg_replace("/\[iframe\](.*?)\[\/iframe\]/ism", '$1', $text); - } - - // Youtube extensions - if ($try_oembed) { - $text = preg_replace_callback("/\[youtube\](https?:\/\/www.youtube.com\/watch\?v\=.*?)\[\/youtube\]/ism", $try_oembed_callback, $text); - $text = preg_replace_callback("/\[youtube\](www.youtube.com\/watch\?v\=.*?)\[\/youtube\]/ism", $try_oembed_callback, $text); - $text = preg_replace_callback("/\[youtube\](https?:\/\/youtu.be\/.*?)\[\/youtube\]/ism", $try_oembed_callback, $text); - } - - $text = preg_replace("/\[youtube\]https?:\/\/www.youtube.com\/watch\?v\=(.*?)\[\/youtube\]/ism", '[youtube]$1[/youtube]', $text); - $text = preg_replace("/\[youtube\]https?:\/\/www.youtube.com\/embed\/(.*?)\[\/youtube\]/ism", '[youtube]$1[/youtube]', $text); - $text = preg_replace("/\[youtube\]https?:\/\/youtu.be\/(.*?)\[\/youtube\]/ism", '[youtube]$1[/youtube]', $text); - - if ($try_oembed) { - $text = preg_replace("/\[youtube\]([A-Za-z0-9\-_=]+)(.*?)\[\/youtube\]/ism", '', $text); - } else { - $text = preg_replace("/\[youtube\]([A-Za-z0-9\-_=]+)(.*?)\[\/youtube\]/ism", - 'https://www.youtube.com/watch?v=$1', $text); - } - - if ($try_oembed) { - $text = preg_replace_callback("/\[vimeo\](https?:\/\/player.vimeo.com\/video\/[0-9]+).*?\[\/vimeo\]/ism", $try_oembed_callback, $text); - $text = preg_replace_callback("/\[vimeo\](https?:\/\/vimeo.com\/[0-9]+).*?\[\/vimeo\]/ism", $try_oembed_callback, $text); - } - - $text = preg_replace("/\[vimeo\]https?:\/\/player.vimeo.com\/video\/([0-9]+)(.*?)\[\/vimeo\]/ism", '[vimeo]$1[/vimeo]', $text); - $text = preg_replace("/\[vimeo\]https?:\/\/vimeo.com\/([0-9]+)(.*?)\[\/vimeo\]/ism", '[vimeo]$1[/vimeo]', $text); - - if ($try_oembed) { - $text = preg_replace("/\[vimeo\]([0-9]+)(.*?)\[\/vimeo\]/ism", '', $text); - } else { - $text = preg_replace("/\[vimeo\]([0-9]+)(.*?)\[\/vimeo\]/ism", - 'https://vimeo.com/$1', $text); - } - - // oembed tag - $text = OEmbed::BBCode2HTML($text); - - // Avoid triple linefeeds through oembed - $text = str_replace("


    ", "

    ", $text); - - // If we found an event earlier, strip out all the event code and replace with a reformatted version. - // Replace the event-start section with the entire formatted event. The other bbcode is stripped. - // Summary (e.g. title) is required, earlier revisions only required description (in addition to - // start which is always required). Allow desc with a missing summary for compatibility. - - if ((!empty($ev['desc']) || !empty($ev['summary'])) && !empty($ev['start'])) { - $sub = Event::getHTML($ev, $simple_html); - - $text = preg_replace("/\[event\-summary\](.*?)\[\/event\-summary\]/ism", '', $text); - $text = preg_replace("/\[event\-description\](.*?)\[\/event\-description\]/ism", '', $text); - $text = preg_replace("/\[event\-start\](.*?)\[\/event\-start\]/ism", $sub, $text); - $text = preg_replace("/\[event\-finish\](.*?)\[\/event\-finish\]/ism", '', $text); - $text = preg_replace("/\[event\-location\](.*?)\[\/event\-location\]/ism", '', $text); - $text = preg_replace("/\[event\-adjust\](.*?)\[\/event\-adjust\]/ism", '', $text); - $text = preg_replace("/\[event\-id\](.*?)\[\/event\-id\]/ism", '', $text); - } - - // Replace non graphical smilies for external posts - if (!$nosmile && !$for_plaintext) { - $text = Smilies::replace($text); - } - - if (!$for_plaintext) { - if (in_array($simple_html, [7, 9])) { - $text = preg_replace_callback("/\[url\](.*?)\[\/url\]/ism", 'self::convertUrlForActivityPubCallback', $text); - $text = preg_replace_callback("/\[url\=(.*?)\](.*?)\[\/url\]/ism", 'self::convertUrlForActivityPubCallback', $text); - } - } else { - $text = preg_replace("(\[url\](.*?)\[\/url\])ism", " $1 ", $text); - $text = preg_replace_callback("&\[url=([^\[\]]*)\]\[img\](.*)\[\/img\]\[\/url\]&Usi", 'self::removePictureLinksCallback', $text); - } - - $text = str_replace(["\r","\n"], ['
    ', '
    '], $text); - - // Remove all hashtag addresses - if ($simple_html && !in_array($simple_html, [3, 7, 9])) { - $text = preg_replace("/([#@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", '$1$3', $text); - } elseif ($simple_html == 3) { - // The ! is converted to @ since Diaspora only understands the @ - $text = preg_replace("/([@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", - '@$3', - $text); - } elseif (in_array($simple_html, [7, 9])) { - $text = preg_replace("/([@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", - '$1$3', - $text); - } elseif (!$simple_html) { - $text = preg_replace("/([@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", - '$1$3', - $text); - } - - // Bookmarks in red - will be converted to bookmarks in friendica - $text = preg_replace("/#\^\[url\](.*?)\[\/url\]/ism", '[bookmark=$1]$1[/bookmark]', $text); - $text = preg_replace("/#\^\[url\=(.*?)\](.*?)\[\/url\]/ism", '[bookmark=$1]$2[/bookmark]', $text); - $text = preg_replace("/#\[url\=.*?\]\^\[\/url\]\[url\=(.*?)\](.*?)\[\/url\]/i", - "[bookmark=$1]$2[/bookmark]", $text); - - if (in_array($simple_html, [2, 6, 7, 8])) { - $text = preg_replace_callback("/([^#@!])\[url\=([^\]]*)\](.*?)\[\/url\]/ism", "self::expandLinksCallback", $text); - //$Text = preg_replace("/[^#@!]\[url\=([^\]]*)\](.*?)\[\/url\]/ism", ' $2 [url]$1[/url]', $Text); - $text = preg_replace("/\[bookmark\=([^\]]*)\](.*?)\[\/bookmark\]/ism", ' $2 [url]$1[/url]',$text); - } - - if ($simple_html == 5) { - $text = preg_replace("/[^#@!]\[url\=(.*?)\](.*?)\[\/url\]/ism", '[url]$1[/url]', $text); - } - - // Perform URL Search - if ($try_oembed) { - $text = preg_replace_callback("/\[bookmark\=([^\]]*)\](.*?)\[\/bookmark\]/ism", $try_oembed_callback, $text); - } - - if ($simple_html == 5) { - $text = preg_replace("/\[bookmark\=([^\]]*)\](.*?)\[\/bookmark\]/ism", '[url]$1[/url]', $text); - } else { - $text = preg_replace("/\[bookmark\=([^\]]*)\](.*?)\[\/bookmark\]/ism", '[url=$1]$2[/url]', $text); - } - - // Handle Diaspora posts - $text = preg_replace_callback( - "&\[url=/?posts/([^\[\]]*)\](.*)\[\/url\]&Usi", - function ($match) { - return "[url=" . DI::baseUrl() . "/display/" . $match[1] . "]" . $match[2] . "[/url]"; - }, $text - ); - - $text = preg_replace_callback( - "&\[url=/people\?q\=(.*)\](.*)\[\/url\]&Usi", - function ($match) { - return "[url=" . DI::baseUrl() . "/search?search=%40" . $match[1] . "]" . $match[2] . "[/url]"; - }, $text - ); - - // Server independent link to posts and comments - // See issue: https://github.com/diaspora/diaspora_federation/issues/75 - $expression = "=diaspora://.*?/post/([0-9A-Za-z\-_@.:]{15,254}[0-9A-Za-z])=ism"; - $text = preg_replace($expression, DI::baseUrl()."/display/$1", $text); - - /* Tag conversion - * Supports: - * - #[url=][/url] - * - [url=]#[/url] - */ - $text = preg_replace_callback("/(?:#\[url\=[^\[\]]*\]|\[url\=[^\[\]]*\]#)(.*?)\[\/url\]/ism", function($matches) { - return '#'; - }, $text); - - // We need no target="_blank" rel="noopener noreferrer" for local links - // convert links start with DI::baseUrl() as local link without the target="_blank" rel="noopener noreferrer" attribute - $escapedBaseUrl = preg_quote(DI::baseUrl(), '/'); - $text = preg_replace("/\[url\](".$escapedBaseUrl.".*?)\[\/url\]/ism", '$1', $text); - $text = preg_replace("/\[url\=(".$escapedBaseUrl.".*?)\](.*?)\[\/url\]/ism", '$2', $text); - - $text = preg_replace("/\[url\](.*?)\[\/url\]/ism", '$1', $text); - $text = preg_replace("/\[url\=(.*?)\](.*?)\[\/url\]/ism", '$2', $text); - - // Red compatibility, though the link can't be authenticated on Friendica - $text = preg_replace("/\[zrl\=(.*?)\](.*?)\[\/zrl\]/ism", '$2', $text); - - - // we may need to restrict this further if it picks up too many strays - // link acct:user@host to a webfinger profile redirector - - $text = preg_replace('/acct:([^@]+)@((?!\-)(?:[a-zA-Z\d\-]{0,62}[a-zA-Z\d]\.){1,126}(?!\d+)[a-zA-Z\d]{1,63})/', 'acct:$1@$2', $text); - - // Perform MAIL Search - $text = preg_replace("/\[mail\](.*?)\[\/mail\]/", '$1', $text); - $text = preg_replace("/\[mail\=(.*?)\](.*?)\[\/mail\]/", '$2', $text); - - // Unhide all [noparse] contained bbtags unspacefying them - // and triming the [noparse] tag. - - $text = preg_replace_callback("/\[noparse\](.*?)\[\/noparse\]/ism", 'self::unescapeNoparseCallback', $text); - $text = preg_replace_callback("/\[nobb\](.*?)\[\/nobb\]/ism", 'self::unescapeNoparseCallback', $text); - $text = preg_replace_callback("/\[pre\](.*?)\[\/pre\]/ism", 'self::unescapeNoparseCallback', $text); - - /// @todo What is the meaning of these lines? - $text = preg_replace('/\[\&\;([#a-z0-9]+)\;\]/', '&$1;', $text); - $text = preg_replace('/\&\#039\;/', '\'', $text); - - // Currently deactivated, it made problems with " inside of alt texts. - //$text = preg_replace('/\"\;/', '"', $text); - - // fix any escaped ampersands that may have been converted into links - $text = preg_replace('/\<([^>]*?)(src|href)=(.*?)\&\;(.*?)\>/ism', '<$1$2=$3&$4>', $text); - - // sanitizes src attributes (http and redir URLs for displaying in a web page, cid used for inline images in emails) - $allowed_src_protocols = ['//', 'http://', 'https://', 'redir/', 'cid:']; - - array_walk($allowed_src_protocols, function(&$value) { $value = preg_quote($value, '#');}); - - $text = preg_replace('#<([^>]*?)(src)="(?!' . implode('|', $allowed_src_protocols) . ')(.*?)"(.*?)>#ism', - '<$1$2=""$4 data-original-src="$3" class="invalid-src" title="' . DI::l10n()->t('Invalid source protocol') . '">', $text); - - // sanitize href attributes (only whitelisted protocols URLs) - // default value for backward compatibility - $allowed_link_protocols = DI::config()->get('system', 'allowed_link_protocols', []); - - // Always allowed protocol even if config isn't set or not including it - $allowed_link_protocols[] = '//'; - $allowed_link_protocols[] = 'http://'; - $allowed_link_protocols[] = 'https://'; - $allowed_link_protocols[] = 'redir/'; - - array_walk($allowed_link_protocols, function(&$value) { $value = preg_quote($value, '#');}); - - $regex = '#<([^>]*?)(href)="(?!' . implode('|', $allowed_link_protocols) . ')(.*?)"(.*?)>#ism'; - $text = preg_replace($regex, '<$1$2="javascript:void(0)"$4 data-original-href="$3" class="invalid-href" title="' . DI::l10n()->t('Invalid link protocol') . '">', $text); - - // Shared content - $text = self::convertShare( - $text, - function (array $attributes, array $author_contact, $content, $is_quote_share) use ($simple_html) { - return self::convertShareCallback($attributes, $author_contact, $content, $is_quote_share, $simple_html); - } - ); - - if ($saved_image) { - $text = self::interpolateSavedImagesIntoItemBody($text, $saved_image); - } - - // Restore code blocks - $text = preg_replace_callback('/#codeblock-([0-9]+)#/iU', - function ($matches) use ($codeblocks) { - $return = $matches[0]; - if (isset($codeblocks[intval($matches[1])])) { - $return = $codeblocks[$matches[1]]; - } - return $return; - }, - $text - ); - - // Clean up the HTML by loading and saving the HTML with the DOM. - // Bad structured html can break a whole page. - // For performance reasons do it only with activated item cache or at export. - if (!$try_oembed || (get_itemcachepath() != '')) { - $doc = new DOMDocument(); - $doc->preserveWhiteSpace = false; - - $text = mb_convert_encoding($text, 'HTML-ENTITIES', "UTF-8"); - - $doctype = ''; - $encoding = ''; - @$doc->loadHTML($encoding . $doctype . '' . $text . ''); - $doc->encoding = 'UTF-8'; - $text = $doc->saveHTML(); - $text = str_replace(['', '', $doctype, $encoding], ['', '', '', ''], $text); - - $text = str_replace('
    ', '', $text); - - //$Text = mb_convert_encoding($Text, "UTF-8", 'HTML-ENTITIES'); - } - - // Clean up some useless linebreaks in lists - //$Text = str_replace('

    ', '', $Text); - //$Text = str_replace('
    ', '', $Text); - //$Text = str_replace('
  • ', '
  • ', $Text); - //$Text = str_replace('
    ", ">", $text); + + // remove some newlines before the general conversion + $text = preg_replace("/\s?\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "[share$1]$2[/share]", $text); + $text = preg_replace("/\s?\[quote(.*?)\]\s?(.*?)\s?\[\/quote\]\s?/ism", "[quote$1]$2[/quote]", $text); + + // when the content is meant exporting to other systems then remove the avatar picture since this doesn't really look good on these systems + if (!$try_oembed) { + $text = preg_replace("/\[share(.*?)avatar\s?=\s?'.*?'\s?(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", "\n[share$1$2]$3[/share]", $text); + } + + // Remove linefeeds inside of the table elements. See issue #6799 + $search = ["\n[th]", "[th]\n", " [th]", "\n[/th]", "[/th]\n", "[/th] ", + "\n[td]", "[td]\n", " [td]", "\n[/td]", "[/td]\n", "[/td] ", + "\n[tr]", "[tr]\n", " [tr]", "[tr] ", "\n[/tr]", "[/tr]\n", " [/tr]", "[/tr] ", + "[table]\n", "[table] ", " [table]", "\n[/table]", " [/table]", "[/table] "]; + $replace = ["[th]", "[th]", "[th]", "[/th]", "[/th]", "[/th]", + "[td]", "[td]", "[td]", "[/td]", "[/td]", "[/td]", + "[tr]", "[tr]", "[tr]", "[tr]", "[/tr]", "[/tr]", "[/tr]", "[/tr]", + "[table]", "[table]", "[table]", "[/table]", "[/table]", "[/table]"]; + do { + $oldtext = $text; + $text = str_replace($search, $replace, $text); + } while ($oldtext != $text); + + // Replace these here only once + $search = ["\n[table]", "[/table]\n"]; + $replace = ["[table]", "[/table]"]; + $text = str_replace($search, $replace, $text); + + // Trim new lines regardless of the system.remove_multiplicated_lines config value + $text = trim($text, "\n"); + + // removing multiplicated newlines + if (DI::config()->get('system', 'remove_multiplicated_lines')) { + $search = ["\n\n\n", "\n ", " \n", "[/quote]\n\n", "\n[/quote]", "[/li]\n", "\n[li]", "\n[*]", "\n[ul]", "[/ul]\n", "\n\n[share ", "[/attachment]\n", + "\n[h1]", "[/h1]\n", "\n[h2]", "[/h2]\n", "\n[h3]", "[/h3]\n", "\n[h4]", "[/h4]\n", "\n[h5]", "[/h5]\n", "\n[h6]", "[/h6]\n"]; + $replace = ["\n\n", "\n", "\n", "[/quote]\n", "[/quote]", "[/li]", "[li]", "[*]", "[ul]", "[/ul]", "\n[share ", "[/attachment]", + "[h1]", "[/h1]", "[h2]", "[/h2]", "[h3]", "[/h3]", "[h4]", "[/h4]", "[h5]", "[/h5]", "[h6]", "[/h6]"]; + do { + $oldtext = $text; + $text = str_replace($search, $replace, $text); + } while ($oldtext != $text); + } + + // Add HTML new lines + $text = str_replace("\n", '
    ', $text); + + /// @todo Have a closer look at the different html modes + // Handle attached links or videos + if ($simple_html == self::ACTIVITYPUB) { + $text = self::removeAttachment($text); + } elseif (!in_array($simple_html, [self::INTERNAL, self::CONNECTORS])) { + $text = self::removeAttachment($text, true); + } else { + $text = self::convertAttachment($text, $simple_html, $try_oembed); + } + + $nosmile = strpos($text, '[nosmile]') !== false; + $text = str_replace('[nosmile]', '', $text); + + // Replace non graphical smilies for external posts + if (!$nosmile && !$for_plaintext) { + $text = self::performWithEscapedTags($text, ['img'], function ($text) { + return Smilies::replace($text); + }); + } + + // leave open the posibility of [map=something] + // this is replaced in Item::prepareBody() which has knowledge of the item location + if (strpos($text, '[/map]') !== false) { + $text = preg_replace_callback( + "/\[map\](.*?)\[\/map\]/ism", + function ($match) use ($simple_html) { + return str_replace($match[0], '

    ' . Map::byLocation($match[1], $simple_html) . '

    ', $match[0]); + }, + $text + ); + } + + if (strpos($text, '[map=') !== false) { + $text = preg_replace_callback( + "/\[map=(.*?)\]/ism", + function ($match) use ($simple_html) { + return str_replace($match[0], '

    ' . Map::byCoordinates(str_replace('/', ' ', $match[1]), $simple_html) . '

    ', $match[0]); + }, + $text + ); + } + + if (strpos($text, '[map]') !== false) { + $text = preg_replace("/\[map\]/", '

    ', $text); + } + + // Check for headers + $text = preg_replace("(\[h1\](.*?)\[\/h1\])ism", '

    $1

    ', $text); + $text = preg_replace("(\[h2\](.*?)\[\/h2\])ism", '

    $1

    ', $text); + $text = preg_replace("(\[h3\](.*?)\[\/h3\])ism", '

    $1

    ', $text); + $text = preg_replace("(\[h4\](.*?)\[\/h4\])ism", '

    $1

    ', $text); + $text = preg_replace("(\[h5\](.*?)\[\/h5\])ism", '
    $1
    ', $text); + $text = preg_replace("(\[h6\](.*?)\[\/h6\])ism", '
    $1
    ', $text); + + // Check for paragraph + $text = preg_replace("(\[p\](.*?)\[\/p\])ism", '

    $1

    ', $text); + + // Check for bold text + $text = preg_replace("(\[b\](.*?)\[\/b\])ism", '$1', $text); + + // Check for Italics text + $text = preg_replace("(\[i\](.*?)\[\/i\])ism", '$1', $text); + + // Check for Underline text + $text = preg_replace("(\[u\](.*?)\[\/u\])ism", '$1', $text); + + // Check for strike-through text + $text = preg_replace("(\[s\](.*?)\[\/s\])ism", '$1', $text); + + // Check for over-line text + $text = preg_replace("(\[o\](.*?)\[\/o\])ism", '$1', $text); + + // Check for colored text + $text = preg_replace("(\[color=(.*?)\](.*?)\[\/color\])ism", "$2", $text); + + // Check for sized text + // [size=50] --> font-size: 50px (with the unit). + if ($simple_html != self::DIASPORA) { + $text = preg_replace("(\[size=(\d*?)\](.*?)\[\/size\])ism", '$2', $text); + $text = preg_replace("(\[size=(.*?)\](.*?)\[\/size\])ism", '$2', $text); + } else { + // Issue 2199: Diaspora doesn't interpret the construct above, nor the or element + $text = preg_replace("(\[size=(.*?)\](.*?)\[\/size\])ism", "$2", $text); + } + + + // Check for centered text + $text = preg_replace("(\[center\](.*?)\[\/center\])ism", '
    $1
    ', $text); + + // Check for list text + $text = str_replace("[*]", "
  • ", $text); + + // Check for style sheet commands + $text = preg_replace("(\[style=(.*?)\](.*?)\[\/style\])ism", '$2', $text); + + // Check for CSS classes + $text = preg_replace("(\[class=(.*?)\](.*?)\[\/class\])ism", '$2', $text); + + // handle nested lists + $endlessloop = 0; + + while ((((strpos($text, "[/list]") !== false) && (strpos($text, "[list") !== false)) || + ((strpos($text, "[/ol]") !== false) && (strpos($text, "[ol]") !== false)) || + ((strpos($text, "[/ul]") !== false) && (strpos($text, "[ul]") !== false)) || + ((strpos($text, "[/li]") !== false) && (strpos($text, "[li]") !== false))) && (++$endlessloop < 20)) { + $text = preg_replace("/\[list\](.*?)\[\/list\]/ism", '
      $1
    ', $text); + $text = preg_replace("/\[list=\](.*?)\[\/list\]/ism", '
      $1
    ', $text); + $text = preg_replace("/\[list=1\](.*?)\[\/list\]/ism", '
      $1
    ', $text); + $text = preg_replace("/\[list=((?-i)i)\](.*?)\[\/list\]/ism", '
      $2
    ', $text); + $text = preg_replace("/\[list=((?-i)I)\](.*?)\[\/list\]/ism", '
      $2
    ', $text); + $text = preg_replace("/\[list=((?-i)a)\](.*?)\[\/list\]/ism", '
      $2
    ', $text); + $text = preg_replace("/\[list=((?-i)A)\](.*?)\[\/list\]/ism", '
      $2
    ', $text); + $text = preg_replace("/\[ul\](.*?)\[\/ul\]/ism", '
      $1
    ', $text); + $text = preg_replace("/\[ol\](.*?)\[\/ol\]/ism", '
      $1
    ', $text); + $text = preg_replace("/\[li\](.*?)\[\/li\]/ism", '
  • $1
  • ', $text); + } + + $text = preg_replace("/\[th\](.*?)\[\/th\]/sm", '$1', $text); + $text = preg_replace("/\[td\](.*?)\[\/td\]/sm", '$1', $text); + $text = preg_replace("/\[tr\](.*?)\[\/tr\]/sm", '$1', $text); + $text = preg_replace("/\[table\](.*?)\[\/table\]/sm", '$1
    ', $text); + + $text = preg_replace("/\[table border=1\](.*?)\[\/table\]/sm", '$1
    ', $text); + $text = preg_replace("/\[table border=0\](.*?)\[\/table\]/sm", '$1
    ', $text); + + $text = str_replace('[hr]', '
    ', $text); + + if (!$for_plaintext) { + $text = self::performWithEscapedTags($text, ['url', 'img', 'audio', 'video', 'youtube', 'vimeo', 'share', 'attachment', 'iframe', 'bookmark'], function ($text) { + return preg_replace(Strings::autoLinkRegEx(), '[url]$1[/url]', $text); + }); + } + + // Check for font change text + $text = preg_replace("/\[font=(.*?)\](.*?)\[\/font\]/sm", "$2", $text); + + // Declare the format for [spoiler] layout + $SpoilerLayout = '
    ' . DI::l10n()->t('Click to open/close') . '$1
    '; + + // Check for [spoiler] text + // handle nested quotes + $endlessloop = 0; + while ((strpos($text, "[/spoiler]") !== false) && (strpos($text, "[spoiler]") !== false) && (++$endlessloop < 20)) { + $text = preg_replace("/\[spoiler\](.*?)\[\/spoiler\]/ism", $SpoilerLayout, $text); + } + + // Check for [spoiler=Title] text + + // handle nested quotes + $endlessloop = 0; + while ((strpos($text, "[/spoiler]")!== false) && (strpos($text, "[spoiler=") !== false) && (++$endlessloop < 20)) { + $text = preg_replace("/\[spoiler=[\"\']*(.*?)[\"\']*\](.*?)\[\/spoiler\]/ism", + '
    $1$2
    ', + $text); + } + + // Declare the format for [quote] layout + $QuoteLayout = '
    $1
    '; + + // Check for [quote] text + // handle nested quotes + $endlessloop = 0; + while ((strpos($text, "[/quote]") !== false) && (strpos($text, "[quote]") !== false) && (++$endlessloop < 20)) { + $text = preg_replace("/\[quote\](.*?)\[\/quote\]/ism", "$QuoteLayout", $text); + } + + // Check for [quote=Author] text + + $t_wrote = DI::l10n()->t('$1 wrote:'); + + // handle nested quotes + $endlessloop = 0; + while ((strpos($text, "[/quote]")!== false) && (strpos($text, "[quote=") !== false) && (++$endlessloop < 20)) { + $text = preg_replace("/\[quote=[\"\']*(.*?)[\"\']*\](.*?)\[\/quote\]/ism", + "

    " . $t_wrote . "

    $2
    ", + $text); + } + + + // [img=widthxheight]image source[/img] + $text = preg_replace_callback( + "/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", + function ($matches) use ($simple_html) { + if (strpos($matches[3], "data:image/") === 0) { + return $matches[0]; + } + + $matches[3] = self::proxyUrl($matches[3], $simple_html); + return "[img=" . $matches[1] . "x" . $matches[2] . "]" . $matches[3] . "[/img]"; + }, + $text + ); + + $text = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '', $text); + $text = preg_replace("/\[zmg\=([0-9]*)x([0-9]*)\](.*?)\[\/zmg\]/ism", '', $text); + + $text = preg_replace_callback("/\[img\=(.*?)\](.*?)\[\/img\]/ism", + function ($matches) use ($simple_html) { + $matches[1] = self::proxyUrl($matches[1], $simple_html); + $matches[2] = htmlspecialchars($matches[2], ENT_COMPAT); + return '' . $matches[2] . ''; + }, + $text); + + // Images + // [img]pathtoimage[/img] + $text = preg_replace_callback( + "/\[img\](.*?)\[\/img\]/ism", + function ($matches) use ($simple_html) { + if (strpos($matches[1], "data:image/") === 0) { + return $matches[0]; + } + + $matches[1] = self::proxyUrl($matches[1], $simple_html); + return "[img]" . $matches[1] . "[/img]"; + }, + $text + ); + + $text = preg_replace("/\[img\](.*?)\[\/img\]/ism", '' . DI::l10n()->t('Image/photo') . '', $text); + $text = preg_replace("/\[zmg\](.*?)\[\/zmg\]/ism", '' . DI::l10n()->t('Image/photo') . '', $text); + + $text = preg_replace("/\[crypt\](.*?)\[\/crypt\]/ism", '
    ' . DI::l10n()->t('Encrypted content') . '
    ', $text); + $text = preg_replace("/\[crypt(.*?)\](.*?)\[\/crypt\]/ism", '
    ' . DI::l10n()->t('Encrypted content') . '
    ', $text); + //$Text = preg_replace("/\[crypt=(.*?)\](.*?)\[\/crypt\]/ism", '
    ' . DI::l10n()->t('Encrypted content') . '
    ', $Text); + + // Simplify "video" element + $text = preg_replace('(\[video.*?\ssrc\s?=\s?([^\s\]]+).*?\].*?\[/video\])ism', '[video]$1[/video]', $text); + + if ($try_oembed) { + // html5 video and audio + $text = preg_replace("/\[video\](.*?\.(ogg|ogv|oga|ogm|webm|mp4).*?)\[\/video\]/ism", + '', $text); + $text = preg_replace("/\[video\](.*?)\[\/video\]/ism", + '$1', $text); + $text = preg_replace("/\[audio\](.*?)\[\/audio\]/ism", '', $text); + + $text = preg_replace_callback("/\[video\](.*?)\[\/video\]/ism", $try_oembed_callback, $text); + $text = preg_replace_callback("/\[audio\](.*?)\[\/audio\]/ism", $try_oembed_callback, $text); + } else { + $text = preg_replace("/\[video\](.*?)\[\/video\]/ism", + '$1', $text); + $text = preg_replace("/\[audio\](.*?)\[\/audio\]/ism", + '$1', $text); + } + + // Backward compatibility, [iframe] support has been removed in version 2020.12 + $text = preg_replace("/\[iframe\](.*?)\[\/iframe\]/ism", '$1', $text); + + // Youtube extensions + if ($try_oembed) { + $text = preg_replace_callback("/\[youtube\](https?:\/\/www.youtube.com\/watch\?v\=.*?)\[\/youtube\]/ism", $try_oembed_callback, $text); + $text = preg_replace_callback("/\[youtube\](www.youtube.com\/watch\?v\=.*?)\[\/youtube\]/ism", $try_oembed_callback, $text); + $text = preg_replace_callback("/\[youtube\](https?:\/\/youtu.be\/.*?)\[\/youtube\]/ism", $try_oembed_callback, $text); + } + + $text = preg_replace("/\[youtube\]https?:\/\/www.youtube.com\/watch\?v\=(.*?)\[\/youtube\]/ism", '[youtube]$1[/youtube]', $text); + $text = preg_replace("/\[youtube\]https?:\/\/www.youtube.com\/embed\/(.*?)\[\/youtube\]/ism", '[youtube]$1[/youtube]', $text); + $text = preg_replace("/\[youtube\]https?:\/\/youtu.be\/(.*?)\[\/youtube\]/ism", '[youtube]$1[/youtube]', $text); + + if ($try_oembed) { + $text = preg_replace("/\[youtube\]([A-Za-z0-9\-_=]+)(.*?)\[\/youtube\]/ism", '', $text); + } else { + $text = preg_replace("/\[youtube\]([A-Za-z0-9\-_=]+)(.*?)\[\/youtube\]/ism", + 'https://www.youtube.com/watch?v=$1', $text); + } + + if ($try_oembed) { + $text = preg_replace_callback("/\[vimeo\](https?:\/\/player.vimeo.com\/video\/[0-9]+).*?\[\/vimeo\]/ism", $try_oembed_callback, $text); + $text = preg_replace_callback("/\[vimeo\](https?:\/\/vimeo.com\/[0-9]+).*?\[\/vimeo\]/ism", $try_oembed_callback, $text); + } + + $text = preg_replace("/\[vimeo\]https?:\/\/player.vimeo.com\/video\/([0-9]+)(.*?)\[\/vimeo\]/ism", '[vimeo]$1[/vimeo]', $text); + $text = preg_replace("/\[vimeo\]https?:\/\/vimeo.com\/([0-9]+)(.*?)\[\/vimeo\]/ism", '[vimeo]$1[/vimeo]', $text); + + if ($try_oembed) { + $text = preg_replace("/\[vimeo\]([0-9]+)(.*?)\[\/vimeo\]/ism", '', $text); + } else { + $text = preg_replace("/\[vimeo\]([0-9]+)(.*?)\[\/vimeo\]/ism", + 'https://vimeo.com/$1', $text); + } + + // oembed tag + $text = OEmbed::BBCode2HTML($text); + + // Avoid triple linefeeds through oembed + $text = str_replace("


    ", "

    ", $text); + + // If we found an event earlier, strip out all the event code and replace with a reformatted version. + // Replace the event-start section with the entire formatted event. The other bbcode is stripped. + // Summary (e.g. title) is required, earlier revisions only required description (in addition to + // start which is always required). Allow desc with a missing summary for compatibility. + + if ((!empty($ev['desc']) || !empty($ev['summary'])) && !empty($ev['start'])) { + $sub = Event::getHTML($ev, $simple_html); + + $text = preg_replace("/\[event\-summary\](.*?)\[\/event\-summary\]/ism", '', $text); + $text = preg_replace("/\[event\-description\](.*?)\[\/event\-description\]/ism", '', $text); + $text = preg_replace("/\[event\-start\](.*?)\[\/event\-start\]/ism", $sub, $text); + $text = preg_replace("/\[event\-finish\](.*?)\[\/event\-finish\]/ism", '', $text); + $text = preg_replace("/\[event\-location\](.*?)\[\/event\-location\]/ism", '', $text); + $text = preg_replace("/\[event\-adjust\](.*?)\[\/event\-adjust\]/ism", '', $text); + $text = preg_replace("/\[event\-id\](.*?)\[\/event\-id\]/ism", '', $text); + } + + if (!$for_plaintext && DI::config()->get('system', 'big_emojis') && ($simple_html != self::DIASPORA)) { + $conv = html_entity_decode(str_replace([' ', "\n", "\r"], '', $text)); + // Emojis are always 4 byte Unicode characters + if (!empty($conv) && (strlen($conv) / mb_strlen($conv) == 4)) { + $text = '' . $text . ''; + } + } + + if (!$for_plaintext) { + if (in_array($simple_html, [self::OSTATUS, self::ACTIVITYPUB])) { + $text = preg_replace_callback("/\[url\](.*?)\[\/url\]/ism", 'self::convertUrlForActivityPubCallback', $text); + $text = preg_replace_callback("/\[url\=(.*?)\](.*?)\[\/url\]/ism", 'self::convertUrlForActivityPubCallback', $text); + } + } else { + $text = preg_replace("(\[url\](.*?)\[\/url\])ism", " $1 ", $text); + $text = preg_replace_callback("&\[url=([^\[\]]*)\]\[img\](.*)\[\/img\]\[\/url\]&Usi", 'self::removePictureLinksCallback', $text); + } + + // Remove all hashtag addresses + if ($simple_html && !in_array($simple_html, [self::DIASPORA, self::OSTATUS, self::ACTIVITYPUB])) { + $text = preg_replace("/([#@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", '$1$3', $text); + } elseif ($simple_html == self::DIASPORA) { + // The ! is converted to @ since Diaspora only understands the @ + $text = preg_replace("/([@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", + '@$3', + $text); + } elseif (in_array($simple_html, [self::OSTATUS, self::ACTIVITYPUB])) { + $text = preg_replace("/([@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", + '$1$3', + $text); + } elseif (!$simple_html) { + $text = preg_replace("/([@!])\[url\=(.*?)\](.*?)\[\/url\]/ism", + '$1$3', + $text); + } + + // Bookmarks in red - will be converted to bookmarks in friendica + $text = preg_replace("/#\^\[url\](.*?)\[\/url\]/ism", '[bookmark=$1]$1[/bookmark]', $text); + $text = preg_replace("/#\^\[url\=(.*?)\](.*?)\[\/url\]/ism", '[bookmark=$1]$2[/bookmark]', $text); + $text = preg_replace("/#\[url\=.*?\]\^\[\/url\]\[url\=(.*?)\](.*?)\[\/url\]/i", + "[bookmark=$1]$2[/bookmark]", $text); + + if (in_array($simple_html, [self::API, self::OSTATUS, self::TWITTER])) { + $text = preg_replace_callback("/([^#@!])\[url\=([^\]]*)\](.*?)\[\/url\]/ism", "self::expandLinksCallback", $text); + //$Text = preg_replace("/[^#@!]\[url\=([^\]]*)\](.*?)\[\/url\]/ism", ' $2 [url]$1[/url]', $Text); + $text = preg_replace("/\[bookmark\=([^\]]*)\](.*?)\[\/bookmark\]/ism", ' $2 [url]$1[/url]',$text); + } + + // Perform URL Search + if ($try_oembed) { + $text = preg_replace_callback("/\[bookmark\=([^\]]*)\](.*?)\[\/bookmark\]/ism", $try_oembed_callback, $text); + } + + $text = preg_replace("/\[bookmark\=([^\]]*)\](.*?)\[\/bookmark\]/ism", '[url=$1]$2[/url]', $text); + + // Handle Diaspora posts + $text = preg_replace_callback( + "&\[url=/?posts/([^\[\]]*)\](.*)\[\/url\]&Usi", + function ($match) { + return "[url=" . DI::baseUrl() . "/display/" . $match[1] . "]" . $match[2] . "[/url]"; + }, $text + ); + + $text = preg_replace_callback( + "&\[url=/people\?q\=(.*)\](.*)\[\/url\]&Usi", + function ($match) { + return "[url=" . DI::baseUrl() . "/search?search=%40" . $match[1] . "]" . $match[2] . "[/url]"; + }, $text + ); + + // Server independent link to posts and comments + // See issue: https://github.com/diaspora/diaspora_federation/issues/75 + $expression = "=diaspora://.*?/post/([0-9A-Za-z\-_@.:]{15,254}[0-9A-Za-z])=ism"; + $text = preg_replace($expression, DI::baseUrl()."/display/$1", $text); + + /* Tag conversion + * Supports: + * - #[url=][/url] + * - [url=]#[/url] + */ + $text = preg_replace_callback("/(?:#\[url\=[^\[\]]*\]|\[url\=[^\[\]]*\]#)(.*?)\[\/url\]/ism", function($matches) use ($simple_html) { + if ($simple_html == BBCode::ACTIVITYPUB) { + return '#' + . XML::escape($matches[1]) . ''; + } else { + return '#'; + } + }, $text); + + // We need no target="_blank" rel="noopener noreferrer" for local links + // convert links start with DI::baseUrl() as local link without the target="_blank" rel="noopener noreferrer" attribute + $escapedBaseUrl = preg_quote(DI::baseUrl(), '/'); + $text = preg_replace("/\[url\](".$escapedBaseUrl.".*?)\[\/url\]/ism", '$1', $text); + $text = preg_replace("/\[url\=(".$escapedBaseUrl.".*?)\](.*?)\[\/url\]/ism", '$2', $text); + + $text = preg_replace("/\[url\](.*?)\[\/url\]/ism", '$1', $text); + $text = preg_replace("/\[url\=(.*?)\](.*?)\[\/url\]/ism", '$2', $text); + + // Red compatibility, though the link can't be authenticated on Friendica + $text = preg_replace("/\[zrl\=(.*?)\](.*?)\[\/zrl\]/ism", '$2', $text); + + + // we may need to restrict this further if it picks up too many strays + // link acct:user@host to a webfinger profile redirector + + $text = preg_replace('/acct:([^@]+)@((?!\-)(?:[a-zA-Z\d\-]{0,62}[a-zA-Z\d]\.){1,126}(?!\d+)[a-zA-Z\d]{1,63})/', 'acct:$1@$2', $text); + + // Perform MAIL Search + $text = preg_replace("/\[mail\](.*?)\[\/mail\]/", '$1', $text); + $text = preg_replace("/\[mail\=(.*?)\](.*?)\[\/mail\]/", '$2', $text); + + /// @todo What is the meaning of these lines? + $text = preg_replace('/\[\&\;([#a-z0-9]+)\;\]/', '&$1;', $text); + $text = preg_replace('/\&\#039\;/', '\'', $text); + + // Currently deactivated, it made problems with " inside of alt texts. + //$text = preg_replace('/\"\;/', '"', $text); + + // fix any escaped ampersands that may have been converted into links + $text = preg_replace('/\<([^>]*?)(src|href)=(.*?)\&\;(.*?)\>/ism', '<$1$2=$3&$4>', $text); + + // sanitizes src attributes (http and redir URLs for displaying in a web page, cid used for inline images in emails) + $allowed_src_protocols = ['//', 'http://', 'https://', 'redir/', 'cid:']; + + array_walk($allowed_src_protocols, function(&$value) { $value = preg_quote($value, '#');}); + + $text = preg_replace('#<([^>]*?)(src)="(?!' . implode('|', $allowed_src_protocols) . ')(.*?)"(.*?)>#ism', + '<$1$2=""$4 data-original-src="$3" class="invalid-src" title="' . DI::l10n()->t('Invalid source protocol') . '">', $text); + + // sanitize href attributes (only allowlisted protocols URLs) + // default value for backward compatibility + $allowed_link_protocols = DI::config()->get('system', 'allowed_link_protocols', []); + + // Always allowed protocol even if config isn't set or not including it + $allowed_link_protocols[] = '//'; + $allowed_link_protocols[] = 'http://'; + $allowed_link_protocols[] = 'https://'; + $allowed_link_protocols[] = 'redir/'; + + array_walk($allowed_link_protocols, function(&$value) { $value = preg_quote($value, '#');}); + + $regex = '#<([^>]*?)(href)="(?!' . implode('|', $allowed_link_protocols) . ')(.*?)"(.*?)>#ism'; + $text = preg_replace($regex, '<$1$2="javascript:void(0)"$4 data-original-href="$3" class="invalid-href" title="' . DI::l10n()->t('Invalid link protocol') . '">', $text); + + // Shared content + $text = self::convertShare( + $text, + function (array $attributes, array $author_contact, $content, $is_quote_share) use ($simple_html) { + return self::convertShareCallback($attributes, $author_contact, $content, $is_quote_share, $simple_html); + } + ); + + $text = self::interpolateSavedImagesIntoItemBody($text, $saved_image); + + return $text; + }); // Escaped noparse, nobb, pre + + // Remove escaping tags and replace new lines that remain + $text = preg_replace_callback('/\[(noparse|nobb)](.*?)\[\/\1]/ism', function ($match) { + return str_replace("\n", "
    ", $match[2]); + }, $text); + + // Additionally, [pre] tags preserve spaces + $text = preg_replace_callback("/\[pre\](.*?)\[\/pre\]/ism", function ($match) { + return str_replace([' ', "\n"], [' ', "
    "], htmlentities($match[1], ENT_NOQUOTES,'UTF-8')); + }, $text); + + return $text; + }); // Escaped code + + $text = preg_replace_callback("#\[code(?:=([^\]]*))?\](.*?)\[\/code\]#ism", + function ($matches) { + if (strpos($matches[2], "\n") !== false) { + $return = '
    ' . htmlentities(trim($matches[2], "\n\r"), ENT_NOQUOTES, 'UTF-8') . '
    '; + } else { + $return = '' . htmlentities($matches[2], ENT_NOQUOTES, 'UTF-8') . ''; + } + + return $return; + }, + $text + ); + + $config = \HTMLPurifier_HTML5Config::createDefault(); + $config->set('HTML.Doctype', 'HTML5'); + $config->set('HTML.SafeIframe', true); + $config->set('URI.SafeIframeRegexp', '%^(?: + https://www.youtube.com/embed/ + | + https://player.vimeo.com/video/ + | + ' . DI::baseUrl() . '/oembed/ # Has to change with the source in Content\Oembed::iframe + )%xi'); + $config->set('Attr.AllowedRel', [ + 'noreferrer' => true, + 'noopener' => true, + ]); + $config->set('Attr.AllowedFrameTargets', [ + '_blank' => true, + ]); + + $HTMLPurifier = new \HTMLPurifier($config); + $text = $HTMLPurifier->purify($text); + + return $text; } /** @@ -1990,12 +1955,7 @@ class BBCode */ private static function bbCodeMention2DiasporaCallback($match) { - $contact = Contact::getDetailsByURL($match[3]); - - if (empty($contact['addr'])) { - $contact = Probe::uri($match[3]); - } - + $contact = Contact::getByURL($match[3], false, ['addr']); if (empty($contact['addr'])) { return $match[0]; } @@ -2040,7 +2000,7 @@ class BBCode // Convert it to HTML - don't try oembed if ($for_diaspora) { - $text = self::convert($text, false, 3); + $text = self::convert($text, false, self::DIASPORA); // Add all tags that maybe were removed if (preg_match_all("/#\[url\=([$url_search_string]*)\](.*?)\[\/url\]/ism", $original_text, $tags)) { @@ -2054,7 +2014,7 @@ class BBCode $text = $text . " " . $tagline; } } else { - $text = self::convert($text, false, 4); + $text = self::convert($text, false, self::CONNECTORS); } // If a link is followed by a quote then there should be a newline before it @@ -2066,7 +2026,7 @@ class BBCode // Now convert HTML to Markdown $text = HTML::toMarkdown($text); - DI::profiler()->saveTimestamp($stamp1, "parser", System::callstack()); + DI::profiler()->saveTimestamp($stamp1, "parser"); // Libertree has a problem with escaped hashtags. $text = str_replace(['\#'], ['#'], $text); @@ -2106,63 +2066,152 @@ class BBCode { $ret = []; - // Convert hashtag links to hashtags - $string = preg_replace('/#\[url\=([^\[\]]*)\](.*?)\[\/url\]/ism', '#$2 ', $string); + BBCode::performWithEscapedTags($string, ['noparse', 'pre', 'code'], function ($string) use (&$ret) { + // Convert hashtag links to hashtags + $string = preg_replace('/#\[url\=([^\[\]]*)\](.*?)\[\/url\]/ism', '#$2 ', $string); - // ignore anything in a code block - $string = preg_replace('/\[code.*?\].*?\[\/code\]/sm', '', $string); + // Force line feeds at bbtags + $string = str_replace(['[', ']'], ["\n[", "]\n"], $string); - // Force line feeds at bbtags - $string = str_replace(['[', ']'], ["\n[", "]\n"], $string); + // ignore anything in a bbtag + $string = preg_replace('/\[(.*?)\]/sm', '', $string); - // ignore anything in a bbtag - $string = preg_replace('/\[(.*?)\]/sm', '', $string); + // Match full names against @tags including the space between first and last + // We will look these up afterward to see if they are full names or not recognisable. - // Match full names against @tags including the space between first and last - // We will look these up afterward to see if they are full names or not recognisable. + if (preg_match_all('/(@[^ \x0D\x0A,:?]+ [^ \x0D\x0A@,:?]+)([ \x0D\x0A@,:?]|$)/', $string, $matches)) { + foreach ($matches[1] as $match) { + if (strstr($match, ']')) { + // we might be inside a bbcode color tag - leave it alone + continue; + } - if (preg_match_all('/(@[^ \x0D\x0A,:?]+ [^ \x0D\x0A@,:?]+)([ \x0D\x0A@,:?]|$)/', $string, $matches)) { - foreach ($matches[1] as $match) { - if (strstr($match, ']')) { - // we might be inside a bbcode color tag - leave it alone - continue; + if (substr($match, -1, 1) === '.') { + $ret[] = substr($match, 0, -1); + } else { + $ret[] = $match; + } } + } + + // Otherwise pull out single word tags. These can be @nickname, @first_last + // and #hash tags. + + if (preg_match_all('/([!#@][^\^ \x0D\x0A,;:?\']*[^\^ \x0D\x0A,;:?!\'.])/', $string, $matches)) { + foreach ($matches[1] as $match) { + if (strstr($match, ']')) { + // we might be inside a bbcode color tag - leave it alone + continue; + } + + // ignore strictly numeric tags like #1 + if ((strpos($match, '#') === 0) && ctype_digit(substr($match, 1))) { + continue; + } + + // try not to catch url fragments + if (strpos($string, $match) && preg_match('/[a-zA-z0-9\/]/', substr($string, strpos($string, $match) - 1, 1))) { + continue; + } - if (substr($match, -1, 1) === '.') { - $ret[] = substr($match, 0, -1); - } else { $ret[] = $match; } } - } + }); - // Otherwise pull out single word tags. These can be @nickname, @first_last - // and #hash tags. + return array_unique($ret); + } - if (preg_match_all('/([!#@][^\^ \x0D\x0A,;:?]+)([ \x0D\x0A,;:?]|$)/', $string, $matches)) { - foreach ($matches[1] as $match) { - if (strstr($match, ']')) { - // we might be inside a bbcode color tag - leave it alone + /** + * Perform a custom function on a text after having escaped blocks enclosed in the provided tag list. + * + * @param string $text + * @param array $tagList A list of tag names, e.g ['noparse', 'nobb', 'pre'] + * @param callable $callback + * @return string + * @throws Exception + *@see Strings::performWithEscapedBlocks + * + */ + public static function performWithEscapedTags(string $text, array $tagList, callable $callback) + { + $tagList = array_map('preg_quote', $tagList); + + return Strings::performWithEscapedBlocks($text, '#\[(?:' . implode('|', $tagList) . ').*?\[/(?:' . implode('|', $tagList) . ')]#ism', $callback); + } + + /** + * Replaces mentions in the provided message body for the provided user and network if any + * + * @param $body + * @param $profile_uid + * @param $network + * @return string + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public static function setMentions($body, $profile_uid = 0, $network = '') + { + BBCode::performWithEscapedTags($body, ['noparse', 'pre', 'code', 'img'], function ($body) use ($profile_uid, $network) { + $tags = BBCode::getTags($body); + + $tagged = []; + $inform = ''; + + foreach ($tags as $tag) { + $tag_type = substr($tag, 0, 1); + + if ($tag_type == Tag::TAG_CHARACTER[Tag::HASHTAG]) { continue; } - if (substr($match, -1, 1) === '.') { - $match = substr($match,0,-1); + /* + * If we already tagged 'Robert Johnson', don't try and tag 'Robert'. + * Robert Johnson should be first in the $tags array + */ + foreach ($tagged as $nextTag) { + if (stristr($nextTag, $tag . ' ')) { + continue 2; + } } - // ignore strictly numeric tags like #1 - if ((strpos($match, '#') === 0) && ctype_digit(substr($match, 1))) { - continue; - } + $success = Item::replaceTag($body, $inform, $profile_uid, $tag, $network); - // try not to catch url fragments - if (strpos($string, $match) && preg_match('/[a-zA-z0-9\/]/', substr($string, strpos($string, $match) - 1, 1))) { - continue; + if ($success['replaced']) { + $tagged[] = $tag; } - $ret[] = $match; } + + return $body; + }); + + return $body; + } + + /** + * @param string $author Author display name + * @param string $profile Author profile URL + * @param string $avatar Author profile picture URL + * @param string $link Post source URL + * @param string $posted Post created date + * @param string|null $guid Post guid (if any) + * @return string + * @TODO Rewrite to handle over whole record array + */ + public static function getShareOpeningTag(string $author, string $profile, string $avatar, string $link, string $posted, string $guid = null) + { + $header = "[share author='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $author) . + "' profile='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $profile) . + "' avatar='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $avatar) . + "' link='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $link) . + "' posted='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $posted); + + if ($guid) { + $header .= "' guid='" . str_replace(["'", "[", "]"], ["'", "[", "]"], $guid); } - return $ret; + $header .= "']"; + + return $header; } } diff --git a/src/Content/Text/HTML.php b/src/Content/Text/HTML.php index 593be7d5f9..c3c43822ab 100644 --- a/src/Content/Text/HTML.php +++ b/src/Content/Text/HTML.php @@ -26,37 +26,16 @@ use DOMXPath; use Friendica\Content\Widget\ContactBlock; use Friendica\Core\Hook; use Friendica\Core\Renderer; +use Friendica\Core\Search; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Util\Network; -use Friendica\Util\Proxy as ProxyUtils; use Friendica\Util\Strings; use Friendica\Util\XML; use League\HTMLToMarkdown\HtmlConverter; class HTML { - public static function sanitizeCSS($input) - { - $cleaned = ""; - - $input = strtolower($input); - - for ($i = 0; $i < strlen($input); $i++) { - $char = substr($input, $i, 1); - - if (($char >= "a") && ($char <= "z")) { - $cleaned .= $char; - } - - if (!(strpos(" #;:0123456789-_.%", $char) === false)) { - $cleaned .= $char; - } - } - - return $cleaned; - } - /** * Search all instances of a specific HTML tag node in the provided DOM document and replaces them with BBCode text nodes. * @@ -166,252 +145,243 @@ class HTML { $message = str_replace("\r", "", $message); - // Removing code blocks before the whitespace removal processing below - $codeblocks = []; + $message = Strings::performWithEscapedBlocks($message, '#
    #iUs', function ($message) { + $message = str_replace( + [ + "
  • ", + "

  • ", + ], + [ + "
  • ", + "
  • ", + ], + $message + ); + + // remove namespaces + $message = preg_replace('=<(\w+):(.+?)>=', '', $message); + $message = preg_replace('==', '', $message); + + $doc = new DOMDocument(); + $doc->preserveWhiteSpace = false; + + $message = mb_convert_encoding($message, 'HTML-ENTITIES', "UTF-8"); + + @$doc->loadHTML($message, LIBXML_HTML_NODEFDTD); + + XML::deleteNode($doc, 'style'); + XML::deleteNode($doc, 'head'); + XML::deleteNode($doc, 'title'); + XML::deleteNode($doc, 'meta'); + XML::deleteNode($doc, 'xml'); + XML::deleteNode($doc, 'removeme'); + + $xpath = new DomXPath($doc); + $list = $xpath->query("//pre"); + foreach ($list as $node) { + // Ensure to escape unescaped & - they will otherwise raise a warning + $safe_value = preg_replace('/&(?!\w+;)/', '&', $node->nodeValue); + $node->nodeValue = str_replace("\n", "\r", $safe_value); + } + + $message = $doc->saveHTML(); + $message = str_replace(["\n<", ">\n", "\r", "\n", "\xC3\x82\xC2\xA0"], ["<", ">", "
    ", " ", ""], $message); + $message = preg_replace('= [\s]*=i', " ", $message); + + if (empty($message)) { + return ''; + } + + @$doc->loadHTML($message, LIBXML_HTML_NODEFDTD); + + self::tagToBBCode($doc, 'html', [], "", ""); + self::tagToBBCode($doc, 'body', [], "", ""); + + // Outlook-Quote - Variant 1 + self::tagToBBCode($doc, 'p', ['class' => 'MsoNormal', 'style' => 'margin-left:35.4pt'], '[quote]', '[/quote]'); + + // Outlook-Quote - Variant 2 + self::tagToBBCode( + $doc, + 'div', + ['style' => 'border:none;border-left:solid blue 1.5pt;padding:0cm 0cm 0cm 4.0pt'], + '[quote]', + '[/quote]' + ); + + // MyBB-Stuff + self::tagToBBCode($doc, 'span', ['style' => 'text-decoration: underline;'], '[u]', '[/u]'); + self::tagToBBCode($doc, 'span', ['style' => 'font-style: italic;'], '[i]', '[/i]'); + self::tagToBBCode($doc, 'span', ['style' => 'font-weight: bold;'], '[b]', '[/b]'); + + /* self::node2BBCode($doc, 'font', array('face'=>'/([\w ]+)/', 'size'=>'/(\d+)/', 'color'=>'/(.+)/'), '[font=$1][size=$2][color=$3]', '[/color][/size][/font]'); + self::node2BBCode($doc, 'font', array('size'=>'/(\d+)/', 'color'=>'/(.+)/'), '[size=$1][color=$2]', '[/color][/size]'); + self::node2BBCode($doc, 'font', array('face'=>'/([\w ]+)/', 'size'=>'/(.+)/'), '[font=$1][size=$2]', '[/size][/font]'); + self::node2BBCode($doc, 'font', array('face'=>'/([\w ]+)/', 'color'=>'/(.+)/'), '[font=$1][color=$3]', '[/color][/font]'); + self::node2BBCode($doc, 'font', array('face'=>'/([\w ]+)/'), '[font=$1]', '[/font]'); + self::node2BBCode($doc, 'font', array('size'=>'/(\d+)/'), '[size=$1]', '[/size]'); + self::node2BBCode($doc, 'font', array('color'=>'/(.+)/'), '[color=$1]', '[/color]'); + */ + // Untested + //self::node2BBCode($doc, 'span', array('style'=>'/.*font-size:\s*(.+?)[,;].*font-family:\s*(.+?)[,;].*color:\s*(.+?)[,;].*/'), '[size=$1][font=$2][color=$3]', '[/color][/font][/size]'); + //self::node2BBCode($doc, 'span', array('style'=>'/.*font-size:\s*(\d+)[,;].*/'), '[size=$1]', '[/size]'); + //self::node2BBCode($doc, 'span', array('style'=>'/.*font-size:\s*(.+?)[,;].*/'), '[size=$1]', '[/size]'); + + self::tagToBBCode($doc, 'span', ['style' => '/.*color:\s*(.+?)[,;].*/'], '[color="$1"]', '[/color]'); + + //self::node2BBCode($doc, 'span', array('style'=>'/.*font-family:\s*(.+?)[,;].*/'), '[font=$1]', '[/font]'); + //self::node2BBCode($doc, 'div', array('style'=>'/.*font-family:\s*(.+?)[,;].*font-size:\s*(\d+?)pt.*/'), '[font=$1][size=$2]', '[/size][/font]'); + //self::node2BBCode($doc, 'div', array('style'=>'/.*font-family:\s*(.+?)[,;].*font-size:\s*(\d+?)px.*/'), '[font=$1][size=$2]', '[/size][/font]'); + //self::node2BBCode($doc, 'div', array('style'=>'/.*font-family:\s*(.+?)[,;].*/'), '[font=$1]', '[/font]'); + // Importing the classes - interesting for importing of posts from third party networks that were exported from friendica + // Test + //self::node2BBCode($doc, 'span', array('class'=>'/([\w ]+)/'), '[class=$1]', '[/class]'); + self::tagToBBCode($doc, 'span', ['class' => 'type-link'], '[class=type-link]', '[/class]'); + self::tagToBBCode($doc, 'span', ['class' => 'type-video'], '[class=type-video]', '[/class]'); + + self::tagToBBCode($doc, 'strong', [], '[b]', '[/b]'); + self::tagToBBCode($doc, 'em', [], '[i]', '[/i]'); + self::tagToBBCode($doc, 'b', [], '[b]', '[/b]'); + self::tagToBBCode($doc, 'i', [], '[i]', '[/i]'); + self::tagToBBCode($doc, 'u', [], '[u]', '[/u]'); + self::tagToBBCode($doc, 's', [], '[s]', '[/s]'); + self::tagToBBCode($doc, 'del', [], '[s]', '[/s]'); + self::tagToBBCode($doc, 'strike', [], '[s]', '[/s]'); + + self::tagToBBCode($doc, 'big', [], "[size=large]", "[/size]"); + self::tagToBBCode($doc, 'small', [], "[size=small]", "[/size]"); + + self::tagToBBCode($doc, 'blockquote', [], '[quote]', '[/quote]'); + + self::tagToBBCode($doc, 'br', [], "\n", ''); + + self::tagToBBCode($doc, 'p', ['class' => 'MsoNormal'], "\n", ""); + self::tagToBBCode($doc, 'div', ['class' => 'MsoNormal'], "\r", ""); + + self::tagToBBCode($doc, 'span', [], "", ""); + + self::tagToBBCode($doc, 'span', [], "", ""); + self::tagToBBCode($doc, 'pre', [], "", ""); + + self::tagToBBCode($doc, 'div', [], "\r", "\r"); + self::tagToBBCode($doc, 'p', [], "\n", "\n"); + + self::tagToBBCode($doc, 'ul', [], "[list]", "[/list]"); + self::tagToBBCode($doc, 'ol', [], "[list=1]", "[/list]"); + self::tagToBBCode($doc, 'li', [], "[*]", ""); + + self::tagToBBCode($doc, 'hr', [], "[hr]", ""); + + self::tagToBBCode($doc, 'table', [], "[table]", "[/table]"); + self::tagToBBCode($doc, 'th', [], "[th]", "[/th]"); + self::tagToBBCode($doc, 'tr', [], "[tr]", "[/tr]"); + self::tagToBBCode($doc, 'td', [], "[td]", "[/td]"); + + self::tagToBBCode($doc, 'h1', [], "[h1]", "[/h1]"); + self::tagToBBCode($doc, 'h2', [], "[h2]", "[/h2]"); + self::tagToBBCode($doc, 'h3', [], "[h3]", "[/h3]"); + self::tagToBBCode($doc, 'h4', [], "[h4]", "[/h4]"); + self::tagToBBCode($doc, 'h5', [], "[h5]", "[/h5]"); + self::tagToBBCode($doc, 'h6', [], "[h6]", "[/h6]"); + + self::tagToBBCode($doc, 'a', ['href' => '/mailto:(.+)/'], '[mail=$1]', '[/mail]'); + self::tagToBBCode($doc, 'a', ['href' => '/(.+)/'], '[url=$1]', '[/url]'); + + self::tagToBBCode($doc, 'img', ['src' => '/(.+)/', 'alt' => '/(.+)/'], '[img=$1]$2', '[/img]', true); + self::tagToBBCode($doc, 'img', ['src' => '/(.+)/', 'width' => '/(\d+)/', 'height' => '/(\d+)/'], '[img=$2x$3]$1', '[/img]', true); + self::tagToBBCode($doc, 'img', ['src' => '/(.+)/'], '[img]$1', '[/img]', true); + + + self::tagToBBCode($doc, 'video', ['src' => '/(.+)/'], '[video]$1', '[/video]', true); + self::tagToBBCode($doc, 'audio', ['src' => '/(.+)/'], '[audio]$1', '[/audio]', true); + // Backward compatibility, [iframe] support has been removed in version 2020.12 + self::tagToBBCode($doc, 'iframe', ['src' => '/(.+)/'], '[url]$1', '[/url]', true); + + self::tagToBBCode($doc, 'key', [], '[code]', '[/code]'); + self::tagToBBCode($doc, 'code', [], '[code]', '[/code]'); + + $message = $doc->saveHTML(); + + // I'm removing something really disturbing + // Don't know exactly what it is + $message = str_replace(chr(194) . chr(160), ' ', $message); + + $message = str_replace(" ", " ", $message); + + // removing multiple DIVs + $message = preg_replace('=\r *\r=i', "\n", $message); + $message = str_replace("\r", "\n", $message); + + Hook::callAll('html2bbcode', $message); + + $message = strip_tags($message); + + $message = html_entity_decode($message, ENT_QUOTES, 'UTF-8'); + + // remove quotes if they don't make sense + $message = preg_replace('=\[/quote\][\s]*\[quote\]=i', "\n", $message); + + $message = preg_replace('=\[quote\]\s*=i', "[quote]", $message); + $message = preg_replace('=\s*\[/quote\]=i', "[/quote]", $message); + + do { + $oldmessage = $message; + $message = str_replace("\n \n", "\n\n", $message); + } while ($oldmessage != $message); + + do { + $oldmessage = $message; + $message = str_replace("\n\n\n", "\n\n", $message); + } while ($oldmessage != $message); + + do { + $oldmessage = $message; + $message = str_replace( + [ + "[/size]\n\n", + "\n[hr]", + "[hr]\n", + "\n[list", + "[/list]\n", + "\n[/", + "[list]\n", + "[list=1]\n", + "\n[*]"], + [ + "[/size]\n", + "[hr]", + "[hr]", + "[list", + "[/list]", + "[/", + "[list]", + "[list=1]", + "[*]"], + $message + ); + } while ($message != $oldmessage); + + $message = str_replace( + ['[b][b]', '[/b][/b]', '[i][i]', '[/i][/i]'], + ['[b]', '[/b]', '[i]', '[/i]'], + $message + ); + + // Handling Yahoo style of mails + $message = str_replace('[hr][b]From:[/b]', '[quote][b]From:[/b]', $message); + + return $message; + }); + $message = preg_replace_callback( '#
    (.*)
    #iUs', - function ($matches) use (&$codeblocks) { - $return = '[codeblock-' . count($codeblocks) . ']'; - + function ($matches) { $prefix = '[code]'; if ($matches[1] != '') { $prefix = '[code=' . $matches[1] . ']'; } - $codeblocks[] = $prefix . PHP_EOL . trim($matches[2]) . PHP_EOL . '[/code]'; - return $return; - }, - $message - ); - - $message = str_replace( - [ - "
  • ", - "

  • ", - ], - [ - "
  • ", - "
  • ", - ], - $message - ); - - // remove namespaces - $message = preg_replace('=<(\w+):(.+?)>=', '', $message); - $message = preg_replace('==', '', $message); - - $doc = new DOMDocument(); - $doc->preserveWhiteSpace = false; - - $message = mb_convert_encoding($message, 'HTML-ENTITIES', "UTF-8"); - - @$doc->loadHTML($message, LIBXML_HTML_NODEFDTD); - - XML::deleteNode($doc, 'style'); - XML::deleteNode($doc, 'head'); - XML::deleteNode($doc, 'title'); - XML::deleteNode($doc, 'meta'); - XML::deleteNode($doc, 'xml'); - XML::deleteNode($doc, 'removeme'); - - $xpath = new DomXPath($doc); - $list = $xpath->query("//pre"); - foreach ($list as $node) { - // Ensure to escape unescaped & - they will otherwise raise a warning - $safe_value = preg_replace('/&(?!\w+;)/', '&', $node->nodeValue); - $node->nodeValue = str_replace("\n", "\r", $safe_value); - } - - $message = $doc->saveHTML(); - $message = str_replace(["\n<", ">\n", "\r", "\n", "\xC3\x82\xC2\xA0"], ["<", ">", "
    ", " ", ""], $message); - $message = preg_replace('= [\s]*=i', " ", $message); - - @$doc->loadHTML($message, LIBXML_HTML_NODEFDTD); - - self::tagToBBCode($doc, 'html', [], "", ""); - self::tagToBBCode($doc, 'body', [], "", ""); - - // Outlook-Quote - Variant 1 - self::tagToBBCode($doc, 'p', ['class' => 'MsoNormal', 'style' => 'margin-left:35.4pt'], '[quote]', '[/quote]'); - - // Outlook-Quote - Variant 2 - self::tagToBBCode( - $doc, - 'div', - ['style' => 'border:none;border-left:solid blue 1.5pt;padding:0cm 0cm 0cm 4.0pt'], - '[quote]', - '[/quote]' - ); - - // MyBB-Stuff - self::tagToBBCode($doc, 'span', ['style' => 'text-decoration: underline;'], '[u]', '[/u]'); - self::tagToBBCode($doc, 'span', ['style' => 'font-style: italic;'], '[i]', '[/i]'); - self::tagToBBCode($doc, 'span', ['style' => 'font-weight: bold;'], '[b]', '[/b]'); - - /* self::node2BBCode($doc, 'font', array('face'=>'/([\w ]+)/', 'size'=>'/(\d+)/', 'color'=>'/(.+)/'), '[font=$1][size=$2][color=$3]', '[/color][/size][/font]'); - self::node2BBCode($doc, 'font', array('size'=>'/(\d+)/', 'color'=>'/(.+)/'), '[size=$1][color=$2]', '[/color][/size]'); - self::node2BBCode($doc, 'font', array('face'=>'/([\w ]+)/', 'size'=>'/(.+)/'), '[font=$1][size=$2]', '[/size][/font]'); - self::node2BBCode($doc, 'font', array('face'=>'/([\w ]+)/', 'color'=>'/(.+)/'), '[font=$1][color=$3]', '[/color][/font]'); - self::node2BBCode($doc, 'font', array('face'=>'/([\w ]+)/'), '[font=$1]', '[/font]'); - self::node2BBCode($doc, 'font', array('size'=>'/(\d+)/'), '[size=$1]', '[/size]'); - self::node2BBCode($doc, 'font', array('color'=>'/(.+)/'), '[color=$1]', '[/color]'); - */ - // Untested - //self::node2BBCode($doc, 'span', array('style'=>'/.*font-size:\s*(.+?)[,;].*font-family:\s*(.+?)[,;].*color:\s*(.+?)[,;].*/'), '[size=$1][font=$2][color=$3]', '[/color][/font][/size]'); - //self::node2BBCode($doc, 'span', array('style'=>'/.*font-size:\s*(\d+)[,;].*/'), '[size=$1]', '[/size]'); - //self::node2BBCode($doc, 'span', array('style'=>'/.*font-size:\s*(.+?)[,;].*/'), '[size=$1]', '[/size]'); - - self::tagToBBCode($doc, 'span', ['style' => '/.*color:\s*(.+?)[,;].*/'], '[color="$1"]', '[/color]'); - - //self::node2BBCode($doc, 'span', array('style'=>'/.*font-family:\s*(.+?)[,;].*/'), '[font=$1]', '[/font]'); - //self::node2BBCode($doc, 'div', array('style'=>'/.*font-family:\s*(.+?)[,;].*font-size:\s*(\d+?)pt.*/'), '[font=$1][size=$2]', '[/size][/font]'); - //self::node2BBCode($doc, 'div', array('style'=>'/.*font-family:\s*(.+?)[,;].*font-size:\s*(\d+?)px.*/'), '[font=$1][size=$2]', '[/size][/font]'); - //self::node2BBCode($doc, 'div', array('style'=>'/.*font-family:\s*(.+?)[,;].*/'), '[font=$1]', '[/font]'); - // Importing the classes - interesting for importing of posts from third party networks that were exported from friendica - // Test - //self::node2BBCode($doc, 'span', array('class'=>'/([\w ]+)/'), '[class=$1]', '[/class]'); - self::tagToBBCode($doc, 'span', ['class' => 'type-link'], '[class=type-link]', '[/class]'); - self::tagToBBCode($doc, 'span', ['class' => 'type-video'], '[class=type-video]', '[/class]'); - - self::tagToBBCode($doc, 'strong', [], '[b]', '[/b]'); - self::tagToBBCode($doc, 'em', [], '[i]', '[/i]'); - self::tagToBBCode($doc, 'b', [], '[b]', '[/b]'); - self::tagToBBCode($doc, 'i', [], '[i]', '[/i]'); - self::tagToBBCode($doc, 'u', [], '[u]', '[/u]'); - self::tagToBBCode($doc, 's', [], '[s]', '[/s]'); - self::tagToBBCode($doc, 'del', [], '[s]', '[/s]'); - self::tagToBBCode($doc, 'strike', [], '[s]', '[/s]'); - - self::tagToBBCode($doc, 'big', [], "[size=large]", "[/size]"); - self::tagToBBCode($doc, 'small', [], "[size=small]", "[/size]"); - - self::tagToBBCode($doc, 'blockquote', [], '[quote]', '[/quote]'); - - self::tagToBBCode($doc, 'br', [], "\n", ''); - - self::tagToBBCode($doc, 'p', ['class' => 'MsoNormal'], "\n", ""); - self::tagToBBCode($doc, 'div', ['class' => 'MsoNormal'], "\r", ""); - - self::tagToBBCode($doc, 'span', [], "", ""); - - self::tagToBBCode($doc, 'span', [], "", ""); - self::tagToBBCode($doc, 'pre', [], "", ""); - - self::tagToBBCode($doc, 'div', [], "\r", "\r"); - self::tagToBBCode($doc, 'p', [], "\n", "\n"); - - self::tagToBBCode($doc, 'ul', [], "[list]", "[/list]"); - self::tagToBBCode($doc, 'ol', [], "[list=1]", "[/list]"); - self::tagToBBCode($doc, 'li', [], "[*]", ""); - - self::tagToBBCode($doc, 'hr', [], "[hr]", ""); - - self::tagToBBCode($doc, 'table', [], "[table]", "[/table]"); - self::tagToBBCode($doc, 'th', [], "[th]", "[/th]"); - self::tagToBBCode($doc, 'tr', [], "[tr]", "[/tr]"); - self::tagToBBCode($doc, 'td', [], "[td]", "[/td]"); - - self::tagToBBCode($doc, 'h1', [], "[h1]", "[/h1]"); - self::tagToBBCode($doc, 'h2', [], "[h2]", "[/h2]"); - self::tagToBBCode($doc, 'h3', [], "[h3]", "[/h3]"); - self::tagToBBCode($doc, 'h4', [], "[h4]", "[/h4]"); - self::tagToBBCode($doc, 'h5', [], "[h5]", "[/h5]"); - self::tagToBBCode($doc, 'h6', [], "[h6]", "[/h6]"); - - self::tagToBBCode($doc, 'a', ['href' => '/mailto:(.+)/'], '[mail=$1]', '[/mail]'); - self::tagToBBCode($doc, 'a', ['href' => '/(.+)/'], '[url=$1]', '[/url]'); - - self::tagToBBCode($doc, 'img', ['src' => '/(.+)/', 'alt' => '/(.+)/'], '[img=$1]$2', '[/img]', true); - self::tagToBBCode($doc, 'img', ['src' => '/(.+)/', 'width' => '/(\d+)/', 'height' => '/(\d+)/'], '[img=$2x$3]$1', '[/img]', true); - self::tagToBBCode($doc, 'img', ['src' => '/(.+)/'], '[img]$1', '[/img]', true); - - - self::tagToBBCode($doc, 'video', ['src' => '/(.+)/'], '[video]$1', '[/video]', true); - self::tagToBBCode($doc, 'audio', ['src' => '/(.+)/'], '[audio]$1', '[/audio]', true); - self::tagToBBCode($doc, 'iframe', ['src' => '/(.+)/'], '[iframe]$1', '[/iframe]', true); - - self::tagToBBCode($doc, 'key', [], '[code]', '[/code]'); - self::tagToBBCode($doc, 'code', [], '[code]', '[/code]'); - - $message = $doc->saveHTML(); - - // I'm removing something really disturbing - // Don't know exactly what it is - $message = str_replace(chr(194) . chr(160), ' ', $message); - - $message = str_replace(" ", " ", $message); - - // removing multiple DIVs - $message = preg_replace('=\r *\r=i', "\n", $message); - $message = str_replace("\r", "\n", $message); - - Hook::callAll('html2bbcode', $message); - - $message = strip_tags($message); - - $message = html_entity_decode($message, ENT_QUOTES, 'UTF-8'); - - // remove quotes if they don't make sense - $message = preg_replace('=\[/quote\][\s]*\[quote\]=i', "\n", $message); - - $message = preg_replace('=\[quote\]\s*=i', "[quote]", $message); - $message = preg_replace('=\s*\[/quote\]=i', "[/quote]", $message); - - do { - $oldmessage = $message; - $message = str_replace("\n \n", "\n\n", $message); - } while ($oldmessage != $message); - - do { - $oldmessage = $message; - $message = str_replace("\n\n\n", "\n\n", $message); - } while ($oldmessage != $message); - - do { - $oldmessage = $message; - $message = str_replace( - [ - "[/size]\n\n", - "\n[hr]", - "[hr]\n", - "\n[list", - "[/list]\n", - "\n[/", - "[list]\n", - "[list=1]\n", - "\n[*]"], - [ - "[/size]\n", - "[hr]", - "[hr]", - "[list", - "[/list]", - "[/", - "[list]", - "[list=1]", - "[*]"], - $message - ); - } while ($message != $oldmessage); - - $message = str_replace( - ['[b][b]', '[/b][/b]', '[i][i]', '[/i][/i]'], - ['[b]', '[/b]', '[i]', '[/i]'], - $message - ); - - // Handling Yahoo style of mails - $message = str_replace('[hr][b]From:[/b]', '[quote][b]From:[/b]', $message); - - // Restore code blocks - $message = preg_replace_callback( - '#\[codeblock-([0-9]+)\]#iU', - function ($matches) use ($codeblocks) { - $return = ''; - if (isset($codeblocks[intval($matches[1])])) { - $return = $codeblocks[$matches[1]]; - } - return $return; + return $prefix . PHP_EOL . trim($matches[2]) . PHP_EOL . '[/code]'; }, $message ); @@ -665,6 +635,7 @@ class HTML self::tagToBBCode($doc, 'img', ['src' => '/(.+)/'], ' ', ' '); } + // Backward compatibility, [iframe] support has been removed in version 2020.12 self::tagToBBCode($doc, 'iframe', ['src' => '/(.+)/'], ' $1 ', ''); $message = $doc->saveHTML(); @@ -881,7 +852,7 @@ class HTML '$click' => $contact['click'] ?? '', '$class' => $class, '$url' => $url, - '$photo' => ProxyUtils::proxifyUrl($contact['thumb'], false, ProxyUtils::SIZE_THUMB), + '$photo' => Contact::getThumb($contact), '$name' => $contact['name'], 'title' => $contact['name'] . ' [' . $contact['addr'] . ']', '$parkle' => $sparkle, @@ -917,7 +888,7 @@ class HTML '$save_label' => $save_label, '$search_hint' => DI::l10n()->t('@name, !forum, #tags, content'), '$mode' => $mode, - '$return_url' => urlencode('search?q=' . urlencode($s)), + '$return_url' => urlencode(Search::getSearchPath($s)), ]; if (!$aside) { diff --git a/src/Content/Text/Markdown.php b/src/Content/Text/Markdown.php index 71437fb350..45f38e4c5b 100644 --- a/src/Content/Text/Markdown.php +++ b/src/Content/Text/Markdown.php @@ -35,20 +35,20 @@ class Markdown * compatibility with Diaspora in spite of the Markdown standard. * * @param string $text - * @param bool $hardwrap + * @param bool $hardwrap Enables line breaks on \n without two trailing spaces + * @param string $baseuri Optional. Prepend anchor links with this URL * @return string - * @throws \Exception */ - public static function convert($text, $hardwrap = true) { + public static function convert($text, $hardwrap = true, $baseuri = null) { $stamp1 = microtime(true); $MarkdownParser = new MarkdownParser(); $MarkdownParser->code_class_prefix = 'language-'; $MarkdownParser->hard_wrap = $hardwrap; $MarkdownParser->hashtag_protection = true; - $MarkdownParser->url_filter_func = function ($url) { - if (strpos($url, '#') === 0) { - $url = ltrim($_SERVER['REQUEST_URI'], '/') . $url; + $MarkdownParser->url_filter_func = function ($url) use ($baseuri) { + if (!empty($baseuri) && strpos($url, '#') === 0) { + $url = ltrim($baseuri, '/') . $url; } return $url; }; @@ -57,7 +57,7 @@ class Markdown $html = $MarkdownParser->transform($text); - DI::profiler()->saveTimestamp($stamp1, "parser", System::callstack()); + DI::profiler()->saveTimestamp($stamp1, "parser"); return $html; } @@ -83,7 +83,7 @@ class Markdown return ''; } - $data = Contact::getDetailsByAddr($matches[3]); + $data = Contact::getByURL($matches[3]); if (empty($data)) { return ''; diff --git a/src/Content/Widget.php b/src/Content/Widget.php index 8c72f68f4c..f4a9fbe1f1 100644 --- a/src/Content/Widget.php +++ b/src/Content/Widget.php @@ -24,18 +24,13 @@ namespace Friendica\Content; use Friendica\Core\Addon; use Friendica\Core\Protocol; use Friendica\Core\Renderer; -use Friendica\Core\Session; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\FileTag; -use Friendica\Model\GContact; use Friendica\Model\Group; use Friendica\Model\Item; -use Friendica\Model\Profile; use Friendica\Util\DateTimeFormat; -use Friendica\Util\Proxy as ProxyUtils; -use Friendica\Util\Strings; use Friendica\Util\Temporal; class Widget @@ -269,10 +264,6 @@ class Widget return ''; } - if (!Feature::isEnabled(local_user(), 'networks')) { - return ''; - } - $extra_sql = self::unavailableNetworks(); $r = DBA::p("SELECT DISTINCT(`network`) FROM `contact` WHERE `uid` = ? AND NOT `deleted` AND `network` != '' $extra_sql ORDER BY `network`", @@ -379,80 +370,59 @@ class Widget } /** - * Return common friends visitor widget + * Show a random selection of five common contacts between the visitor and the viewed profile user. * - * @param string $profile_uid uid + * @param int $uid Viewed profile user ID + * @param string $nickname Viewed profile user nickname * @return string|void * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException */ - public static function commonFriendsVisitor($profile_uid) + public static function commonFriendsVisitor(int $uid, string $nickname) { - if (local_user() == $profile_uid) { - return; + if (local_user() == $uid) { + return ''; } - $zcid = 0; - - $cid = Session::getRemoteContactID($profile_uid); - - if (!$cid) { - if (Profile::getMyURL()) { - $contact = DBA::selectFirst('contact', ['id'], - ['nurl' => Strings::normaliseLink(Profile::getMyURL()), 'uid' => $profile_uid]); - if (DBA::isResult($contact)) { - $cid = $contact['id']; - } else { - $gcontact = DBA::selectFirst('gcontact', ['id'], ['nurl' => Strings::normaliseLink(Profile::getMyURL())]); - if (DBA::isResult($gcontact)) { - $zcid = $gcontact['id']; - } - } - } + $visitorPCid = local_user() ? Contact::getPublicIdByUserId(local_user()) : remote_user(); + if (!$visitorPCid) { + return ''; } - if ($cid == 0 && $zcid == 0) { - return; + $localPCid = Contact::getPublicIdByUserId($uid); + + $condition = [ + 'NOT `self` AND NOT `blocked` AND NOT `hidden` AND `id` != ?', + $localPCid, + ]; + + $total = Contact\Relation::countCommon($localPCid, $visitorPCid, $condition); + if (!$total) { + return ''; } - if ($cid) { - $t = GContact::countCommonFriends($profile_uid, $cid); - } else { - $t = GContact::countCommonFriendsZcid($profile_uid, $zcid); - } - - if (!$t) { - return; - } - - if ($cid) { - $r = GContact::commonFriends($profile_uid, $cid, 0, 5, true); - } else { - $r = GContact::commonFriendsZcid($profile_uid, $zcid, 0, 5, true); - } - - if (!DBA::isResult($r)) { - return; + $commonContacts = Contact\Relation::listCommon($localPCid, $visitorPCid, $condition, 0, 5, true); + if (!DBA::isResult($commonContacts)) { + return ''; } $entries = []; - foreach ($r as $rr) { - $entry = [ - 'url' => Contact::magicLink($rr['url']), - 'name' => $rr['name'], - 'photo' => ProxyUtils::proxifyUrl($rr['photo'], false, ProxyUtils::SIZE_THUMB), + foreach ($commonContacts as $contact) { + $entries[] = [ + 'url' => Contact::magicLink($contact['url']), + 'name' => $contact['name'], + 'photo' => Contact::getThumb($contact), ]; - $entries[] = $entry; } $tpl = Renderer::getMarkupTemplate('widget/remote_friends_common.tpl'); return Renderer::replaceMacros($tpl, [ - '$desc' => DI::l10n()->tt("%d contact in common", "%d contacts in common", $t), + '$desc' => DI::l10n()->tt("%d contact in common", "%d contacts in common", $total), '$base' => DI::baseUrl(), - '$uid' => $profile_uid, - '$cid' => (($cid) ? $cid : '0'), - '$linkmore' => (($t > 5) ? 'true' : ''), + '$nickname' => $nickname, + '$linkmore' => $total > 5 ? 'true' : '', '$more' => DI::l10n()->t('show more'), - '$items' => $entries + '$contacts' => $entries ]); } @@ -475,7 +445,7 @@ class Widget } if (Feature::isEnabled($uid, 'tagadelic')) { - $owner_id = Contact::getIdForURL($a->profile['url'], 0, true); + $owner_id = Contact::getIdForURL($a->profile['url'], 0, false); if (!$owner_id) { return ''; @@ -497,10 +467,6 @@ class Widget { $o = ''; - if (!Feature::isEnabled($uid, 'archives')) { - return $o; - } - $visible_years = DI::pConfig()->get($uid, 'system', 'archive_visible_years', 5); /* arrange the list in years */ @@ -550,9 +516,31 @@ class Widget '$cutoff' => $cutoff, '$url' => $url, '$dates' => $ret, + '$showless' => DI::l10n()->t('show less'), '$showmore' => DI::l10n()->t('show more') ]); return $o; } + + /** + * Display the account types sidebar + * The account type value is added as a parameter to the url + * + * @param string $base Basepath + * @param int $accounttype Acount type + * @return string + */ + public static function accounttypes(string $base, $accounttype) + { + $accounts = [ + ['ref' => 'person', 'name' => DI::l10n()->t('Persons')], + ['ref' => 'organisation', 'name' => DI::l10n()->t('Organisations')], + ['ref' => 'news', 'name' => DI::l10n()->t('News')], + ['ref' => 'community', 'name' => DI::l10n()->t('Forums')], + ]; + + return self::filter('accounttype', DI::l10n()->t('Account Types'), '', + DI::l10n()->t('All'), $base, $accounts, $accounttype); + } } diff --git a/src/Content/Widget/CalendarExport.php b/src/Content/Widget/CalendarExport.php index dda3513fec..9f282d2642 100644 --- a/src/Content/Widget/CalendarExport.php +++ b/src/Content/Widget/CalendarExport.php @@ -54,22 +54,6 @@ class CalendarExport return; } - /* - * If it's a kind of profile page (intval($owner_uid)) return if the user not logged in and - * export feature isn't enabled. - */ - /* - * Cal logged in user (test permission at foreign profile page). - * If the $owner uid is available we know it is part of one of the profile pages (like /cal). - * So we have to test if if it's the own profile page of the logged in user - * or a foreign one. For foreign profile pages we need to check if the feature - * for exporting the cal is enabled (otherwise the widget would appear for logged in users - * on foreigen profile pages even if the widget is disabled). - */ - if (local_user() != $owner_uid && !Feature::isEnabled($owner_uid, "export_calendar")) { - return; - } - // $a->data is only available if the profile page is visited. If the visited page is not part // of the profile page it should be the personal /events page. So we can use $a->user. $user = ($a->data['user']['nickname'] ?? '') ?: $a->user['nickname']; diff --git a/src/Content/Widget/ContactBlock.php b/src/Content/Widget/ContactBlock.php index 47fac09a83..9e74a16115 100644 --- a/src/Content/Widget/ContactBlock.php +++ b/src/Content/Widget/ContactBlock.php @@ -66,6 +66,7 @@ class ContactBlock 'pending' => false, 'hidden' => false, 'archive' => false, + 'failed' => false, 'network' => [Protocol::DFRN, Protocol::ACTIVITYPUB, Protocol::OSTATUS, Protocol::DIASPORA, Protocol::FEED], ]); @@ -98,7 +99,7 @@ class ContactBlock $contact_ids[] = $contact["id"]; } - $contacts_stmt = DBA::select('contact', ['id', 'uid', 'addr', 'url', 'name', 'thumb', 'network'], ['id' => $contact_ids]); + $contacts_stmt = DBA::select('contact', ['id', 'uid', 'addr', 'url', 'name', 'thumb', 'avatar', 'network'], ['id' => $contact_ids]); if (DBA::isResult($contacts_stmt)) { $contacts_title = DI::l10n()->tt('%d Contact', '%d Contacts', $total); diff --git a/src/Content/Widget/SavedSearches.php b/src/Content/Widget/SavedSearches.php index 355f41f730..30e5b9c2ab 100644 --- a/src/Content/Widget/SavedSearches.php +++ b/src/Content/Widget/SavedSearches.php @@ -22,6 +22,7 @@ namespace Friendica\Content\Widget; use Friendica\Core\Renderer; +use Friendica\Core\Search; use Friendica\Database\DBA; use Friendica\DI; @@ -35,32 +36,32 @@ class SavedSearches */ public static function getHTML($return_url, $search = '') { - $o = ''; - + $saved = []; $saved_searches = DBA::select('search', ['id', 'term'], ['uid' => local_user()]); - if (DBA::isResult($saved_searches)) { - $saved = []; - foreach ($saved_searches as $saved_search) { - $saved[] = [ - 'id' => $saved_search['id'], - 'term' => $saved_search['term'], - 'encodedterm' => urlencode($saved_search['term']), - 'delete' => DI::l10n()->t('Remove term'), - 'selected' => $search == $saved_search['term'], - ]; - } + while ($saved_search = DBA::fetch($saved_searches)) { + $saved[] = [ + 'id' => $saved_search['id'], + 'term' => $saved_search['term'], + 'encodedterm' => urlencode($saved_search['term']), + 'searchpath' => Search::getSearchPath($saved_search['term']), + 'delete' => DI::l10n()->t('Remove term'), + 'selected' => $search == $saved_search['term'], + ]; + } + DBA::close($saved_searches); - $tpl = Renderer::getMarkupTemplate('widget/saved_searches.tpl'); - - $o = Renderer::replaceMacros($tpl, [ - '$title' => DI::l10n()->t('Saved Searches'), - '$add' => '', - '$searchbox' => '', - '$saved' => $saved, - '$return_url' => urlencode($return_url), - ]); + if (empty($saved)) { + return ''; } - return $o; + $tpl = Renderer::getMarkupTemplate('widget/saved_searches.tpl'); + + return Renderer::replaceMacros($tpl, [ + '$title' => DI::l10n()->t('Saved Searches'), + '$add' => '', + '$searchbox' => '', + '$saved' => $saved, + '$return_url' => urlencode($return_url), + ]); } } diff --git a/src/Core/ACL.php b/src/Core/ACL.php index f35889061d..5f69d78b68 100644 --- a/src/Core/ACL.php +++ b/src/Core/ACL.php @@ -33,75 +33,73 @@ use Friendica\Model\Group; class ACL { /** - * Returns a select input tag with all the contact of the local user + * Returns a select input tag for private message recipient * - * @param string $selname Name attribute of the select input tag - * @param string $selclass Class attribute of the select input tag - * @param array $preselected Contact IDs that should be already selected - * @param int $size Length of the select box - * @param int $tabindex Select input tag tabindex attribute + * @param int $selected Existing recipien contact ID * @return string * @throws \Exception */ - public static function getMessageContactSelectHTML($selname, $selclass, array $preselected = [], $size = 4, $tabindex = null) + public static function getMessageContactSelectHTML(int $selected = null) { - $a = DI::app(); - $o = ''; + $page = DI::page(); + + $page->registerFooterScript(Theme::getPathForFile('asset/typeahead.js/dist/typeahead.bundle.js')); + $page->registerFooterScript(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput.js')); + $page->registerStylesheet(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput.css')); + $page->registerStylesheet(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput-typeahead.css')); + // When used for private messages, we limit correspondence to mutual DFRN/Friendica friends and the selector // to one recipient. By default our selector allows multiple selects amongst all contacts. - $sql_extra = sprintf(" AND `rel` = %d ", intval(Contact::FRIEND)); - $sql_extra .= sprintf(" AND `network` IN ('%s' , '%s') ", Protocol::DFRN, Protocol::DIASPORA); + $condition = [ + 'uid' => local_user(), + 'self' => false, + 'blocked' => false, + 'pending' => false, + 'archive' => false, + 'deleted' => false, + 'rel' => [Contact::FOLLOWER, Contact::SHARING, Contact::FRIEND], + 'network' => Protocol::FEDERATED, + ]; - $tabindex_attr = !empty($tabindex) ? ' tabindex="' . intval($tabindex) . '"' : ''; - - $hidepreselected = ''; - if ($preselected) { - $sql_extra .= " AND `id` IN (" . implode(",", $preselected) . ")"; - $hidepreselected = ' style="display: none;"'; - } - - $o .= "' . PHP_EOL; - - if ($preselected) { - $o .= implode(', ', $receiverlist); - } - - Hook::callAll(DI::module()->getName() . '_post_' . $selname, $o); + $tpl = Renderer::getMarkupTemplate('acl/self_only.tpl'); + $o = Renderer::replaceMacros($tpl, [ + '$selfPublicContactId' => $selfPublicContactId, + '$explanation' => $explanation, + ]); return $o; } @@ -303,7 +301,7 @@ class ACL 'emailcc' => $form_prefix ? $form_prefix . '[emailcc]' : 'emailcc', ]; - $tpl = Renderer::getMarkupTemplate('acl_selector.tpl'); + $tpl = Renderer::getMarkupTemplate('acl/full_selector.tpl'); $o = Renderer::replaceMacros($tpl, [ '$public_title' => DI::l10n()->t('Public'), '$public_desc' => DI::l10n()->t('This content will be shown to all your followers and can be seen in the community pages and by anyone with its link.'), diff --git a/src/Core/Addon.php b/src/Core/Addon.php index dd229be287..511364b8ac 100644 --- a/src/Core/Addon.php +++ b/src/Core/Addon.php @@ -136,7 +136,7 @@ class Addon $func(); } - DBA::delete('hook', ['file' => 'addon/' . $addon . '/' . $addon . '.php']); + Hook::delete(['file' => 'addon/' . $addon . '/' . $addon . '.php']); unset(self::$addons[array_search($addon, self::$addons)]); } @@ -163,29 +163,21 @@ class Addon if (function_exists($addon . '_install')) { $func = $addon . '_install'; $func(DI::app()); - - $addon_admin = (function_exists($addon . "_addon_admin") ? 1 : 0); - - DBA::insert('addon', ['name' => $addon, 'installed' => true, - 'timestamp' => $t, 'plugin_admin' => $addon_admin]); - - // we can add the following with the previous SQL - // once most site tables have been updated. - // This way the system won't fall over dead during the update. - - if (file_exists('addon/' . $addon . '/.hidden')) { - DBA::update('addon', ['hidden' => true], ['name' => $addon]); - } - - if (!self::isEnabled($addon)) { - self::$addons[] = $addon; - } - - return true; - } else { - Logger::error("Addon {addon}: {action} failed", ['action' => 'install', 'addon' => $addon]); - return false; } + + DBA::insert('addon', [ + 'name' => $addon, + 'installed' => true, + 'timestamp' => $t, + 'plugin_admin' => function_exists($addon . '_addon_admin'), + 'hidden' => file_exists('addon/' . $addon . '/.hidden') + ]); + + if (!self::isEnabled($addon)) { + self::$addons[] = $addon; + } + + return true; } /** @@ -204,17 +196,9 @@ class Addon } Logger::notice("Addon {addon}: {action}", ['action' => 'reload', 'addon' => $addon['name']]); - @include_once($fname); - if (function_exists($addonname . '_uninstall')) { - $func = $addonname . '_uninstall'; - $func(DI::app()); - } - if (function_exists($addonname . '_install')) { - $func = $addonname . '_install'; - $func(DI::app()); - } - DBA::update('addon', ['timestamp' => $t], ['id' => $addon['id']]); + self::uninstall($fname); + self::install($fname); } } @@ -237,8 +221,6 @@ class Addon */ public static function getInfo($addon) { - $a = DI::app(); - $addon = Strings::sanitizeFilePathItem($addon); $info = [ @@ -256,7 +238,7 @@ class Addon $stamp1 = microtime(true); $f = file_get_contents("addon/$addon/$addon.php"); - DI::profiler()->saveTimestamp($stamp1, "file", System::callstack()); + DI::profiler()->saveTimestamp($stamp1, "file"); $r = preg_match("|/\*.*\*/|msU", $f, $m); diff --git a/src/Core/Cache/ProfilerCache.php b/src/Core/Cache/ProfilerCache.php index 1f77db67ac..7c4802077a 100644 --- a/src/Core/Cache/ProfilerCache.php +++ b/src/Core/Cache/ProfilerCache.php @@ -56,7 +56,7 @@ class ProfilerCache implements ICache, IMemoryCache $return = $this->cache->getAllKeys($prefix); - $this->profiler->saveTimestamp($time, 'cache', System::callstack()); + $this->profiler->saveTimestamp($time, 'cache'); return $return; } @@ -70,7 +70,7 @@ class ProfilerCache implements ICache, IMemoryCache $return = $this->cache->get($key); - $this->profiler->saveTimestamp($time, 'cache', System::callstack()); + $this->profiler->saveTimestamp($time, 'cache'); return $return; } @@ -84,7 +84,7 @@ class ProfilerCache implements ICache, IMemoryCache $return = $this->cache->set($key, $value, $ttl); - $this->profiler->saveTimestamp($time, 'cache', System::callstack()); + $this->profiler->saveTimestamp($time, 'cache'); return $return; } @@ -98,7 +98,7 @@ class ProfilerCache implements ICache, IMemoryCache $return = $this->cache->delete($key); - $this->profiler->saveTimestamp($time, 'cache', System::callstack()); + $this->profiler->saveTimestamp($time, 'cache'); return $return; } @@ -112,7 +112,7 @@ class ProfilerCache implements ICache, IMemoryCache $return = $this->cache->clear($outdated); - $this->profiler->saveTimestamp($time, 'cache', System::callstack()); + $this->profiler->saveTimestamp($time, 'cache'); return $return; } @@ -127,7 +127,7 @@ class ProfilerCache implements ICache, IMemoryCache $return = $this->cache->add($key, $value, $ttl); - $this->profiler->saveTimestamp($time, 'cache', System::callstack()); + $this->profiler->saveTimestamp($time, 'cache'); return $return; } else { @@ -145,7 +145,7 @@ class ProfilerCache implements ICache, IMemoryCache $return = $this->cache->compareSet($key, $oldValue, $newValue, $ttl); - $this->profiler->saveTimestamp($time, 'cache', System::callstack()); + $this->profiler->saveTimestamp($time, 'cache'); return $return; } else { @@ -163,7 +163,7 @@ class ProfilerCache implements ICache, IMemoryCache $return = $this->cache->compareDelete($key, $value); - $this->profiler->saveTimestamp($time, 'cache', System::callstack()); + $this->profiler->saveTimestamp($time, 'cache'); return $return; } else { diff --git a/src/Core/Cache/RedisCache.php b/src/Core/Cache/RedisCache.php index 9a982fe040..5dbb963882 100644 --- a/src/Core/Cache/RedisCache.php +++ b/src/Core/Cache/RedisCache.php @@ -139,7 +139,9 @@ class RedisCache extends BaseCache implements IMemoryCache public function delete($key) { $cachekey = $this->getCacheKey($key); - return ($this->redis->del($cachekey) > 0); + $this->redis->del($cachekey); + // Redis doesn't have an error state for del() + return true; } /** diff --git a/src/Core/Config/Cache.php b/src/Core/Config/Cache.php index b3fe9d4e00..25b25550e0 100644 --- a/src/Core/Config/Cache.php +++ b/src/Core/Config/Cache.php @@ -30,11 +30,28 @@ use ParagonIE\HiddenString\HiddenString; */ class Cache { + /** @var int Indicates that the cache entry is set by file - Low Priority */ + const SOURCE_FILE = 0; + /** @var int Indicates that the cache entry is set by the DB config table - Middle Priority */ + const SOURCE_DB = 1; + /** @var int Indicates that the cache entry is set by a server environment variable - High Priority */ + const SOURCE_ENV = 3; + /** @var int Indicates that the cache entry is fixed and must not be changed */ + const SOURCE_FIX = 4; + + /** @var int Default value for a config source */ + const SOURCE_DEFAULT = self::SOURCE_FILE; + /** * @var array */ private $config; + /** + * @var int[][] + */ + private $source = []; + /** * @var bool */ @@ -43,11 +60,12 @@ class Cache /** * @param array $config A initial config array * @param bool $hidePasswordOutput True, if cache variables should take extra care of password values + * @param int $source Sets a source of the initial config values */ - public function __construct(array $config = [], bool $hidePasswordOutput = true) + public function __construct(array $config = [], bool $hidePasswordOutput = true, $source = self::SOURCE_DEFAULT) { $this->hidePasswordOutput = $hidePasswordOutput; - $this->load($config); + $this->load($config, $source); } /** @@ -55,9 +73,9 @@ class Cache * Doesn't overwrite previously set values by default to prevent default config files to supersede DB Config. * * @param array $config - * @param bool $overwrite Force value overwrite if the config key already exists + * @param int $source Indicates the source of the config entry */ - public function load(array $config, bool $overwrite = false) + public function load(array $config, int $source = self::SOURCE_DEFAULT) { $categories = array_keys($config); @@ -68,11 +86,7 @@ class Cache foreach ($keys as $key) { $value = $config[$category][$key]; if (isset($value)) { - if ($overwrite) { - $this->set($category, $key, $value); - } else { - $this->setDefault($category, $key, $value); - } + $this->set($category, $key, $value, $source); } } } @@ -91,49 +105,45 @@ class Cache { if (isset($this->config[$cat][$key])) { return $this->config[$cat][$key]; - } elseif (!isset($key) && isset($this->config[$cat])) { + } else if (!isset($key) && isset($this->config[$cat])) { return $this->config[$cat]; } else { return null; } } - /** - * Sets a default value in the config cache. Ignores already existing keys. - * - * @param string $cat Config category - * @param string $key Config key - * @param mixed $value Default value to set - */ - private function setDefault(string $cat, string $key, $value) - { - if (!isset($this->config[$cat][$key])) { - $this->set($cat, $key, $value); - } - } - /** * Sets a value in the config cache. Accepts raw output from the config table * - * @param string $cat Config category - * @param string $key Config key - * @param mixed $value Value to set + * @param string $cat Config category + * @param string $key Config key + * @param mixed $value Value to set + * @param int $source The source of the current config key * * @return bool True, if the value is set */ - public function set(string $cat, string $key, $value) + public function set(string $cat, string $key, $value, $source = self::SOURCE_DEFAULT) { if (!isset($this->config[$cat])) { $this->config[$cat] = []; + $this->source[$cat] = []; + } + + if (isset($this->source[$cat][$key]) && + $source < $this->source[$cat][$key]) { + return false; } if ($this->hidePasswordOutput && - $key == 'password' && - is_string($value)) { + $key == 'password' && + is_string($value)) { $this->config[$cat][$key] = new HiddenString((string)$value); } else { $this->config[$cat][$key] = $value; } + + $this->source[$cat][$key] = $source; + return true; } @@ -149,8 +159,10 @@ class Cache { if (isset($this->config[$cat][$key])) { unset($this->config[$cat][$key]); + unset($this->source[$cat][$key]); if (count($this->config[$cat]) == 0) { unset($this->config[$cat]); + unset($this->source[$cat]); } return true; } else { diff --git a/src/Core/Config/JitConfig.php b/src/Core/Config/JitConfig.php index 4cf0d06f36..dbf1ea3eaf 100644 --- a/src/Core/Config/JitConfig.php +++ b/src/Core/Config/JitConfig.php @@ -70,7 +70,7 @@ class JitConfig extends BaseConfig } // load the whole category out of the DB into the cache - $this->configCache->load($config, true); + $this->configCache->load($config, Cache::SOURCE_DB); } /** diff --git a/src/Core/Config/PreloadConfig.php b/src/Core/Config/PreloadConfig.php index c1181414ba..168823f4dd 100644 --- a/src/Core/Config/PreloadConfig.php +++ b/src/Core/Config/PreloadConfig.php @@ -69,7 +69,7 @@ class PreloadConfig extends BaseConfig $this->config_loaded = true; // load the whole category out of the DB into the cache - $this->configCache->load($config, true); + $this->configCache->load($config, Cache::SOURCE_DB); } /** diff --git a/src/Core/Console.php b/src/Core/Console.php index 86178c209d..f43b89e9e6 100644 --- a/src/Core/Console.php +++ b/src/Core/Console.php @@ -51,7 +51,7 @@ Commands: docbloxerrorchecker Check the file tree for DocBlox errors extract Generate translation string file for the Friendica project (deprecated) globalcommunityblock Block remote profile from interacting with this node - globalcommunitysilence Silence remote profile from global community page + globalcommunitysilence Silence a profile from the global community page archivecontact Archive a contact when you know that it isn't existing anymore help Show help about a command, e.g (bin/console help config) autoinstall Starts automatic installation of friendica based on values from htconfig.php @@ -64,6 +64,7 @@ Commands: postupdate Execute pending post update scripts (can last days) serverblock Manage blocked servers storage Manage storage backend + relay Manage ActivityPub relay servers Options: -h|--help|-? Show help information @@ -92,6 +93,8 @@ HELP; 'postupdate' => Friendica\Console\PostUpdate::class, 'serverblock' => Friendica\Console\ServerBlock::class, 'storage' => Friendica\Console\Storage::class, + 'relay' => Friendica\Console\Relay::class, + 'fixapdeliveryworkertaskparameters' => Friendica\Console\FixAPDeliveryWorkerTaskParameters::class, ]; /** diff --git a/src/Core/Hook.php b/src/Core/Hook.php index 8fdadd666a..d7b1f737a0 100644 --- a/src/Core/Hook.php +++ b/src/Core/Hook.php @@ -99,9 +99,7 @@ class Hook return true; } - $result = DBA::insert('hook', ['hook' => $hook, 'file' => $file, 'function' => $function, 'priority' => $priority]); - - return $result; + return self::insert(['hook' => $hook, 'file' => $file, 'function' => $function, 'priority' => $priority]); } /** @@ -119,10 +117,10 @@ class Hook // This here is only needed for fixing a problem that existed on the develop branch $condition = ['hook' => $hook, 'file' => $file, 'function' => $function]; - DBA::delete('hook', $condition); + self::delete($condition); $condition = ['hook' => $hook, 'file' => $relative_file, 'function' => $function]; - $result = DBA::delete('hook', $condition); + $result = self::delete($condition); return $result; } @@ -220,7 +218,7 @@ class Hook } else { // remove orphan hooks $condition = ['hook' => $name, 'file' => $hook[0], 'function' => $hook[1]]; - DBA::delete('hook', $condition, ['cascade' => false]); + self::delete($condition, ['cascade' => false]); } } @@ -245,4 +243,45 @@ class Hook return false; } + + /** + * Deletes one or more hook records + * + * We have to clear the cached routerDispatchData because addons can provide routes + * + * @param array $condition + * @param array $options + * @return bool + * @throws \Exception + */ + public static function delete(array $condition, array $options = []) + { + $result = DBA::delete('hook', $condition, $options); + + if ($result) { + DI::cache()->delete('routerDispatchData'); + } + + return $result; + } + + /** + * Inserts a hook record + * + * We have to clear the cached routerDispatchData because addons can provide routes + * + * @param array $condition + * @return bool + * @throws \Exception + */ + private static function insert(array $condition) + { + $result = DBA::insert('hook', $condition); + + if ($result) { + DI::cache()->delete('routerDispatchData'); + } + + return $result; + } } diff --git a/src/Core/Installer.php b/src/Core/Installer.php index 31cdb26b9d..70ee4bba40 100644 --- a/src/Core/Installer.php +++ b/src/Core/Installer.php @@ -28,7 +28,6 @@ use Friendica\Database\Database; use Friendica\Database\DBStructure; use Friendica\DI; use Friendica\Util\Images; -use Friendica\Util\Network; use Friendica\Util\Strings; /** @@ -197,7 +196,7 @@ class Installer if ($result) { $txt = DI::l10n()->t('You may need to import the file "database.sql" manually using phpmyadmin or mysql.') . EOL; - $txt .= DI::l10n()->t('Please see the file "INSTALL.txt".'); + $txt .= DI::l10n()->t('Please see the file "doc/INSTALL.md".'); $this->addCheck($txt, false, true, htmlentities($result, ENT_COMPAT, 'UTF-8')); @@ -259,7 +258,7 @@ class Installer $help = ""; if (!$passed) { $help .= DI::l10n()->t('Could not find a command line version of PHP in the web server PATH.') . EOL; - $help .= DI::l10n()->t("If you don't have a command line version of PHP installed on your server, you will not be able to run the background processing. See 'Setup the worker'") . EOL; + $help .= DI::l10n()->t("If you don't have a command line version of PHP installed on your server, you will not be able to run the background processing. See 'Setup the worker'") . EOL; $help .= EOL . EOL; $tpl = Renderer::getMarkupTemplate('field_input.tpl'); /// @todo Separate backend Installer class and presentation layer/view @@ -464,6 +463,13 @@ class Installer ); $returnVal = $returnVal ? $status : false; + $status = $this->checkFunction('proc_open', + DI::l10n()->t('Program execution functions'), + DI::l10n()->t('Error: Program execution functions required but not enabled.'), + true + ); + $returnVal = $returnVal ? $status : false; + $status = $this->checkFunction('json_encode', DI::l10n()->t('JSON PHP module'), DI::l10n()->t('Error: JSON PHP module required but not installed.'), @@ -548,11 +554,11 @@ class Installer $help = ""; $error_msg = ""; if (function_exists('curl_init')) { - $fetchResult = Network::fetchUrlFull($baseurl . "/install/testrewrite"); + $fetchResult = DI::httpRequest()->fetchFull($baseurl . "/install/testrewrite"); $url = Strings::normaliseLink($baseurl . "/install/testrewrite"); if ($fetchResult->getReturnCode() != 204) { - $fetchResult = Network::fetchUrlFull($url); + $fetchResult = DI::httpRequest()->fetchFull($url); } if ($fetchResult->getReturnCode() != 204) { diff --git a/src/Core/Process.php b/src/Core/Process.php index fd6b17fe14..447d312d44 100644 --- a/src/Core/Process.php +++ b/src/Core/Process.php @@ -23,6 +23,7 @@ namespace Friendica\Core; use Friendica\App; use Friendica\Core\Config\IConfig; +use Friendica\Model; use Psr\Log\LoggerInterface; /** @@ -56,12 +57,59 @@ class Process */ private $basePath; - public function __construct(LoggerInterface $logger, App\Mode $mode, IConfig $config, string $basepath) + /** @var Model\Process */ + private $processModel; + + /** + * The Process ID of this process + * + * @var int + */ + private $pid; + + public function __construct(LoggerInterface $logger, App\Mode $mode, IConfig $config, Model\Process $processModel, string $basepath, int $pid) { - $this->logger = $logger; - $this->mode = $mode; - $this->config = $config; + $this->logger = $logger; + $this->mode = $mode; + $this->config = $config; $this->basePath = $basepath; + $this->processModel = $processModel; + $this->pid = $pid; + } + + /** + * Set the process id + * + * @param integer $pid + * @return void + */ + public function setPid(int $pid) + { + $this->pid = $pid; + } + + /** + * Log active processes into the "process" table + */ + public function start() + { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + + $command = basename($trace[0]['file']); + + $this->processModel->deleteInactive(); + $this->processModel->insert($command, $this->pid); + } + + /** + * Remove the active process from the "process" table + * + * @return bool + * @throws \Exception + */ + public function end() + { + return $this->processModel->deleteByPid($this->pid); } /** @@ -142,7 +190,7 @@ class Process $reached = ($free < $min_memory); if ($reached) { - $this->logger->debug('Minimal memory reached.', ['free' => $free, 'memtotal' => $meminfo['MemTotal'], 'limit' => $min_memory]); + $this->logger->warning('Minimal memory reached.', ['free' => $free, 'memtotal' => $meminfo['MemTotal'], 'limit' => $min_memory]); } return $reached; @@ -172,7 +220,7 @@ class Process $load = System::currentLoad(); if ($load) { if (intval($load) > $maxsysload) { - $this->logger->info('system load for process too high.', ['load' => $load, 'process' => $process, 'maxsysload' => $maxsysload]); + $this->logger->warning('system load for process too high.', ['load' => $load, 'process' => $process, 'maxsysload' => $maxsysload]); return true; } } @@ -188,6 +236,7 @@ class Process public function run($command, $args) { if (!function_exists('proc_open')) { + $this->logger->warning('"proc_open" not available - quitting'); return; } @@ -205,6 +254,7 @@ class Process } if ($this->isMinMemoryReached()) { + $this->logger->warning('Memory limit reached - quitting'); return; } @@ -214,9 +264,11 @@ class Process $resource = proc_open($cmdline . ' &', [], $foo, $this->basePath); } if (!is_resource($resource)) { - $this->logger->debug('We got no resource for command.', ['cmd' => $cmdline]); + $this->logger->warning('We got no resource for command.', ['command' => $cmdline]); return; } proc_close($resource); + + $this->logger->info('Executed "proc_open"', ['command' => $cmdline, 'callstack' => System::callstack(10)]); } } diff --git a/src/Core/Protocol.php b/src/Core/Protocol.php index e510f1868c..7b9789752e 100644 --- a/src/Core/Protocol.php +++ b/src/Core/Protocol.php @@ -21,7 +21,7 @@ namespace Friendica\Core; -use Friendica\Util\Network; +use Friendica\DI; /** * Manage compatibility with federated networks @@ -91,7 +91,6 @@ class Protocol * @param string $profile_url * @param array $matches preg_match return array: [0] => Full match [1] => hostname [2] => username * @return string - * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ public static function matchByProfileUrl($profile_url, &$matches = []) { @@ -123,7 +122,7 @@ class Protocol if (preg_match('=https?://(.*)/user/(.*)=ism', $profile_url, $matches)) { $statusnet_host = $matches[1]; $statusnet_user = $matches[2]; - $UserData = Network::fetchUrl('http://' . $statusnet_host . '/api/users/show.json?user_id=' . $statusnet_user); + $UserData = DI::httpRequest()->fetch('http://' . $statusnet_host . '/api/users/show.json?user_id=' . $statusnet_user); $user = json_decode($UserData); if ($user) { $matches[2] = $user->screen_name; diff --git a/src/Core/Renderer.php b/src/Core/Renderer.php index 5ce47ad931..1ccad740bd 100644 --- a/src/Core/Renderer.php +++ b/src/Core/Renderer.php @@ -23,8 +23,8 @@ namespace Friendica\Core; use Exception; use Friendica\DI; -use Friendica\Render\FriendicaSmarty; -use Friendica\Render\ITemplateEngine; +use Friendica\Network\HTTPException\InternalServerErrorException; +use Friendica\Render\TemplateEngine; /** * This class handles Renderer related functions. @@ -51,7 +51,6 @@ class Renderer 'sourcename' => '', 'videowidth' => 425, 'videoheight' => 350, - 'force_max_items' => 0, 'stylesheet' => '', 'template_engine' => 'smarty3', ]; @@ -66,31 +65,33 @@ class Renderer ]; /** - * This is our template processor + * Returns the rendered template output from the template string and variables * - * @param string|FriendicaSmarty $s The string requiring macro substitution or an instance of FriendicaSmarty - * @param array $vars Key value pairs (search => replace) - * - * @return string substituted string - * @throws Exception + * @param string $template + * @param array $vars + * @return string + * @throws InternalServerErrorException */ - public static function replaceMacros($s, array $vars = []) + public static function replaceMacros(string $template, array $vars = []) { $stamp1 = microtime(true); // pass $baseurl to all templates if it isn't set - $vars = array_merge(['$baseurl' => DI::baseUrl()->get()], $vars); + $vars = array_merge(['$baseurl' => DI::baseUrl()->get(), '$APP' => DI::app()], $vars); $t = self::getTemplateEngine(); try { - $output = $t->replaceMacros($s, $vars); + $output = $t->replaceMacros($template, $vars); } catch (Exception $e) { - echo "
    " . __FUNCTION__ . ": " . $e->getMessage() . "
    "; - exit(); + DI::logger()->critical($e->getMessage(), ['template' => $template, 'vars' => $vars]); + $message = is_site_admin() ? + $e->getMessage() : + DI::l10n()->t('Friendica can\'t display this page at the moment, please contact the administrator.'); + throw new InternalServerErrorException($message); } - DI::profiler()->saveTimestamp($stamp1, "rendering", System::callstack()); + DI::profiler()->saveTimestamp($stamp1, "rendering"); return $output; } @@ -98,25 +99,28 @@ class Renderer /** * Load a given template $s * - * @param string $s Template to load. + * @param string $file Template to load. * @param string $subDir Subdirectory (Optional) * * @return string template. - * @throws Exception + * @throws InternalServerErrorException */ - public static function getMarkupTemplate($s, $subDir = '') + public static function getMarkupTemplate($file, $subDir = '') { $stamp1 = microtime(true); $t = self::getTemplateEngine(); try { - $template = $t->getTemplateFile($s, $subDir); + $template = $t->getTemplateFile($file, $subDir); } catch (Exception $e) { - echo "
    " . __FUNCTION__ . ": " . $e->getMessage() . "
    "; - exit(); + DI::logger()->critical($e->getMessage(), ['file' => $file, 'subDir' => $subDir]); + $message = is_site_admin() ? + $e->getMessage() : + DI::l10n()->t('Friendica can\'t display this page at the moment, please contact the administrator.'); + throw new InternalServerErrorException($message); } - DI::profiler()->saveTimestamp($stamp1, "file", System::callstack()); + DI::profiler()->saveTimestamp($stamp1, "file"); return $template; } @@ -125,18 +129,22 @@ class Renderer * Register template engine class * * @param string $class + * @throws InternalServerErrorException */ public static function registerTemplateEngine($class) { $v = get_class_vars($class); - if (!empty($v['name'])) - { + if (!empty($v['name'])) { $name = $v['name']; self::$template_engines[$name] = $class; } else { - echo "template engine $class cannot be registered without a name.\n"; - die(); + $admin_message = DI::l10n()->t('template engine cannot be registered without a name.'); + DI::logger()->critical($admin_message, ['class' => $class]); + $message = is_site_admin() ? + $admin_message : + DI::l10n()->t('Friendica can\'t display this page at the moment, please contact the administrator.'); + throw new InternalServerErrorException($message); } } @@ -146,7 +154,8 @@ class Renderer * If $name is not defined, return engine defined by theme, * or default * - * @return ITemplateEngine Template Engine instance + * @return TemplateEngine Template Engine instance + * @throws InternalServerErrorException */ public static function getTemplateEngine() { @@ -156,15 +165,20 @@ class Renderer if (isset(self::$template_engine_instance[$template_engine])) { return self::$template_engine_instance[$template_engine]; } else { + $a = DI::app(); $class = self::$template_engines[$template_engine]; - $obj = new $class; + $obj = new $class($a->getCurrentTheme(), $a->theme_info); self::$template_engine_instance[$template_engine] = $obj; return $obj; } } - echo "template engine $template_engine is not registered!\n"; - exit(); + $admin_message = DI::l10n()->t('template engine is not registered!'); + DI::logger()->critical($admin_message, ['template_engine' => $template_engine]); + $message = is_site_admin() ? + $admin_message : + DI::l10n()->t('Friendica can\'t display this page at the moment, please contact the administrator.'); + throw new InternalServerErrorException($message); } /** diff --git a/src/Core/Search.php b/src/Core/Search.php index 4742ac5993..aedad2f0e1 100644 --- a/src/Core/Search.php +++ b/src/Core/Search.php @@ -24,12 +24,9 @@ namespace Friendica\Core; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; -use Friendica\Model\GContact; use Friendica\Network\HTTPException; -use Friendica\Network\Probe; use Friendica\Object\Search\ContactResult; use Friendica\Object\Search\ResultList; -use Friendica\Protocol\PortableContact; use Friendica\Util\Network; use Friendica\Util\Strings; @@ -64,8 +61,7 @@ class Search if ((filter_var($user, FILTER_VALIDATE_EMAIL) && Network::isEmailDomainValid($user)) || (substr(Strings::normaliseLink($user), 0, 7) == "http://")) { - /// @todo Possibly use "getIdForURL" instead? - $user_data = Probe::uri($user); + $user_data = Contact::getByURL($user); if (empty($user_data)) { return $emptyResultList; } @@ -74,10 +70,7 @@ class Search return $emptyResultList; } - // Ensure that we do have a contact entry - Contact::getIdForURL($user_data['url'] ?? ''); - - $contactDetails = Contact::getDetailsByURL($user_data['url'] ?? '', local_user()); + $contactDetails = Contact::getByURLForUser($user_data['url'] ?? '', local_user()); $result = new ContactResult( $user_data['name'] ?? '', @@ -87,7 +80,7 @@ class Search $user_data['photo'] ?? '', $user_data['network'] ?? '', $contactDetails['id'] ?? 0, - 0, + $user_data['id'] ?? 0, $user_data['tags'] ?? '' ); @@ -100,7 +93,7 @@ class Search /** * Search in the global directory for occurrences of the search string * - * @see https://github.com/friendica/friendica-directory/blob/master/docs/Protocol.md#search + * @see https://github.com/friendica/friendica-directory/blob/stable/docs/Protocol.md#search * * @param string $search * @param int $type specific type of searching @@ -129,7 +122,7 @@ class Search $searchUrl .= '&page=' . $page; } - $resultJson = Network::fetchUrl($searchUrl, false, 0, 'application/json'); + $resultJson = DI::httpRequest()->fetch($searchUrl, 0, 'application/json'); $results = json_decode($resultJson, true); @@ -143,7 +136,7 @@ class Search foreach ($profiles as $profile) { $profile_url = $profile['url'] ?? ''; - $contactDetails = Contact::getDetailsByURL($profile_url, local_user()); + $contactDetails = Contact::getByURLForUser($profile_url, local_user()); $result = new ContactResult( $profile['name'] ?? '', @@ -176,6 +169,8 @@ class Search */ public static function getContactsFromLocalDirectory($search, $type = self::TYPE_ALL, $start = 0, $itemPage = 80) { + Logger::info('Searching', ['search' => $search, 'type' => $type, 'start' => $start, 'itempage' => $itemPage]); + $config = DI::config(); $diaspora = $config->get('system', 'diaspora_enabled') ? Protocol::DIASPORA : Protocol::DFRN; @@ -183,18 +178,20 @@ class Search $wildcard = Strings::escapeHtml('%' . $search . '%'); - $count = DBA::count('gcontact', [ - 'NOT `hide` + $condition = [ + 'NOT `unsearchable` AND `network` IN (?, ?, ?, ?) - AND ((`last_contact` >= `last_failure`) OR (`updated` >= `last_failure`)) + AND NOT `failed` AND `uid` = ? AND (`url` LIKE ? OR `name` LIKE ? OR `location` LIKE ? OR `addr` LIKE ? OR `about` LIKE ? OR `keywords` LIKE ?) - AND `community` = ?', - Protocol::ACTIVITYPUB, Protocol::DFRN, $ostatus, $diaspora, + AND `forum` = ?', + Protocol::ACTIVITYPUB, Protocol::DFRN, $ostatus, $diaspora, 0, $wildcard, $wildcard, $wildcard, $wildcard, $wildcard, $wildcard, ($type === self::TYPE_FORUM), - ]); + ]; + + $count = DBA::count('contact', $condition); $resultList = new ResultList($start, $itemPage, $count); @@ -202,18 +199,7 @@ class Search return $resultList; } - $data = DBA::select('gcontact', ['nurl'], [ - 'NOT `hide` - AND `network` IN (?, ?, ?, ?) - AND ((`last_contact` >= `last_failure`) OR (`updated` >= `last_failure`)) - AND (`url` LIKE ? OR `name` LIKE ? OR `location` LIKE ? - OR `addr` LIKE ? OR `about` LIKE ? OR `keywords` LIKE ?) - AND `community` = ?', - Protocol::ACTIVITYPUB, Protocol::DFRN, $ostatus, $diaspora, - $wildcard, $wildcard, $wildcard, - $wildcard, $wildcard, $wildcard, - ($type === self::TYPE_FORUM), - ], [ + $data = DBA::select('contact', [], $condition, [ 'group_by' => ['nurl', 'updated'], 'limit' => [$start, $itemPage], 'order' => ['updated' => 'DESC'] @@ -223,21 +209,7 @@ class Search return $resultList; } - while ($row = DBA::fetch($data)) { - $urlParts = parse_url($row["nurl"]); - - // Ignore results that look strange. - // For historic reasons the gcontact table does contain some garbage. - if (!empty($urlParts['query']) || !empty($urlParts['fragment'])) { - continue; - } - - $contact = Contact::getDetailsByURL($row["nurl"], local_user()); - - if ($contact["name"] == "") { - $contact["name"] = end(explode("/", $urlParts["path"])); - } - + while ($contact = DBA::fetch($data)) { $result = new ContactResult( $contact["name"], $contact["addr"], @@ -245,8 +217,8 @@ class Search $contact["url"], $contact["photo"], $contact["network"], - $contact["cid"], - $contact["zid"], + $contact["cid"] ?? 0, + $contact["zid"] ?? 0, $contact["keywords"] ); @@ -262,7 +234,7 @@ class Search } /** - * Searching for global contacts for autocompletion + * Searching for contacts for autocompletion * * @param string $search Name or part of a name or nick * @param string $mode Search mode (e.g. "community") @@ -270,8 +242,10 @@ class Search * @return array with the search results * @throws HTTPException\InternalServerErrorException */ - public static function searchGlobalContact($search, $mode, int $page = 1) + public static function searchContact($search, $mode, int $page = 1) { + Logger::info('Searching', ['search' => $search, 'mode' => $mode, 'page' => $page]); + if (DI::config()->get('system', 'block_public') && !Session::isAuthenticated()) { return []; } @@ -287,10 +261,10 @@ class Search // check if searching in the local global contact table is enabled if (DI::config()->get('system', 'poco_local_search')) { - $return = GContact::searchByName($search, $mode); + $return = Contact::searchByName($search, $mode); } else { $p = $page > 1 ? 'p=' . $page : ''; - $curlResult = Network::curl(self::getGlobalDirectory() . '/search/people?' . $p . '&q=' . urlencode($search), false, ['accept_content' => 'application/json']); + $curlResult = DI::httpRequest()->get(self::getGlobalDirectory() . '/search/people?' . $p . '&q=' . urlencode($search), ['accept_content' => 'application/json']); if ($curlResult->isSuccess()) { $searchResult = json_decode($curlResult->getBody(), true); if (!empty($searchResult['profiles'])) { @@ -311,4 +285,19 @@ class Search { return DI::config()->get('system', 'directory', self::DEFAULT_DIRECTORY); } + + /** + * Return the search path (either fulltext search or tag search) + * + * @param string $search + * @return string search path + */ + public static function getSearchPath(string $search) + { + if (substr($search, 0, 1) == '#') { + return 'search?tag=' . urlencode(substr($search, 1)); + } else { + return 'search?q=' . urlencode($search); + } + } } diff --git a/src/Core/Session.php b/src/Core/Session.php index f08c68ed08..c4fbb3f8c6 100644 --- a/src/Core/Session.php +++ b/src/Core/Session.php @@ -65,20 +65,28 @@ class Session } /** - * Returns contact ID for given user ID + * Return the user contact ID of a visitor for the given user ID they are visiting * * @param integer $uid User ID - * @return integer Contact ID of visitor for given user ID + * @return integer */ public static function getRemoteContactID($uid) { $session = DI::session(); - if (empty($session->get('remote')[$uid])) { - return 0; + if (!empty($session->get('remote')[$uid])) { + $remote = $session->get('remote')[$uid]; + } else { + $remote = 0; } - return $session->get('remote')[$uid]; + $local_user = !empty($session->get('authenticated')) ? $session->get('uid') : 0; + + if (empty($remote) && ($local_user != $uid) && !empty($my_address = $session->get('my_address'))) { + $remote = Contact::getIdForURL($my_address, $uid, false); + } + + return $remote; } /** @@ -111,7 +119,7 @@ class Session $remote_contacts = DBA::select('contact', ['id', 'uid'], ['nurl' => Strings::normaliseLink($session->get('my_url')), 'rel' => [Contact::FOLLOWER, Contact::FRIEND], 'self' => false]); while ($contact = DBA::fetch($remote_contacts)) { - if (($contact['uid'] == 0) || Contact::isBlockedByUser($contact['id'], $contact['uid'])) { + if (($contact['uid'] == 0) || Contact\User::isBlocked($contact['id'], $contact['uid'])) { continue; } diff --git a/src/Core/Session/Handler/Cache.php b/src/Core/Session/Handler/Cache.php index 5aec68e634..af82deb586 100644 --- a/src/Core/Session/Handler/Cache.php +++ b/src/Core/Session/Handler/Cache.php @@ -87,7 +87,7 @@ class Cache implements SessionHandlerInterface } if (!$session_data) { - return true; + return $this->destroy($session_id); } return $this->cache->set('session:' . $session_id, $session_data, Session::$expire); diff --git a/src/Core/Session/Handler/Database.php b/src/Core/Session/Handler/Database.php index 3c2f9027a5..c61402954d 100644 --- a/src/Core/Session/Handler/Database.php +++ b/src/Core/Session/Handler/Database.php @@ -94,7 +94,7 @@ class Database implements SessionHandlerInterface } if (!$session_data) { - return true; + return $this->destroy($session_id); } $expire = time() + Session::$expire; diff --git a/src/Core/System.php b/src/Core/System.php index 46f0cb4f32..e84fcb5737 100644 --- a/src/Core/System.php +++ b/src/Core/System.php @@ -33,32 +33,35 @@ class System /** * Returns a string with a callstack. Can be used for logging. * - * @param integer $depth optional, default 4 + * @param integer $depth How many calls to include in the stacks after filtering + * @param int $offset How many calls to shave off the top of the stack, for example if + * this is called from a centralized method that isn't relevant to the callstack * @return string */ - public static function callstack($depth = 4) + public static function callstack(int $depth = 4, int $offset = 0) { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); - // We remove the first two items from the list since they contain data that we don't need. - array_shift($trace); - array_shift($trace); + // We remove at least the first two items from the list since they contain data that we don't need. + $trace = array_slice($trace, 2 + $offset); $callstack = []; - $previous = ['class' => '', 'function' => '']; + $previous = ['class' => '', 'function' => '', 'database' => false]; // The ignore list contains all functions that are only wrapper functions - $ignore = ['fetchUrl', 'call_user_func_array']; + $ignore = ['call_user_func_array']; while ($func = array_pop($trace)) { if (!empty($func['class'])) { - // Don't show multiple calls from the "dba" class to show the essential parts of the callstack - if ((($previous['class'] != $func['class']) || ($func['class'] != 'Friendica\Database\DBA')) && ($previous['function'] != 'q')) { + // Don't show multiple calls from the Database classes to show the essential parts of the callstack + $func['database'] = in_array($func['class'], ['Friendica\Database\DBA', 'Friendica\Database\Database']); + if (!$previous['database'] || !$func['database']) { $classparts = explode("\\", $func['class']); $callstack[] = array_pop($classparts).'::'.$func['function']; $previous = $func; } } elseif (!in_array($func['function'], $ignore)) { + $func['database'] = ($func['function'] == 'q'); $callstack[] = $func['function']; $func['class'] = ''; $previous = $func; @@ -134,12 +137,13 @@ class System * and adds an application/json HTTP header to the output. * After finishing the process is getting killed. * - * @param mixed $x The input content. - * @param string $content_type Type of the input (Default: 'application/json'). + * @param mixed $x The input content. + * @param string $content_type Type of the input (Default: 'application/json'). + * @param integer $options JSON options */ - public static function jsonExit($x, $content_type = 'application/json') { + public static function jsonExit($x, $content_type = 'application/json', int $options = 0) { header("Content-type: $content_type"); - echo json_encode($x); + echo json_encode($x, $options); exit(); } diff --git a/src/Core/Theme.php b/src/Core/Theme.php index 03f1dfd9cd..334f31a6e5 100644 --- a/src/Core/Theme.php +++ b/src/Core/Theme.php @@ -90,7 +90,7 @@ class Theme $stamp1 = microtime(true); $theme_file = file_get_contents("view/theme/$theme/theme.php"); - DI::profiler()->saveTimestamp($stamp1, "file", System::callstack()); + DI::profiler()->saveTimestamp($stamp1, "file"); $result = preg_match("|/\*.*\*/|msU", $theme_file, $matches); @@ -158,6 +158,8 @@ class Theme if (function_exists($func)) { $func(); } + + Hook::delete(['file' => "view/theme/$theme/theme.php"]); } $allowed_themes = Theme::getAllowedList(); diff --git a/src/Core/Update.php b/src/Core/Update.php index 7a03b37695..1d8f88d012 100644 --- a/src/Core/Update.php +++ b/src/Core/Update.php @@ -41,7 +41,7 @@ class Update * * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - public static function check($basePath, $via_worker, App\Mode $mode) + public static function check(string $basePath, bool $via_worker, App\Mode $mode) { if (!DBA::connected()) { return; @@ -80,15 +80,15 @@ class Update * Automatic database updates * * @param string $basePath The base path of this application - * @param bool $force Force the Update-Check even if the database version doesn't match - * @param bool $override Overrides any running/stuck updates - * @param bool $verbose Run the Update-Check verbose - * @param bool $sendMail Sends a Mail to the administrator in case of success/failure + * @param bool $force Force the Update-Check even if the database version doesn't match + * @param bool $override Overrides any running/stuck updates + * @param bool $verbose Run the Update-Check verbose + * @param bool $sendMail Sends a Mail to the administrator in case of success/failure * * @return string Empty string if the update is successful, error messages otherwise * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - public static function run($basePath, $force = false, $override = false, $verbose = false, $sendMail = true) + public static function run(string $basePath, bool $force = false, bool $override = false, bool $verbose = false, bool $sendMail = true) { // In force mode, we release the dbupdate lock first // Necessary in case of an stuck update @@ -111,31 +111,36 @@ class Update if ($stored < $current || $force) { DI::config()->load('database'); - Logger::info('Update starting.', ['from' => $stored, 'to' => $current]); - // Compare the current structure with the defined structure // If the Lock is acquired, never release it automatically to avoid double updates - if (DI::lock()->acquire('dbupdate', 120, Cache\Duration::INFINITE)) { + if (DI::lock()->acquire('dbupdate', 0, Cache\Duration::INFINITE)) { + + Logger::notice('Update starting.', ['from' => $stored, 'to' => $current]); // Checks if the build changed during Lock acquiring (so no double update occurs) $retryBuild = DI::config()->get('system', 'build', null, true); if ($retryBuild !== $build) { - Logger::info('Update already done.', ['from' => $stored, 'to' => $current]); + Logger::notice('Update already done.', ['from' => $stored, 'to' => $current]); DI::lock()->release('dbupdate'); return ''; } // run the pre_update_nnnn functions in update.php - for ($x = $stored + 1; $x <= $current; $x++) { - $r = self::runUpdateFunction($x, 'pre_update'); + for ($version = $stored + 1; $version <= $current; $version++) { + Logger::notice('Execute pre update.', ['version' => $version]); + $r = self::runUpdateFunction($version, 'pre_update', $sendMail); if (!$r) { + Logger::warning('Pre update failed', ['version' => $version]); DI::config()->set('system', 'update', Update::FAILED); DI::lock()->release('dbupdate'); return $r; + } else { + Logger::notice('Pre update executed.', ['version' => $version]); } } // update the structure in one call + Logger::notice('Execute structure update'); $retval = DBStructure::update($basePath, $verbose, true); if (!empty($retval)) { if ($sendMail) { @@ -149,28 +154,32 @@ class Update DI::lock()->release('dbupdate'); return $retval; } else { - DI::config()->set('database', 'last_successful_update', $current); - DI::config()->set('database', 'last_successful_update_time', time()); - Logger::info('Update finished.', ['from' => $stored, 'to' => $current]); + Logger::notice('Database structure update finished.', ['from' => $stored, 'to' => $current]); } // run the update_nnnn functions in update.php - for ($x = $stored + 1; $x <= $current; $x++) { - $r = self::runUpdateFunction($x, 'update'); + for ($version = $stored + 1; $version <= $current; $version++) { + Logger::notice('Execute post update.', ['version' => $version]); + $r = self::runUpdateFunction($version, 'update', $sendMail); if (!$r) { + Logger::warning('Post update failed', ['version' => $version]); DI::config()->set('system', 'update', Update::FAILED); DI::lock()->release('dbupdate'); return $r; + } else { + DI::config()->set('system', 'build', $version); + Logger::notice('Post update executed.', ['version' => $version]); } } - Logger::notice('Update success.', ['from' => $stored, 'to' => $current]); - if ($sendMail) { - self::updateSuccessfull($stored, $current); - } - + DI::config()->set('system', 'build', $current); DI::config()->set('system', 'update', Update::SUCCESS); DI::lock()->release('dbupdate'); + + Logger::notice('Update success.', ['from' => $stored, 'to' => $current]); + if ($sendMail) { + self::updateSuccessful($stored, $current); + } } } } @@ -181,17 +190,18 @@ class Update /** * Executes a specific update function * - * @param int $x the DB version number of the function - * @param string $prefix the prefix of the function (update, pre_update) - * + * @param int $version the DB version number of the function + * @param string $prefix the prefix of the function (update, pre_update) + * @param bool $sendMail whether to send emails on success/failure + * @return bool true, if the update function worked * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - public static function runUpdateFunction($x, $prefix) + public static function runUpdateFunction(int $version, string $prefix, bool $sendMail = true) { - $funcname = $prefix . '_' . $x; + $funcname = $prefix . '_' . $version; - Logger::info('Update function start.', ['function' => $funcname]); + Logger::notice('Update function start.', ['function' => $funcname]); if (function_exists($funcname)) { // There could be a lot of processes running or about to run. @@ -204,40 +214,32 @@ class Update if (DI::lock()->acquire('dbupdate_function', 120, Cache\Duration::INFINITE)) { // call the specific update + Logger::notice('Pre update function start.', ['function' => $funcname]); $retval = $funcname(); + Logger::notice('Update function done.', ['function' => $funcname]); if ($retval) { - //send the administrator an e-mail - self::updateFailed( - $x, - DI::l10n()->t('Update %s failed. See error logs.', $x) - ); + if ($sendMail) { + //send the administrator an e-mail + self::updateFailed( + $version, + DI::l10n()->t('Update %s failed. See error logs.', $version) + ); + } Logger::error('Update function ERROR.', ['function' => $funcname, 'retval' => $retval]); DI::lock()->release('dbupdate_function'); return false; } else { - DI::config()->set('database', 'last_successful_update_function', $funcname); - DI::config()->set('database', 'last_successful_update_function_time', time()); - - if ($prefix == 'update') { - DI::config()->set('system', 'build', $x); - } - DI::lock()->release('dbupdate_function'); - Logger::info('Update function finished.', ['function' => $funcname]); + Logger::notice('Update function finished.', ['function' => $funcname]); return true; } + } else { + Logger::error('Locking failed.', ['function' => $funcname]); + return false; } } else { - Logger::info('Update function skipped.', ['function' => $funcname]); - - DI::config()->set('database', 'last_successful_update_function', $funcname); - DI::config()->set('database', 'last_successful_update_function_time', time()); - - if ($prefix == 'update') { - DI::config()->set('system', 'build', $x); - } - + Logger::notice('Update function skipped.', ['function' => $funcname]); return true; } } @@ -249,9 +251,9 @@ class Update * @param string $error_message error message * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - private static function updateFailed($update_id, $error_message) { + private static function updateFailed(int $update_id, string $error_message) { //send the administrators an e-mail - $condition = ['email' => explode(",", str_replace(" ", "", DI::config()->get('config', 'admin_email'))), 'parent-uid' => 0]; + $condition = ['email' => explode(',', str_replace(' ', '', DI::config()->get('config', 'admin_email'))), 'parent-uid' => 0]; $adminlist = DBA::select('user', ['uid', 'language', 'email'], $condition, ['order' => ['uid']]); // No valid result? @@ -280,7 +282,7 @@ class Update This needs to be fixed soon and I can't do it alone. Please contact a friendica developer if you can not help me on your own. My database might be invalid.", $update_id)); - $body = $l10n->t("The error message is\n[pre]%s[/pre]", $error_message); + $body = $l10n->t('The error message is\n[pre]%s[/pre]', $error_message); $email = DI::emailer() ->newSystemMail() @@ -291,14 +293,20 @@ class Update DI::emailer()->send($email); } - //try the logger - Logger::alert('Database structure update FAILED.', ['error' => $error_message]); + Logger::alert('Database structure update failed.', ['error' => $error_message]); } - private static function updateSuccessfull($from_build, $to_build) + /** + * Send a mail to the administrator about the successful update + * + * @param integer $from_build + * @param integer $to_build + * @return void + */ + private static function updateSuccessful(int $from_build, int $to_build) { //send the administrators an e-mail - $condition = ['email' => explode(",", str_replace(" ", "", DI::config()->get('config', 'admin_email'))), 'parent-uid' => 0]; + $condition = ['email' => explode(',', str_replace(' ', '', DI::config()->get('config', 'admin_email'))), 'parent-uid' => 0]; $adminlist = DBA::select('user', ['uid', 'language', 'email'], $condition, ['order' => ['uid']]); if (DBA::isResult($adminlist)) { @@ -314,8 +322,8 @@ class Update $lang = (($admin['language']) ? $admin['language'] : 'en'); $l10n = DI::l10n()->withLang($lang); - $preamble = Strings::deindent($l10n->t(" - The friendica database was successfully updated from %s to %s.", + $preamble = Strings::deindent($l10n->t(' + The friendica database was successfully updated from %s to %s.', $from_build, $to_build)); $email = DI::emailer() @@ -328,7 +336,6 @@ class Update } } - //try the logger Logger::debug('Database structure update successful.'); } } diff --git a/src/Core/UserImport.php b/src/Core/UserImport.php index 06ba6398a8..ed131910c7 100644 --- a/src/Core/UserImport.php +++ b/src/Core/UserImport.php @@ -271,7 +271,7 @@ class UserImport if ($r === false) { Logger::log("uimport:insert profile: ERROR : " . DBA::errorMessage(), Logger::INFO); - info(DI::l10n()->t("User profile creation error")); + notice(DI::l10n()->t("User profile creation error")); DBA::delete('user', ['uid' => $newuid]); DBA::delete('profile_field', ['uid' => $newuid]); return; diff --git a/src/Core/Worker.php b/src/Core/Worker.php index 24febf3bc4..56c62451b9 100644 --- a/src/Core/Worker.php +++ b/src/Core/Worker.php @@ -21,12 +21,11 @@ namespace Friendica\Core; +use Friendica\App\Mode; use Friendica\Core; use Friendica\Database\DBA; use Friendica\DI; -use Friendica\Model\Process; use Friendica\Util\DateTimeFormat; -use Friendica\Util\Network; /** * Contains the class for the worker background job processing @@ -38,8 +37,10 @@ class Worker const STATE_REFETCH = 3; // Worker had refetched jobs in the execution loop. const STATE_SHORT_LOOP = 4; // Worker is processing preassigned jobs, thus saving much time. - const FAST_COMMANDS = ['APDelivery', 'Delivery', 'CreateShadowEntry']; + const FAST_COMMANDS = ['APDelivery', 'Delivery']; + const LOCK_PROCESS = 'worker_process'; + const LOCK_WORKER = 'worker'; private static $up_start; private static $db_duration = 0; @@ -49,6 +50,7 @@ class Worker private static $lock_duration = 0; private static $last_update; private static $state; + private static $daemon_mode = null; /** * Processes the tasks that are in the workerqueue table @@ -66,12 +68,12 @@ class Worker // At first check the maximum load. We shouldn't continue with a high load if (DI::process()->isMaxLoadReached()) { - Logger::log('Pre check: maximum load reached, quitting.', Logger::DEBUG); + Logger::notice('Pre check: maximum load reached, quitting.'); return; } // We now start the process. This is done after the load check since this could increase the load. - self::startProcess(); + DI::process()->start(); // Kill stale processes every 5 minutes $last_cleanup = DI::config()->get('system', 'worker_last_cleaned', 0); @@ -80,27 +82,8 @@ class Worker self::killStaleWorkers(); } - // Count active workers and compare them with a maximum value that depends on the load - if (self::tooMuchWorkers()) { - Logger::log('Pre check: Active worker limit reached, quitting.', Logger::DEBUG); - return; - } - - // Do we have too few memory? - if (DI::process()->isMinMemoryReached()) { - Logger::log('Pre check: Memory limit reached, quitting.', Logger::DEBUG); - return; - } - - // Possibly there are too much database connections - if (self::maxConnectionsReached()) { - Logger::log('Pre check: maximum connections reached, quitting.', Logger::DEBUG); - return; - } - - // Possibly there are too much database processes that block the system - if (DI::process()->isMaxProcessesReached()) { - Logger::log('Pre check: maximum processes reached, quitting.', Logger::DEBUG); + // Check if the system is ready + if (!self::isReady()) { return; } @@ -109,26 +92,31 @@ class Worker self::runCron(); } - $starttime = time(); + $last_check = $starttime = time(); self::$state = self::STATE_STARTUP; // We fetch the next queue entry that is about to be executed while ($r = self::workerProcess()) { - $refetched = false; + if (self::IPCJobsExists(getmypid())) { + self::IPCDeleteJobState(getmypid()); + } + + // Don't refetch when a worker fetches tasks for multiple workers + $refetched = DI::config()->get('system', 'worker_multiple_fetch'); foreach ($r as $entry) { // Assure that the priority is an integer value $entry['priority'] = (int)$entry['priority']; // The work will be done if (!self::execute($entry)) { - Logger::log('Process execution failed, quitting.', Logger::DEBUG); + Logger::notice('Process execution failed, quitting.'); return; } // Trying to fetch new processes - but only once when successful - if (!$refetched && DI::lock()->acquire('worker_process', 0)) { + if (!$refetched && DI::lock()->acquire(self::LOCK_PROCESS, 0)) { self::findWorkerProcesses(); - DI::lock()->release('worker_process'); + DI::lock()->release(self::LOCK_PROCESS); self::$state = self::STATE_REFETCH; $refetched = true; } else { @@ -137,40 +125,82 @@ class Worker } // To avoid the quitting of multiple workers only one worker at a time will execute the check - if (!self::getWaitingJobForPID()) { + if ((time() > $last_check + 5) && !self::getWaitingJobForPID()) { self::$state = self::STATE_LONG_LOOP; - if (DI::lock()->acquire('worker', 0)) { + if (DI::lock()->acquire(self::LOCK_WORKER, 0)) { // Count active workers and compare them with a maximum value that depends on the load if (self::tooMuchWorkers()) { - Logger::log('Active worker limit reached, quitting.', Logger::DEBUG); - DI::lock()->release('worker'); + Logger::notice('Active worker limit reached, quitting.'); + DI::lock()->release(self::LOCK_WORKER); return; } // Check free memory if (DI::process()->isMinMemoryReached()) { - Logger::log('Memory limit reached, quitting.', Logger::DEBUG); - DI::lock()->release('worker'); + Logger::warning('Memory limit reached, quitting.'); + DI::lock()->release(self::LOCK_WORKER); return; } - DI::lock()->release('worker'); + DI::lock()->release(self::LOCK_WORKER); } + $last_check = time(); } // Quit the worker once every cron interval if (time() > ($starttime + (DI::config()->get('system', 'cron_interval') * 60))) { Logger::info('Process lifetime reached, respawning.'); - self::spawnWorker(); + self::unclaimProcess(); + if (self::isDaemonMode()) { + self::IPCSetJobState(true); + } else { + self::spawnWorker(); + } return; } } // Cleaning up. Possibly not needed, but it doesn't harm anything. - if (DI::config()->get('system', 'worker_daemon_mode', false)) { + if (self::isDaemonMode()) { self::IPCSetJobState(false); } - Logger::log("Couldn't select a workerqueue entry, quitting process " . getmypid() . ".", Logger::DEBUG); + Logger::info("Couldn't select a workerqueue entry, quitting process", ['pid' => getmypid()]); + } + + /** + * Checks if the system is ready. + * + * Several system parameters like memory, connections and processes are checked. + * + * @return boolean + */ + public static function isReady() + { + // Count active workers and compare them with a maximum value that depends on the load + if (self::tooMuchWorkers()) { + Logger::notice('Active worker limit reached, quitting.'); + return false; + } + + // Do we have too few memory? + if (DI::process()->isMinMemoryReached()) { + Logger::warning('Memory limit reached, quitting.'); + return false; + } + + // Possibly there are too much database connections + if (self::maxConnectionsReached()) { + Logger::warning('Maximum connections reached, quitting.'); + return false; + } + + // Possibly there are too much database processes that block the system + if (DI::process()->isMaxProcessesReached()) { + Logger::warning('Maximum processes reached, quitting.'); + return false; + } + + return true; } /** @@ -179,7 +209,7 @@ class Worker * @return boolean Returns "true" if tasks are existing * @throws \Exception */ - private static function entriesExists() + public static function entriesExists() { $stamp = (float)microtime(true); $exists = DBA::exists('workerqueue', ["NOT `done` AND `pid` = 0 AND `next_try` < ?", DateTimeFormat::utcNow()]); @@ -264,23 +294,31 @@ class Worker // Quit when in maintenance if (DI::config()->get('system', 'maintenance', false, true)) { - Logger::log("Maintenance mode - quit process ".$mypid, Logger::DEBUG); + Logger::notice("Maintenance mode - quit process", ['pid' => $mypid]); return false; } // Constantly check the number of parallel database processes if (DI::process()->isMaxProcessesReached()) { - Logger::log("Max processes reached for process ".$mypid, Logger::DEBUG); + Logger::warning("Max processes reached for process", ['pid' => $mypid]); return false; } // Constantly check the number of available database connections to let the frontend be accessible at any time if (self::maxConnectionsReached()) { - Logger::log("Max connection reached for process ".$mypid, Logger::DEBUG); + Logger::warning("Max connection reached for process", ['pid' => $mypid]); return false; } - $argv = json_decode($queue["parameter"], true); + $argv = json_decode($queue['parameter'], true); + if (!empty($queue['command'])) { + array_unshift($argv, $queue['command']); + } + + if (empty($argv)) { + Logger::warning('Parameter is empty', ['queue' => $queue]); + return false; + } // Check for existance and validity of the include file $include = $argv[0]; @@ -322,7 +360,7 @@ class Worker } if (!validate_include($include)) { - Logger::log("Include file ".$argv[0]." is not valid!"); + Logger::warning("Include file is not valid", ['file' => $argv[0]]); $stamp = (float)microtime(true); DBA::delete('workerqueue', ['id' => $queue["id"]]); self::$db_duration = (microtime(true) - $stamp); @@ -359,7 +397,7 @@ class Worker self::$db_duration = (microtime(true) - $stamp); self::$db_duration_write += (microtime(true) - $stamp); } else { - Logger::log("Function ".$funcname." does not exist"); + Logger::warning("Function does not exist", ['function' => $funcname]); $stamp = (float)microtime(true); DBA::delete('workerqueue', ['id' => $queue["id"]]); self::$db_duration = (microtime(true) - $stamp); @@ -383,7 +421,11 @@ class Worker { $a = DI::app(); - $argc = count($argv); + $cooldown = DI::config()->get("system", "worker_cooldown", 0); + if ($cooldown > 0) { + Logger::info('Pre execution cooldown.', ['priority' => $queue["priority"], 'id' => $queue["id"], 'cooldown' => $cooldown]); + sleep($cooldown); + } Logger::enableWorker($funcname); @@ -395,6 +437,11 @@ class Worker // For this reason the variables have to be initialized. DI::profiler()->reset(); + if (!in_array($queue['priority'], PRIORITIES)) { + Logger::warning('Invalid priority', ['queue' => $queue, 'callstack' => System::callstack(20)]); + $queue['priority'] = PRIORITY_MEDIUM; + } + $a->queue = $queue; $up_duration = microtime(true) - self::$up_start; @@ -406,7 +453,7 @@ class Worker if ($method_call) { call_user_func_array(sprintf('Friendica\Worker\%s::execute', $funcname), $argv); } else { - $funcname($argv, $argc); + $funcname($argv, count($argv)); } Logger::disableWorker(); @@ -452,10 +499,8 @@ class Worker DI::profiler()->saveLog(DI::logger(), "ID " . $queue["id"] . ": " . $funcname); - $cooldown = DI::config()->get("system", "worker_cooldown", 0); - if ($cooldown > 0) { - Logger::info('Cooldown.', ['priority' => $queue["priority"], 'id' => $queue["id"], 'cooldown' => $cooldown]); + Logger::info('Post execution cooldown.', ['priority' => $queue["priority"], 'id' => $queue["id"], 'cooldown' => $cooldown]); sleep($cooldown); } } @@ -504,12 +549,12 @@ class Worker $used = DBA::numRows($r); DBA::close($r); - Logger::log("Connection usage (user values): ".$used."/".$max, Logger::DEBUG); + Logger::info("Connection usage (user values)", ['usage' => $used, 'max' => $max]); $level = ($used / $max) * 100; if ($level >= $maxlevel) { - Logger::log("Maximum level (".$maxlevel."%) of user connections reached: ".$used."/".$max); + Logger::warning("Maximum level (".$maxlevel."%) of user connections reached: ".$used."/".$max); return true; } } @@ -532,14 +577,14 @@ class Worker if ($used == 0) { return false; } - Logger::log("Connection usage (system values): ".$used."/".$max, Logger::DEBUG); + Logger::info("Connection usage (system values)", ['used' => $used, 'max' => $max]); $level = $used / $max * 100; if ($level < $maxlevel) { return false; } - Logger::log("Maximum level (".$level."%) of system connections reached: ".$used."/".$max); + Logger::warning("Maximum level (".$level."%) of system connections reached: ".$used."/".$max); return true; } @@ -554,9 +599,9 @@ class Worker $stamp = (float)microtime(true); $entries = DBA::select( 'workerqueue', - ['id', 'pid', 'executed', 'priority', 'parameter'], + ['id', 'pid', 'executed', 'priority', 'command', 'parameter'], ['NOT `done` AND `pid` != 0'], - ['order' => ['priority', 'created']] + ['order' => ['priority', 'retrial', 'created']] ); self::$db_duration += (microtime(true) - $stamp); @@ -581,13 +626,21 @@ class Worker $max_duration_defaults = [PRIORITY_CRITICAL => 720, PRIORITY_HIGH => 10, PRIORITY_MEDIUM => 60, PRIORITY_LOW => 180, PRIORITY_NEGLIGIBLE => 720]; $max_duration = $max_duration_defaults[$entry["priority"]]; - $argv = json_decode($entry["parameter"], true); - $argv[0] = basename($argv[0]); + $argv = json_decode($entry['parameter'], true); + if (!empty($entry['command'])) { + $command = $entry['command']; + } elseif (!empty($argv)) { + $command = array_shift($argv); + } else { + return; + } + + $command = basename($command); // How long is the process already running? $duration = (time() - strtotime($entry["executed"])) / 60; if ($duration > $max_duration) { - Logger::log("Worker process ".$entry["pid"]." (".substr(json_encode($argv), 0, 50).") took more than ".$max_duration." minutes. It will be killed now."); + Logger::notice('Worker process took too much time - killed', ['duration' => number_format($duration, 3), 'max' => $max_duration, 'id' => $entry["id"], 'pid' => $entry["pid"], 'command' => $command]); posix_kill($entry["pid"], SIGTERM); // We killed the stale process. @@ -610,7 +663,7 @@ class Worker self::$db_duration += (microtime(true) - $stamp); self::$db_duration_write += (microtime(true) - $stamp); } else { - Logger::log("Worker process ".$entry["pid"]." (".substr(json_encode($argv), 0, 50).") now runs for ".round($duration)." of ".$max_duration." allowed minutes. That's okay.", Logger::DEBUG); + Logger::info('Process runtime is okay', ['duration' => number_format($duration, 3), 'max' => $max_duration, 'id' => $entry["id"], 'pid' => $entry["pid"], 'command' => $command]); } } } @@ -721,17 +774,17 @@ class Worker $high_running = self::processWithPriorityActive($top_priority); if (!$high_running && ($top_priority > PRIORITY_UNDEFINED) && ($top_priority < PRIORITY_NEGLIGIBLE)) { - Logger::log("There are jobs with priority ".$top_priority." waiting but none is executed. Open a fastlane.", Logger::DEBUG); + Logger::info("Jobs with a higher priority are waiting but none is executed. Open a fastlane.", ['priority' => $top_priority]); $queues = $active + 1; } } - Logger::log("Load: " . $load ."/" . $maxsysload . " - processes: " . $deferred . "/" . $active . "/" . $waiting_processes . $processlist . " - maximum: " . $queues . "/" . $maxqueues, Logger::DEBUG); + Logger::notice("Load: " . $load ."/" . $maxsysload . " - processes: " . $deferred . "/" . $active . "/" . $waiting_processes . $processlist . " - maximum: " . $queues . "/" . $maxqueues); // Are there fewer workers running as possible? Then fork a new one. if (!DI::config()->get("system", "worker_dont_fork", false) && ($queues > ($active + 1)) && self::entriesExists()) { - Logger::log("Active workers: ".$active."/".$queues." Fork a new worker.", Logger::DEBUG); - if (DI::config()->get('system', 'worker_daemon_mode', false)) { + Logger::info("There are fewer workers as possible, fork a new worker.", ['active' => $active, 'queues' => $queues]); + if (self::isDaemonMode()) { self::IPCSetJobState(true); } else { self::spawnWorker(); @@ -740,7 +793,7 @@ class Worker } // if there are too much worker, we don't spawn a new one. - if (DI::config()->get('system', 'worker_daemon_mode', false) && ($active > $queues)) { + if (self::isDaemonMode() && ($active > $queues)) { self::IPCSetJobState(false); } @@ -758,9 +811,34 @@ class Worker $stamp = (float)microtime(true); $count = DBA::count('process', ['command' => 'Worker.php']); self::$db_duration += (microtime(true) - $stamp); + self::$db_duration_count += (microtime(true) - $stamp); return $count; } + /** + * Returns the number of active worker processes + * + * @return array List of worker process ids + * @throws \Exception + */ + private static function getWorkerPIDList() + { + $ids = []; + $stamp = (float)microtime(true); + + $queues = DBA::p("SELECT `process`.`pid`, COUNT(`workerqueue`.`pid`) AS `entries` FROM `process` + LEFT JOIN `workerqueue` ON `workerqueue`.`pid` = `process`.`pid` AND NOT `workerqueue`.`done` + GROUP BY `process`.`pid`"); + while ($queue = DBA::fetch($queues)) { + $ids[$queue['pid']] = $queue['entries']; + } + DBA::close($queues); + + self::$db_duration += (microtime(true) - $stamp); + self::$db_duration_count += (microtime(true) - $stamp); + return $ids; + } + /** * Returns waiting jobs for the current process id * @@ -782,11 +860,11 @@ class Worker /** * Returns the next jobs that should be executed - * + * @param int $limit * @return array array with next jobs * @throws \Exception */ - private static function nextProcess() + private static function nextProcess(int $limit) { $priority = self::nextPriority(); if (empty($priority)) { @@ -794,17 +872,20 @@ class Worker return []; } - $limit = DI::config()->get('system', 'worker_fetch_limit', 1); - $ids = []; $stamp = (float)microtime(true); $condition = ["`priority` = ? AND `pid` = 0 AND NOT `done` AND `next_try` < ?", $priority, DateTimeFormat::utcNow()]; - $tasks = DBA::select('workerqueue', ['id', 'parameter'], $condition, ['limit' => $limit, 'order' => ['created']]); + $tasks = DBA::select('workerqueue', ['id', 'command', 'parameter'], $condition, ['limit' => $limit, 'order' => ['retrial', 'created']]); self::$db_duration += (microtime(true) - $stamp); while ($task = DBA::fetch($tasks)) { $ids[] = $task['id']; // Only continue that loop while we are storing commands that can be processed quickly - $command = json_decode($task['parameter'])[0]; + if (!empty($task['command'])) { + $command = $task['command']; + } else { + $command = json_decode($task['parameter'])[0]; + } + if (!in_array($command, self::FAST_COMMANDS)) { break; } @@ -894,23 +975,42 @@ class Worker */ private static function findWorkerProcesses() { - $mypid = getmypid(); + $fetch_limit = DI::config()->get('system', 'worker_fetch_limit', 1); - $ids = self::nextProcess(); + if (DI::config()->get('system', 'worker_multiple_fetch')) { + $pids = []; + foreach (self::getWorkerPIDList() as $pid => $count) { + if ($count <= $fetch_limit) { + $pids[] = $pid; + } + } + if (empty($pids)) { + return; + } + $limit = $fetch_limit * count($pids); + } else { + $pids = [getmypid()]; + $limit = $fetch_limit; + } - // If there is no result we check without priority limit - if (empty($ids)) { - $limit = DI::config()->get('system', 'worker_fetch_limit', 1); + $ids = self::nextProcess($limit); + $limit -= count($ids); + // If there is not enough results we check without priority limit + if ($limit > 0) { $stamp = (float)microtime(true); $condition = ["`pid` = 0 AND NOT `done` AND `next_try` < ?", DateTimeFormat::utcNow()]; - $tasks = DBA::select('workerqueue', ['id', 'parameter'], $condition, ['limit' => $limit, 'order' => ['priority', 'created']]); + $tasks = DBA::select('workerqueue', ['id', 'command', 'parameter'], $condition, ['limit' => $limit, 'order' => ['priority', 'retrial', 'created']]); self::$db_duration += (microtime(true) - $stamp); while ($task = DBA::fetch($tasks)) { $ids[] = $task['id']; // Only continue that loop while we are storing commands that can be processed quickly - $command = json_decode($task['parameter'])[0]; + if (!empty($task['command'])) { + $command = $task['command']; + } else { + $command = json_decode($task['parameter'])[0]; + } if (!in_array($command, self::FAST_COMMANDS)) { break; } @@ -918,21 +1018,34 @@ class Worker DBA::close($tasks); } - if (!empty($ids)) { - $stamp = (float)microtime(true); - $condition = ['id' => $ids, 'done' => false, 'pid' => 0]; - DBA::update('workerqueue', ['executed' => DateTimeFormat::utcNow(), 'pid' => $mypid], $condition); - self::$db_duration += (microtime(true) - $stamp); - self::$db_duration_write += (microtime(true) - $stamp); + if (empty($ids)) { + return; } - return !empty($ids); + // Assign the task ids to the workers + $worker = []; + foreach (array_unique($ids) as $id) { + $pid = next($pids); + if (!$pid) { + $pid = reset($pids); + } + $worker[$pid][] = $id; + } + + $stamp = (float)microtime(true); + foreach ($worker as $worker_pid => $worker_ids) { + Logger::info('Set queue entry', ['pid' => $worker_pid, 'ids' => $worker_ids]); + DBA::update('workerqueue', ['executed' => DateTimeFormat::utcNow(), 'pid' => $worker_pid], + ['id' => $worker_ids, 'done' => false, 'pid' => 0]); + } + self::$db_duration += (microtime(true) - $stamp); + self::$db_duration_write += (microtime(true) - $stamp); } /** * Returns the next worker process * - * @return string SQL statement + * @return array worker processes * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ public static function workerProcess() @@ -944,22 +1057,16 @@ class Worker } $stamp = (float)microtime(true); - if (!DI::lock()->acquire('worker_process')) { + if (!DI::lock()->acquire(self::LOCK_PROCESS)) { return false; } self::$lock_duration += (microtime(true) - $stamp); - $found = self::findWorkerProcesses(); + self::findWorkerProcesses(); - DI::lock()->release('worker_process'); + DI::lock()->release(self::LOCK_PROCESS); - if ($found) { - $stamp = (float)microtime(true); - $r = DBA::select('workerqueue', [], ['pid' => getmypid(), 'done' => false]); - self::$db_duration += (microtime(true) - $stamp); - return DBA::toArray($r); - } - return false; + return self::getWaitingJobForPID(); } /** @@ -978,93 +1085,6 @@ class Worker self::$db_duration_write += (microtime(true) - $stamp); } - /** - * Call the front end worker - * - * @return void - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function callWorker() - { - if (!DI::config()->get("system", "frontend_worker")) { - return; - } - - $url = DI::baseUrl() . '/worker'; - Network::fetchUrl($url, false, 1); - } - - /** - * Call the front end worker if there aren't any active - * - * @return void - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function executeIfIdle() - { - if (!DI::config()->get("system", "frontend_worker")) { - return; - } - - // Do we have "proc_open"? Then we can fork the worker - if (function_exists("proc_open")) { - // When was the last time that we called the worker? - // Less than one minute? Then we quit - if ((time() - DI::config()->get("system", "worker_started")) < 60) { - return; - } - - DI::config()->set("system", "worker_started", time()); - - // Do we have enough running workers? Then we quit here. - if (self::tooMuchWorkers()) { - // Cleaning dead processes - self::killStaleWorkers(); - Process::deleteInactive(); - - return; - } - - self::runCron(); - - Logger::log('Call worker', Logger::DEBUG); - self::spawnWorker(); - return; - } - - // We cannot execute background processes. - // We now run the processes from the frontend. - // This won't work with long running processes. - self::runCron(); - - self::clearProcesses(); - - $workers = self::activeWorkers(); - - if ($workers == 0) { - self::callWorker(); - } - } - - /** - * Removes long running worker processes - * - * @return void - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function clearProcesses() - { - $timeout = DI::config()->get("system", "frontend_worker_timeout", 10); - - /// @todo We should clean up the corresponding workerqueue entries as well - $stamp = (float)microtime(true); - $condition = ["`created` < ? AND `command` = 'worker.php'", - DateTimeFormat::utc("now - ".$timeout." minutes")]; - DBA::delete('process', $condition); - self::$db_duration = (microtime(true) - $stamp); - self::$db_duration_write += (microtime(true) - $stamp); - } - /** * Runs the cron processes * @@ -1073,7 +1093,7 @@ class Worker */ private static function runCron() { - Logger::log('Add cron entries', Logger::DEBUG); + Logger::info('Add cron entries'); // Check for spooled items self::add(['priority' => PRIORITY_HIGH, 'force_priority' => true], 'SpoolPost'); @@ -1085,6 +1105,67 @@ class Worker self::killStaleWorkers(); } + /** + * Fork a child process + * + * @param boolean $do_cron + * @return void + */ + private static function forkProcess(bool $do_cron) + { + if (DI::process()->isMinMemoryReached()) { + Logger::warning('Memory limit reached - quitting'); + return; + } + + // Children inherit their parent's database connection. + // To avoid problems we disconnect and connect both parent and child + DBA::disconnect(); + $pid = pcntl_fork(); + if ($pid == -1) { + DBA::connect(); + Logger::warning('Could not spawn worker'); + return; + } elseif ($pid) { + // The parent process continues here + DBA::connect(); + + self::IPCSetJobState(true, $pid); + Logger::info('Spawned new worker', ['pid' => $pid]); + + $cycles = 0; + while (self::IPCJobsExists($pid) && (++$cycles < 100)) { + usleep(10000); + } + + Logger::info('Spawned worker is ready', ['pid' => $pid, 'wait_cycles' => $cycles]); + return; + } + + // We now are in the new worker + $pid = getmypid(); + + DBA::connect(); + /// @todo Reinitialize the logger to set a new process_id and uid + DI::process()->setPid($pid); + + $cycles = 0; + while (!self::IPCJobsExists($pid) && (++$cycles < 100)) { + usleep(10000); + } + + Logger::info('Worker spawned', ['pid' => $pid, 'wait_cycles' => $cycles]); + + self::processQueue($do_cron); + + self::unclaimProcess(); + + self::IPCSetJobState(false, $pid); + DI::process()->end(); + Logger::info('Worker ended', ['pid' => $pid]); + exit(); + } + /** * Spawns a new worker * @@ -1094,16 +1175,14 @@ class Worker */ public static function spawnWorker($do_cron = false) { - $command = 'bin/worker.php'; - - $args = ['no_cron' => !$do_cron]; - - $a = DI::app(); - $process = new Core\Process(DI::logger(), DI::mode(), DI::config(), $a->getBasePath()); - $process->run($command, $args); - - // after spawning we have to remove the flag. - if (DI::config()->get('system', 'worker_daemon_mode', false)) { + if (self::isDaemonMode() && DI::config()->get('system', 'worker_fork')) { + self::forkProcess($do_cron); + } else { + $process = new Core\Process(DI::logger(), DI::mode(), DI::config(), + DI::modelProcess(), DI::app()->getBasePath(), getmypid()); + $process->run('bin/worker.php', ['no_cron' => !$do_cron]); + } + if (self::isDaemonMode()) { self::IPCSetJobState(false); } } @@ -1115,7 +1194,7 @@ class Worker * * next args are passed as $cmd command line * or: Worker::add(PRIORITY_HIGH, "Notifier", Delivery::DELETION, $drop_id); - * or: Worker::add(array('priority' => PRIORITY_HIGH, 'dont_fork' => true), "CreateShadowEntry", $post_id); + * or: Worker::add(array('priority' => PRIORITY_HIGH, 'dont_fork' => true), "Delivery", $post_id); * * @return boolean "false" if worker queue entry already existed or there had been an error * @throws \Friendica\Network\HTTPException\InternalServerErrorException @@ -1144,6 +1223,7 @@ class Worker // Don't fork from frontend tasks by default $dont_fork = DI::config()->get("system", "worker_dont_fork", false) || !DI::mode()->isBackend(); $created = DateTimeFormat::utcNow(); + $delayed = DBA::NULL_DATETIME; $force_priority = false; $run_parameter = array_shift($args); @@ -1151,6 +1231,9 @@ class Worker if (is_int($run_parameter)) { $priority = $run_parameter; } elseif (is_array($run_parameter)) { + if (isset($run_parameter['delayed'])) { + $delayed = $run_parameter['delayed']; + } if (isset($run_parameter['priority'])) { $priority = $run_parameter['priority']; } @@ -1165,45 +1248,58 @@ class Worker } } + $command = array_shift($args); $parameters = json_encode($args); - $found = DBA::exists('workerqueue', ['parameter' => $parameters, 'done' => false]); + $found = DBA::exists('workerqueue', ['command' => $command, 'parameter' => $parameters, 'done' => false]); $added = false; + if (!in_array($priority, PRIORITIES)) { + Logger::warning('Invalid priority', ['priority' => $priority, 'command' => $command, 'callstack' => System::callstack(20)]); + $priority = PRIORITY_MEDIUM; + } + // Quit if there was a database error - a precaution for the update process to 3.5.3 if (DBA::errorNo() != 0) { return false; } if (!$found) { - $added = DBA::insert('workerqueue', ['parameter' => $parameters, 'created' => $created, 'priority' => $priority]); + $added = DBA::insert('workerqueue', ['command' => $command, 'parameter' => $parameters, 'created' => $created, + 'priority' => $priority, 'next_try' => $delayed]); if (!$added) { return false; } } elseif ($force_priority) { - DBA::update('workerqueue', ['priority' => $priority], ['parameter' => $parameters, 'done' => false, 'pid' => 0]); + DBA::update('workerqueue', ['priority' => $priority], ['command' => $command, 'parameter' => $parameters, 'done' => false, 'pid' => 0]); } + // Set the IPC flag to ensure an immediate process execution via daemon + if (self::isDaemonMode()) { + self::IPCSetJobState(true); + } + + self::checkDaemonState(); + // Should we quit and wait for the worker to be called as a cronjob? if ($dont_fork) { return $added; } // If there is a lock then we don't have to check for too much worker - if (!DI::lock()->acquire('worker', 0)) { + if (!DI::lock()->acquire(self::LOCK_WORKER, 0)) { return $added; } // If there are already enough workers running, don't fork another one $quit = self::tooMuchWorkers(); - DI::lock()->release('worker'); + DI::lock()->release(self::LOCK_WORKER); if ($quit) { return $added; } - // We tell the daemon that a new job entry exists - if (DI::config()->get('system', 'worker_daemon_mode', false)) { - // We don't have to set the IPC flag - this is done in "tooMuchWorkers" + // Quit on daemon mode + if (self::isDaemonMode()) { return $added; } @@ -1213,6 +1309,11 @@ class Worker return $added; } + public static function countWorkersByCommand(string $command) + { + return DBA::count('workerqueue', ['done' => false, 'pid' => 0, 'command' => $command]); + } + /** * Returns the next retrial level for worker jobs. * This function will skip levels when jobs are older. @@ -1235,7 +1336,7 @@ class Worker $new_retrial = $retrial; } } - Logger::info('New retrial for task', ['id' => $queue['id'], 'created' => $queue['created'], 'old' => $queue['retrial'], 'new' => $new_retrial]); + Logger::notice('New retrial for task', ['id' => $queue['id'], 'created' => $queue['created'], 'old' => $queue['retrial'], 'new' => $new_retrial]); return $new_retrial; } @@ -1261,7 +1362,7 @@ class Worker $new_retrial = self::getNextRetrial($queue, $max_level); if ($new_retrial > $max_level) { - Logger::info('The task exceeded the maximum retry count', ['id' => $id, 'created' => $queue['created'], 'old_prio' => $queue['priority'], 'old_retrial' => $queue['retrial'], 'max_level' => $max_level, 'retrial' => $new_retrial]); + Logger::notice('The task exceeded the maximum retry count', ['id' => $id, 'created' => $queue['created'], 'old_prio' => $queue['priority'], 'old_retrial' => $queue['retrial'], 'max_level' => $max_level, 'retrial' => $new_retrial]); return false; } @@ -1288,41 +1389,31 @@ class Worker return true; } - /** - * Log active processes into the "process" table - */ - public static function startProcess() - { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); - - $command = basename($trace[0]['file']); - - Process::deleteInactive(); - - Process::insert($command); - } - - /** - * Remove the active process from the "process" table - * - * @return bool - * @throws \Exception - */ - public static function endProcess() - { - return Process::deleteByPid(); - } - /** * Set the flag if some job is waiting * * @param boolean $jobs Is there a waiting job? + * @param int $key Key number * @throws \Exception */ - public static function IPCSetJobState($jobs) + public static function IPCSetJobState(bool $jobs, int $key = 0) { $stamp = (float)microtime(true); - DBA::update('worker-ipc', ['jobs' => $jobs], ['key' => 1], true); + DBA::replace('worker-ipc', ['jobs' => $jobs, 'key' => $key]); + self::$db_duration += (microtime(true) - $stamp); + self::$db_duration_write += (microtime(true) - $stamp); + } + + /** + * Delete a key entry + * + * @param int $key Key number + * @throws \Exception + */ + public static function IPCDeleteJobState(int $key) + { + $stamp = (float)microtime(true); + DBA::delete('worker-ipc', ['key' => $key]); self::$db_duration += (microtime(true) - $stamp); self::$db_duration_write += (microtime(true) - $stamp); } @@ -1330,13 +1421,14 @@ class Worker /** * Checks if some worker job waits to be executed * + * @param int $key Key number * @return bool * @throws \Exception */ - public static function IPCJobsExists() + public static function IPCJobsExists(int $key = 0) { $stamp = (float)microtime(true); - $row = DBA::selectFirst('worker-ipc', ['jobs'], ['key' => 1]); + $row = DBA::selectFirst('worker-ipc', ['jobs'], ['key' => $key]); self::$db_duration += (microtime(true) - $stamp); // When we don't have a row, no job is running @@ -1346,4 +1438,152 @@ class Worker return (bool)$row['jobs']; } + + /** + * Checks if the worker is running in the daemon mode. + * + * @return boolean + */ + public static function isDaemonMode() + { + if (!is_null(self::$daemon_mode)) { + return self::$daemon_mode; + } + + if (DI::mode()->getExecutor() == Mode::DAEMON) { + return true; + } + + $daemon_mode = DI::config()->get('system', 'worker_daemon_mode', false, true); + if ($daemon_mode) { + return $daemon_mode; + } + + if (!function_exists('pcntl_fork')) { + self::$daemon_mode = false; + return false; + } + + $pidfile = DI::config()->get('system', 'pidfile'); + if (empty($pidfile)) { + // No pid file, no daemon + self::$daemon_mode = false; + return false; + } + + if (!is_readable($pidfile)) { + // No pid file. We assume that the daemon had been intentionally stopped. + self::$daemon_mode = false; + return false; + } + + $pid = intval(file_get_contents($pidfile)); + $running = posix_kill($pid, 0); + + self::$daemon_mode = $running; + return $running; + } + + /** + * Test if the daemon is running. If not, it will be started + * + * @return void + */ + private static function checkDaemonState() + { + if (!DI::config()->get('system', 'daemon_watchdog', false)) { + return; + } + + if (!DI::mode()->isNormal()) { + return; + } + + // Check every minute if the daemon is running + if (DI::config()->get('system', 'last_daemon_check', 0) + 60 > time()) { + return; + } + + DI::config()->set('system', 'last_daemon_check', time()); + + $pidfile = DI::config()->get('system', 'pidfile'); + if (empty($pidfile)) { + // No pid file, no daemon + return; + } + + if (!is_readable($pidfile)) { + // No pid file. We assume that the daemon had been intentionally stopped. + return; + } + + $pid = intval(file_get_contents($pidfile)); + if (posix_kill($pid, 0)) { + Logger::info('Daemon process is running', ['pid' => $pid]); + return; + } + + Logger::warning('Daemon process is not running', ['pid' => $pid]); + + self::spawnDaemon(); + } + + /** + * Spawn a new daemon process + * + * @return void + */ + private static function spawnDaemon() + { + Logger::notice('Starting new daemon process'); + $command = 'bin/daemon.php'; + $a = DI::app(); + $process = new Core\Process(DI::logger(), DI::mode(), DI::config(), DI::modelProcess(), $a->getBasePath(), getmypid()); + $process->run($command, ['start']); + Logger::notice('New daemon process started'); + } + + /** + * Check if the system is inside the defined maintenance window + * + * @return boolean + */ + public static function isInMaintenanceWindow(bool $check_last_execution = false) + { + // Calculate the seconds of the start end end of the maintenance window + $start = strtotime(DI::config()->get('system', 'maintenance_start')) % 86400; + $end = strtotime(DI::config()->get('system', 'maintenance_end')) % 86400; + + Logger::info('Maintenance window', ['start' => date('H:i:s', $start), 'end' => date('H:i:s', $end)]); + + if ($check_last_execution) { + // Calculate the window duration + $duration = max($start, $end) - min($start, $end); + + // Quit when the last cron execution had been after the previous window + $last_cron = DI::config()->get('system', 'last_cron_daily'); + if ($last_cron + $duration > time()) { + Logger::info('The Daily cron had been executed recently', ['last' => date(DateTimeFormat::MYSQL, $last_cron), 'start' => date('H:i:s', $start), 'end' => date('H:i:s', $end)]); + return false; + } + } + + $current = time() % 86400; + + if ($start < $end) { + // Execute if we are inside the window + $execute = ($current >= $start) && ($current <= $end); + } else { + // Don't execute if we are outside the window + $execute = !(($current > $end) && ($current < $start)); + } + + if ($execute) { + Logger::info('We are inside the maintenance window', ['current' => date('H:i:s', $current), 'start' => date('H:i:s', $start), 'end' => date('H:i:s', $end)]); + } else { + Logger::info('We are outside the maintenance window', ['current' => date('H:i:s', $current), 'start' => date('H:i:s', $start), 'end' => date('H:i:s', $end)]); + } + + return $execute; + } } diff --git a/src/DI.php b/src/DI.php index c89315c0ec..6461059a83 100644 --- a/src/DI.php +++ b/src/DI.php @@ -63,14 +63,6 @@ abstract class DI // "App" namespace instances // - /** - * @return App\Authentication - */ - public static function auth() - { - return self::$dice->create(App\Authentication::class); - } - /** * @return App\Arguments */ @@ -247,6 +239,14 @@ abstract class DI return self::$dice->create(Factory\Api\Mastodon\Account::class); } + /** + * @return Factory\Api\Mastodon\Attachment + */ + public static function mstdnAttachment() + { + return self::$dice->create(Factory\Api\Mastodon\Attachment::class); + } + /** * @return Factory\Api\Mastodon\Emoji */ @@ -255,6 +255,14 @@ abstract class DI return self::$dice->create(Factory\Api\Mastodon\Emoji::class); } + /** + * @return Factory\Api\Mastodon\Error + */ + public static function mstdnError() + { + return self::$dice->create(Factory\Api\Mastodon\Error::class); + } + /** * @return Factory\Api\Mastodon\Field */ @@ -279,6 +287,38 @@ abstract class DI return self::$dice->create(Factory\Api\Mastodon\Relationship::class); } + /** + * @return Factory\Api\Mastodon\Status + */ + public static function mstdnStatus() + { + return self::$dice->create(Factory\Api\Mastodon\Status::class); + } + + /** + * @return Factory\Api\Mastodon\Mention + */ + public static function mstdnMention() + { + return self::$dice->create(Factory\Api\Mastodon\Mention::class); + } + + /** + * @return Factory\Api\Mastodon\Tag + */ + public static function mstdnTag() + { + return self::$dice->create(Factory\Api\Mastodon\Tag::class); + } + + /** + * @return Factory\Api\Twitter\User + */ + public static function twitterUser() + { + return self::$dice->create(Factory\Api\Twitter\User::class); + } + /** * @return Factory\Notification\Notification */ @@ -298,6 +338,13 @@ abstract class DI // // "Model" namespace instances // + /** + * @return Model\Process + */ + public static function modelProcess() + { + return self::$dice->create(Model\Process::class); + } /** * @return Model\User\Cookie @@ -315,6 +362,18 @@ abstract class DI return self::$dice->create(Model\Storage\IStorage::class); } + // + // "Network" namespace + // + + /** + * @return Network\IHTTPRequest + */ + public static function httpRequest() + { + return self::$dice->create(Network\IHTTPRequest::class); + } + // // "Repository" namespace // @@ -371,6 +430,18 @@ abstract class DI return self::$dice->create(Protocol\Activity::class); } + // + // "Security" namespace instances + // + + /** + * @return \Friendica\Security\Authentication + */ + public static function auth() + { + return self::$dice->create(Security\Authentication::class); + } + // // "Util" namespace instances // diff --git a/src/Database/DBA.php b/src/Database/DBA.php index 9825d06c68..1a7ff3bc54 100644 --- a/src/Database/DBA.php +++ b/src/Database/DBA.php @@ -72,6 +72,16 @@ class DBA return DI::dba()->getConnection(); } + /** + * Return the database driver string + * + * @return string with either "pdo" or "mysqli" + */ + public static function getDriver() + { + return DI::dba()->getDriver(); + } + /** * Returns the MySQL server version string * @@ -280,16 +290,31 @@ class DBA /** * Insert a row into a table * - * @param string|array $table Table name or array [schema => table] - * @param array $param parameter array - * @param bool $on_duplicate_update Do an update on a duplicate entry + * @param string|array $table Table name or array [schema => table] + * @param array $param parameter array + * @param int $duplicate_mode What to do on a duplicated entry * * @return boolean was the insert successful? * @throws \Exception */ - public static function insert($table, $param, $on_duplicate_update = false) + public static function insert($table, array $param, int $duplicate_mode = Database::INSERT_DEFAULT) { - return DI::dba()->insert($table, $param, $on_duplicate_update); + return DI::dba()->insert($table, $param, $duplicate_mode); + } + + /** + * Inserts a row with the provided data in the provided table. + * If the data corresponds to an existing row through a UNIQUE or PRIMARY index constraints, it updates the row instead. + * + * @param string|array $table Table name or array [schema => table] + * @param array $param parameter array + * + * @return boolean was the insert successful? + * @throws \Exception + */ + public static function replace($table, $param) + { + return DI::dba()->replace($table, $param); } /** @@ -539,7 +564,7 @@ class DBA * Returns the SQL condition string built from the provided condition array * * This function operates with two modes. - * - Supplied with a filed/value associative array, it builds simple strict + * - Supplied with a field/value associative array, it builds simple strict * equality conditions linked by AND. * - Supplied with a flat list, the first element is the condition string and * the following arguments are the values to be interpolated @@ -645,9 +670,59 @@ class DBA return $condition; } + /** + * Merges the provided conditions into a single collapsed one + * + * @param array ...$conditions One or more condition arrays + * @return array A collapsed condition + * @see DBA::collapseCondition() for the condition array formats + */ + public static function mergeConditions(array ...$conditions) + { + if (count($conditions) == 1) { + return current($conditions); + } + + $conditionStrings = []; + $result = []; + + foreach ($conditions as $key => $condition) { + if (!$condition) { + continue; + } + + $condition = self::collapseCondition($condition); + + $conditionStrings[] = array_shift($condition); + // The result array holds the eventual parameter values + $result = array_merge($result, $condition); + } + + if (count($conditionStrings)) { + // We prepend the condition string at the end to form a collapsed condition array again + array_unshift($result, implode(' AND ', $conditionStrings)); + } + + return $result; + } + /** * Returns the SQL parameter string built from the provided parameter array * + * Expected format for each key: + * + * group_by: + * - list of column names + * + * order: + * - numeric keyed column name => ASC + * - associative element with boolean value => DESC (true), ASC (false) + * - associative element with string value => 'ASC' or 'DESC' literally + * + * limit: + * - single numeric value => count + * - list with two numeric values => offset, count + * * @param array $params * @return string */ @@ -665,7 +740,11 @@ class DBA if ($order === 'RAND()') { $order_string .= "RAND(), "; } elseif (!is_int($fields)) { - $order_string .= self::quoteIdentifier($fields) . " " . ($order ? "DESC" : "ASC") . ", "; + if ($order !== 'DESC' && $order !== 'ASC') { + $order = $order ? 'DESC' : 'ASC'; + } + + $order_string .= self::quoteIdentifier($fields) . " " . $order . ", "; } else { $order_string .= self::quoteIdentifier($order) . ", "; } @@ -697,6 +776,18 @@ class DBA return DI::dba()->toArray($stmt, $do_close); } + /** + * Cast field types according to the table definition + * + * @param string $table + * @param array $fields + * @return array casted fields + */ + public static function castFields(string $table, array $fields) + { + return DI::dba()->castFields($table, $fields); + } + /** * Returns the error number of the last query * diff --git a/src/Database/DBStructure.php b/src/Database/DBStructure.php index dc13bd6566..9b69f7b111 100644 --- a/src/Database/DBStructure.php +++ b/src/Database/DBStructure.php @@ -25,10 +25,10 @@ use Exception; use Friendica\Core\Hook; use Friendica\Core\Logger; use Friendica\DI; +use Friendica\Model\Item; +use Friendica\Model\User; use Friendica\Util\DateTimeFormat; -require_once __DIR__ . '/../../include/dba.php'; - /** * This class contains functions that doesn't need to know if pdo, mysqli or whatever is used. */ @@ -48,6 +48,62 @@ class DBStructure */ private static $definition = []; + /** + * Set a database version to trigger update functions + * + * @param string $version + * @return void + */ + public static function setDatabaseVersion(string $version) + { + if (!is_numeric($version)) { + throw new \Asika\SimpleConsole\CommandArgsException('The version number must be numeric'); + } + + DI::config()->set('system', 'build', $version); + echo DI::l10n()->t('The database version had been set to %s.', $version); + } + + /** + * Drop unused tables + * + * @param boolean $execute + * @return void + */ + public static function dropTables(bool $execute) + { + $old_tables = ['fserver', 'gcign', 'gcontact', 'gcontact-relation', 'gfollower' ,'glink', 'item-delivery-data', + 'item_id', 'poll', 'poll_result', 'queue', 'retriever_rule', 'sign', 'spam', 'term']; + + $tables = DBA::selectToArray(['INFORMATION_SCHEMA' => 'TABLES'], ['TABLE_NAME'], + ['TABLE_SCHEMA' => DBA::databaseName(), 'TABLE_TYPE' => 'BASE TABLE']); + + if (empty($tables)) { + echo DI::l10n()->t('No unused tables found.'); + return; + } + + if (!$execute) { + echo DI::l10n()->t('These tables are not used for friendica and will be deleted when you execute "dbstructure drop -e":') . "\n\n"; + } + + foreach ($tables as $table) { + if (in_array($table['TABLE_NAME'], $old_tables)) { + if ($execute) { + $sql = 'DROP TABLE ' . DBA::quoteIdentifier($table['TABLE_NAME']) . ';'; + echo $sql . "\n"; + + $result = DBA::e($sql); + if (!DBA::isResult($result)) { + self::printUpdateError($sql); + } + } else { + echo $table['TABLE_NAME'] . "\n"; + } + } + } + } + /** * Converts all tables from MyISAM/InnoDB Antelope to InnoDB Barracuda */ @@ -154,6 +210,34 @@ class DBStructure return $definition; } + /** + * Get field data for the given table + * + * @param string $table + * @param array $data data fields + * @return array fields for the given + */ + public static function getFieldsForTable(string $table, array $data = []) + { + $definition = DBStructure::definition('', false); + if (empty($definition[$table])) { + return []; + } + + $fieldnames = array_keys($definition[$table]['fields']); + + $fields = []; + + // Assign all field that are present in the table + foreach ($fieldnames as $field) { + if (isset($data[$field])) { + $fields[$field] = $data[$field]; + } + } + + return $fields; + } + private static function createTable($name, $structure, $verbose, $action) { $r = true; @@ -162,11 +246,16 @@ class DBStructure $comment = ""; $sql_rows = []; $primary_keys = []; + $foreign_keys = []; + foreach ($structure["fields"] AS $fieldname => $field) { $sql_rows[] = "`" . DBA::escape($fieldname) . "` " . self::FieldCommand($field); if (!empty($field['primary'])) { $primary_keys[] = $fieldname; } + if (!empty($field['foreign'])) { + $foreign_keys[$fieldname] = $field; + } } if (!empty($structure["indexes"])) { @@ -178,6 +267,10 @@ class DBStructure } } + foreach ($foreign_keys AS $fieldname => $parameters) { + $sql_rows[] = self::foreignCommand($name, $fieldname, $parameters); + } + if (isset($structure["engine"])) { $engine = " ENGINE=" . $structure["engine"]; } @@ -283,26 +376,34 @@ class DBStructure public static function update($basePath, $verbose, $action, $install = false, array $tables = null, array $definition = null) { if ($action && !$install) { + if (self::isUpdating()) { + return DI::l10n()->t('Another database update is currently running.'); + } + DI::config()->set('system', 'maintenance', 1); DI::config()->set('system', 'maintenance_reason', DI::l10n()->t('%s: Database update', DateTimeFormat::utcNow() . ' ' . date('e'))); } + // ensure that all initial values exist. This test has to be done prior and after the structure check. + // Prior is needed if the specific tables already exists - after is needed when they had been created. + self::checkInitialValues(); + $errors = ''; - Logger::log('updating structure', Logger::DEBUG); + Logger::info('updating structure'); // Get the current structure $database = []; if (is_null($tables)) { - $tables = q("SHOW TABLES"); + $tables = DBA::toArray(DBA::p("SHOW TABLES")); } if (DBA::isResult($tables)) { foreach ($tables AS $table) { $table = current($table); - Logger::log(sprintf('updating structure for table %s ...', $table), Logger::DEBUG); + Logger::info('updating structure', ['table' => $table]); $database[$table] = self::tableStructure($table); } } @@ -387,6 +488,7 @@ class DBStructure // Remove the relation data that is used for the referential integrity unset($parameters['relation']); + unset($parameters['foreign']); // We change the collation after the indexes had been changed. // This is done to avoid index length problems. @@ -441,9 +543,43 @@ class DBStructure } } - if (isset($database[$name]["table_status"]["Comment"])) { + $existing_foreign_keys = $database[$name]['foreign_keys']; + + // Foreign keys + // Compare the field structure field by field + foreach ($structure["fields"] AS $fieldname => $parameters) { + if (empty($parameters['foreign'])) { + continue; + } + + $constraint = self::getConstraintName($name, $fieldname, $parameters); + + unset($existing_foreign_keys[$constraint]); + + if (empty($database[$name]['foreign_keys'][$constraint])) { + $sql2 = self::addForeignKey($name, $fieldname, $parameters); + + if ($sql3 == "") { + $sql3 = "ALTER" . $ignore . " TABLE `" . $temp_name . "` " . $sql2; + } else { + $sql3 .= ", " . $sql2; + } + } + } + + foreach ($existing_foreign_keys as $param) { + $sql2 = self::dropForeignKey($param['CONSTRAINT_NAME']); + + if ($sql3 == "") { + $sql3 = "ALTER" . $ignore . " TABLE `" . $temp_name . "` " . $sql2; + } else { + $sql3 .= ", " . $sql2; + } + } + + if (isset($database[$name]["table_status"]["TABLE_COMMENT"])) { $structurecomment = $structure["comment"] ?? ''; - if ($database[$name]["table_status"]["Comment"] != $structurecomment) { + if ($database[$name]["table_status"]["TABLE_COMMENT"] != $structurecomment) { $sql2 = "COMMENT = '" . DBA::escape($structurecomment) . "'"; if ($sql3 == "") { @@ -454,8 +590,8 @@ class DBStructure } } - if (isset($database[$name]["table_status"]["Engine"]) && isset($structure['engine'])) { - if ($database[$name]["table_status"]["Engine"] != $structure['engine']) { + if (isset($database[$name]["table_status"]["ENGINE"]) && isset($structure['engine'])) { + if ($database[$name]["table_status"]["ENGINE"] != $structure['engine']) { $sql2 = "ENGINE = '" . DBA::escape($structure['engine']) . "'"; if ($sql3 == "") { @@ -466,8 +602,8 @@ class DBStructure } } - if (isset($database[$name]["table_status"]["Collation"])) { - if ($database[$name]["table_status"]["Collation"] != 'utf8mb4_general_ci') { + if (isset($database[$name]["table_status"]["TABLE_COLLATION"])) { + if ($database[$name]["table_status"]["TABLE_COLLATION"] != 'utf8mb4_general_ci') { $sql2 = "DEFAULT COLLATE utf8mb4_general_ci"; if ($sql3 == "") { @@ -596,7 +732,9 @@ class DBStructure } } - View::create($verbose, $action); + View::create(false, $action); + + self::checkInitialValues(); if ($action && !$install) { DI::config()->set('system', 'maintenance', 0); @@ -614,22 +752,36 @@ class DBStructure private static function tableStructure($table) { - $structures = q("DESCRIBE `%s`", $table); + // This query doesn't seem to be executable as a prepared statement + $indexes = DBA::toArray(DBA::p("SHOW INDEX FROM " . DBA::quoteIdentifier($table))); - $full_columns = q("SHOW FULL COLUMNS FROM `%s`", $table); + $fields = DBA::selectToArray(['INFORMATION_SCHEMA' => 'COLUMNS'], + ['COLUMN_NAME', 'COLUMN_TYPE', 'IS_NULLABLE', 'COLUMN_DEFAULT', 'EXTRA', + 'COLUMN_KEY', 'COLLATION_NAME', 'COLUMN_COMMENT'], + ["`TABLE_SCHEMA` = ? AND `TABLE_NAME` = ?", + DBA::databaseName(), $table]); - $indexes = q("SHOW INDEX FROM `%s`", $table); + $foreign_keys = DBA::selectToArray(['INFORMATION_SCHEMA' => 'KEY_COLUMN_USAGE'], + ['COLUMN_NAME', 'CONSTRAINT_NAME', 'REFERENCED_TABLE_NAME', 'REFERENCED_COLUMN_NAME'], + ["`TABLE_SCHEMA` = ? AND `TABLE_NAME` = ? AND `REFERENCED_TABLE_SCHEMA` IS NOT NULL", + DBA::databaseName(), $table]); - $table_status = q("SHOW TABLE STATUS WHERE `name` = '%s'", $table); - - if (DBA::isResult($table_status)) { - $table_status = $table_status[0]; - } else { - $table_status = []; - } + $table_status = DBA::selectFirst(['INFORMATION_SCHEMA' => 'TABLES'], + ['ENGINE', 'TABLE_COLLATION', 'TABLE_COMMENT'], + ["`TABLE_SCHEMA` = ? AND `TABLE_NAME` = ?", + DBA::databaseName(), $table]); $fielddata = []; $indexdata = []; + $foreigndata = []; + + if (DBA::isResult($foreign_keys)) { + foreach ($foreign_keys as $foreign_key) { + $parameters = ['foreign' => [$foreign_key['REFERENCED_TABLE_NAME'] => $foreign_key['REFERENCED_COLUMN_NAME']]]; + $constraint = self::getConstraintName($table, $foreign_key['COLUMN_NAME'], $parameters); + $foreigndata[$constraint] = $foreign_key; + } + } if (DBA::isResult($indexes)) { foreach ($indexes AS $index) { @@ -650,39 +802,39 @@ class DBStructure $indexdata[$index["Key_name"]][] = $column; } } - if (DBA::isResult($structures)) { - foreach ($structures AS $field) { - // Replace the default size values so that we don't have to define them + + $fielddata = []; + if (DBA::isResult($fields)) { + foreach ($fields AS $field) { $search = ['tinyint(1)', 'tinyint(3) unsigned', 'tinyint(4)', 'smallint(5) unsigned', 'smallint(6)', 'mediumint(8) unsigned', 'mediumint(9)', 'bigint(20)', 'int(10) unsigned', 'int(11)']; $replace = ['boolean', 'tinyint unsigned', 'tinyint', 'smallint unsigned', 'smallint', 'mediumint unsigned', 'mediumint', 'bigint', 'int unsigned', 'int']; - $field["Type"] = str_replace($search, $replace, $field["Type"]); + $field['COLUMN_TYPE'] = str_replace($search, $replace, $field['COLUMN_TYPE']); - $fielddata[$field["Field"]]["type"] = $field["Type"]; - if ($field["Null"] == "NO") { - $fielddata[$field["Field"]]["not null"] = true; + $fielddata[$field['COLUMN_NAME']]['type'] = $field['COLUMN_TYPE']; + + if ($field['IS_NULLABLE'] == 'NO') { + $fielddata[$field['COLUMN_NAME']]['not null'] = true; } - if (isset($field["Default"])) { - $fielddata[$field["Field"]]["default"] = $field["Default"]; + if (isset($field['COLUMN_DEFAULT']) && ($field['COLUMN_DEFAULT'] != 'NULL')) { + $fielddata[$field['COLUMN_NAME']]['default'] = trim($field['COLUMN_DEFAULT'], "'"); } - if ($field["Extra"] != "") { - $fielddata[$field["Field"]]["extra"] = $field["Extra"]; + if (!empty($field['EXTRA'])) { + $fielddata[$field['COLUMN_NAME']]['extra'] = $field['EXTRA']; } - if ($field["Key"] == "PRI") { - $fielddata[$field["Field"]]["primary"] = true; + if ($field['COLUMN_KEY'] == 'PRI') { + $fielddata[$field['COLUMN_NAME']]['primary'] = true; } - } - } - if (DBA::isResult($full_columns)) { - foreach ($full_columns AS $column) { - $fielddata[$column["Field"]]["Collation"] = $column["Collation"]; - $fielddata[$column["Field"]]["comment"] = $column["Comment"]; + + $fielddata[$field['COLUMN_NAME']]['Collation'] = $field['COLLATION_NAME']; + $fielddata[$field['COLUMN_NAME']]['comment'] = $field['COLUMN_COMMENT']; } } - return ["fields" => $fielddata, "indexes" => $indexdata, "table_status" => $table_status]; + return ["fields" => $fielddata, "indexes" => $indexdata, + "foreign_keys" => $foreigndata, "table_status" => $table_status]; } private static function dropIndex($indexname) @@ -703,6 +855,45 @@ class DBStructure return ($sql); } + private static function getConstraintName(string $tablename, string $fieldname, array $parameters) + { + $foreign_table = array_keys($parameters['foreign'])[0]; + $foreign_field = array_values($parameters['foreign'])[0]; + + return $tablename . "-" . $fieldname. "-" . $foreign_table. "-" . $foreign_field; + } + + private static function foreignCommand(string $tablename, string $fieldname, array $parameters) { + $foreign_table = array_keys($parameters['foreign'])[0]; + $foreign_field = array_values($parameters['foreign'])[0]; + + $sql = "FOREIGN KEY (`" . $fieldname . "`) REFERENCES `" . $foreign_table . "` (`" . $foreign_field . "`)"; + + if (!empty($parameters['foreign']['on update'])) { + $sql .= " ON UPDATE " . strtoupper($parameters['foreign']['on update']); + } else { + $sql .= " ON UPDATE RESTRICT"; + } + + if (!empty($parameters['foreign']['on delete'])) { + $sql .= " ON DELETE " . strtoupper($parameters['foreign']['on delete']); + } else { + $sql .= " ON DELETE CASCADE"; + } + + return $sql; + } + + private static function addForeignKey(string $tablename, string $fieldname, array $parameters) + { + return sprintf("ADD %s", self::foreignCommand($tablename, $fieldname, $parameters)); + } + + private static function dropForeignKey(string $constraint) + { + return sprintf("DROP FOREIGN KEY `%s`", $constraint); + } + /** * Constructs a GROUP BY clause from a UNIQUE index definition. * @@ -840,6 +1031,19 @@ class DBStructure return true; } + /** + * Check if a foreign key exists for the given table field + * + * @param string $table + * @param string $field + * @return boolean + */ + public static function existsForeignKeyForField(string $table, string $field) + { + return DBA::exists(['INFORMATION_SCHEMA' => 'KEY_COLUMN_USAGE'], + ["`TABLE_SCHEMA` = ? AND `TABLE_NAME` = ? AND `COLUMN_NAME` = ? AND `REFERENCED_TABLE_SCHEMA` IS NOT NULL", + DBA::databaseName(), $table, $field]); + } /** * Check if a table exists * @@ -878,4 +1082,160 @@ class DBStructure $stmtColumns = DBA::p("SHOW COLUMNS FROM `" . $table . "`"); return DBA::toArray($stmtColumns); } + + /** + * Check if initial database values do exist - or create them + */ + public static function checkInitialValues(bool $verbose = false) + { + if (self::existsTable('verb')) { + if (!DBA::exists('verb', ['id' => 1])) { + foreach (Item::ACTIVITIES as $index => $activity) { + DBA::insert('verb', ['id' => $index + 1, 'name' => $activity], Database::INSERT_IGNORE); + } + if ($verbose) { + echo "verb: activities added\n"; + } + } elseif ($verbose) { + echo "verb: activities already added\n"; + } + + if (!DBA::exists('verb', ['id' => 0])) { + DBA::insert('verb', ['name' => '']); + $lastid = DBA::lastInsertId(); + if ($lastid != 0) { + DBA::update('verb', ['id' => 0], ['id' => $lastid]); + if ($verbose) { + echo "Zero verb added\n"; + } + } + } elseif ($verbose) { + echo "Zero verb already added\n"; + } + } elseif ($verbose) { + echo "verb: Table not found\n"; + } + + if (self::existsTable('user') && !DBA::exists('user', ['uid' => 0])) { + $user = [ + "verified" => true, + "page-flags" => User::PAGE_FLAGS_SOAPBOX, + "account-type" => User::ACCOUNT_TYPE_RELAY, + ]; + DBA::insert('user', $user); + $lastid = DBA::lastInsertId(); + if ($lastid != 0) { + DBA::update('user', ['uid' => 0], ['uid' => $lastid]); + if ($verbose) { + echo "Zero user added\n"; + } + } + } elseif (self::existsTable('user') && $verbose) { + echo "Zero user already added\n"; + } elseif ($verbose) { + echo "user: Table not found\n"; + } + + if (self::existsTable('contact') && !DBA::exists('contact', ['id' => 0])) { + DBA::insert('contact', ['nurl' => '']); + $lastid = DBA::lastInsertId(); + if ($lastid != 0) { + DBA::update('contact', ['id' => 0], ['id' => $lastid]); + if ($verbose) { + echo "Zero contact added\n"; + } + } + } elseif (self::existsTable('contact') && $verbose) { + echo "Zero contact already added\n"; + } elseif ($verbose) { + echo "contact: Table not found\n"; + } + + if (self::existsTable('tag') && !DBA::exists('tag', ['id' => 0])) { + DBA::insert('tag', ['name' => '']); + $lastid = DBA::lastInsertId(); + if ($lastid != 0) { + DBA::update('tag', ['id' => 0], ['id' => $lastid]); + if ($verbose) { + echo "Zero tag added\n"; + } + } + } elseif (self::existsTable('tag') && $verbose) { + echo "Zero tag already added\n"; + } elseif ($verbose) { + echo "tag: Table not found\n"; + } + + if (self::existsTable('permissionset')) { + if (!DBA::exists('permissionset', ['id' => 0])) { + DBA::insert('permissionset', ['allow_cid' => '', 'allow_gid' => '', 'deny_cid' => '', 'deny_gid' => '']); + $lastid = DBA::lastInsertId(); + if ($lastid != 0) { + DBA::update('permissionset', ['id' => 0], ['id' => $lastid]); + if ($verbose) { + echo "Zero permissionset added\n"; + } + } + } elseif ($verbose) { + echo "Zero permissionset already added\n"; + } + if (!self::existsForeignKeyForField('item', 'psid')) { + $sets = DBA::p("SELECT `psid`, `item`.`uid`, `item`.`private` FROM `item` + LEFT JOIN `permissionset` ON `permissionset`.`id` = `item`.`psid` + WHERE `permissionset`.`id` IS NULL AND NOT `psid` IS NULL"); + while ($set = DBA::fetch($sets)) { + if (($set['private'] == Item::PRIVATE) && ($set['uid'] != 0)) { + $owner = User::getOwnerDataById($set['uid']); + if ($owner) { + $permission = '<' . $owner['id'] . '>'; + } else { + $permission = '<>'; + } + } else { + $permission = ''; + } + $fields = ['id' => $set['psid'], 'uid' => $set['uid'], 'allow_cid' => $permission, + 'allow_gid' => '', 'deny_cid' => '', 'deny_gid' => '']; + DBA::insert('permissionset', $fields); + } + DBA::close($sets); + } + } elseif ($verbose) { + echo "permissionset: Table not found\n"; + } + + if (!self::existsForeignKeyForField('tokens', 'client_id')) { + $tokens = DBA::p("SELECT `tokens`.`id` FROM `tokens` + LEFT JOIN `clients` ON `clients`.`client_id` = `tokens`.`client_id` + WHERE `clients`.`client_id` IS NULL"); + while ($token = DBA::fetch($tokens)) { + DBA::delete('tokens', ['id' => $token['id']]); + } + DBA::close($tokens); + } + } + + /** + * Checks if a database update is currently running + * + * @return boolean + */ + private static function isUpdating() + { + $isUpdate = false; + + $processes = DBA::select(['information_schema' => 'processlist'], ['info'], + ['db' => DBA::databaseName(), 'command' => ['Query', 'Execute']]); + + while ($process = DBA::fetch($processes)) { + $parts = explode(' ', $process['info']); + if (in_array(strtolower(array_shift($parts)), ['alter', 'create', 'drop', 'rename'])) { + $isUpdate = true; + } + } + + DBA::close($processes); + + return $isUpdate; + } } diff --git a/src/Database/Database.php b/src/Database/Database.php index ad0c857960..a95b1ad69e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -39,6 +39,13 @@ use Psr\Log\LoggerInterface; */ class Database { + const PDO = 'pdo'; + const MYSQLI = 'mysqli'; + + const INSERT_DEFAULT = 0; + const INSERT_UPDATE = 1; + const INSERT_IGNORE = 2; + protected $connected = false; /** @@ -57,22 +64,22 @@ class Database /** @var PDO|mysqli */ protected $connection; protected $driver; - private $emulate_prepares = false; + protected $pdo_emulate_prepares = false; private $error = false; private $errorno = 0; private $affected_rows = 0; protected $in_transaction = false; protected $in_retrial = false; + protected $testmode = false; private $relation = []; - public function __construct(Cache $configCache, Profiler $profiler, LoggerInterface $logger, array $server = []) + public function __construct(Cache $configCache, Profiler $profiler, LoggerInterface $logger) { // We are storing these values for being able to perform a reconnect $this->configCache = $configCache; $this->profiler = $profiler; $this->logger = $logger; - $this->readServerVariables($server); $this->connect(); if ($this->isConnected()) { @@ -81,30 +88,6 @@ class Database } } - private function readServerVariables(array $server) - { - // Use environment variables for mysql if they are set beforehand - if (!empty($server['MYSQL_HOST']) - && (!empty($server['MYSQL_USERNAME'] || !empty($server['MYSQL_USER']))) - && $server['MYSQL_PASSWORD'] !== false - && !empty($server['MYSQL_DATABASE'])) - { - $db_host = $server['MYSQL_HOST']; - if (!empty($server['MYSQL_PORT'])) { - $db_host .= ':' . $server['MYSQL_PORT']; - } - $this->configCache->set('database', 'hostname', $db_host); - unset($db_host); - if (!empty($server['MYSQL_USERNAME'])) { - $this->configCache->set('database', 'username', $server['MYSQL_USERNAME']); - } else { - $this->configCache->set('database', 'username', $server['MYSQL_USER']); - } - $this->configCache->set('database', 'password', (string) $server['MYSQL_PASSWORD']); - $this->configCache->set('database', 'database', $server['MYSQL_DATABASE']); - } - } - public function connect() { if (!is_null($this->connection) && $this->connected()) { @@ -121,6 +104,11 @@ class Database if (count($serverdata) > 1) { $port = trim($serverdata[1]); } + + if (!empty(trim($this->configCache->get('database', 'port')))) { + $port = trim($this->configCache->get('database', 'port')); + } + $server = trim($server); $user = trim($this->configCache->get('database', 'username')); $pass = trim($this->configCache->get('database', 'password')); @@ -131,10 +119,12 @@ class Database return false; } - $this->emulate_prepares = (bool)$this->configCache->get('database', 'emulate_prepares'); + $persistent = (bool)$this->configCache->get('database', 'persistent'); - if (class_exists('\PDO') && in_array('mysql', PDO::getAvailableDrivers())) { - $this->driver = 'pdo'; + $this->pdo_emulate_prepares = (bool)$this->configCache->get('database', 'pdo_emulate_prepares'); + + if (!$this->configCache->get('database', 'disable_pdo') && class_exists('\PDO') && in_array('mysql', PDO::getAvailableDrivers())) { + $this->driver = self::PDO; $connect = "mysql:host=" . $server . ";dbname=" . $db; if ($port > 0) { @@ -146,8 +136,8 @@ class Database } try { - $this->connection = @new PDO($connect, $user, $pass); - $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); + $this->connection = @new PDO($connect, $user, $pass, [PDO::ATTR_PERSISTENT => $persistent]); + $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, $this->pdo_emulate_prepares); $this->connected = true; } catch (PDOException $e) { $this->connected = false; @@ -155,7 +145,7 @@ class Database } if (!$this->connected && class_exists('\mysqli')) { - $this->driver = 'mysqli'; + $this->driver = self::MYSQLI; if ($port > 0) { $this->connection = @new mysqli($server, $user, $pass, $db, $port); @@ -181,6 +171,10 @@ class Database return $this->connected; } + public function setTestmode(bool $test) + { + $this->testmode = $test; + } /** * Sets the logger for DBA * @@ -212,10 +206,10 @@ class Database { if (!is_null($this->connection)) { switch ($this->driver) { - case 'pdo': + case self::PDO: $this->connection = null; break; - case 'mysqli': + case self::MYSQLI: $this->connection->close(); $this->connection = null; break; @@ -245,6 +239,16 @@ class Database return $this->connection; } + /** + * Return the database driver string + * + * @return string with either "pdo" or "mysqli" + */ + public function getDriver() + { + return $this->driver; + } + /** * Returns the MySQL server version string * @@ -257,10 +261,10 @@ class Database { if ($this->server_info == '') { switch ($this->driver) { - case 'pdo': + case self::PDO: $this->server_info = $this->connection->getAttribute(PDO::ATTR_SERVER_VERSION); break; - case 'mysqli': + case self::MYSQLI: $this->server_info = $this->connection->server_info; break; } @@ -311,7 +315,7 @@ class Database } $watchlist = explode(',', $this->configCache->get('system', 'db_log_index_watch')); - $blacklist = explode(',', $this->configCache->get('system', 'db_log_index_blacklist')); + $denylist = explode(',', $this->configCache->get('system', 'db_log_index_denylist')); while ($row = $this->fetch($r)) { if ((intval($this->configCache->get('system', 'db_loglimit_index')) > 0)) { @@ -325,7 +329,7 @@ class Database $log = true; } - if (in_array($row['key'], $blacklist) || ($row['key'] == "")) { + if (in_array($row['key'], $denylist) || ($row['key'] == "")) { $log = false; } @@ -335,13 +339,13 @@ class Database $row['key'] . "\t" . $row['rows'] . "\t" . $row['Extra'] . "\t" . basename($backtrace[1]["file"]) . "\t" . $backtrace[1]["line"] . "\t" . $backtrace[2]["function"] . "\t" . - substr($query, 0, 2000) . "\n", FILE_APPEND); + substr($query, 0, 4000) . "\n", FILE_APPEND); } } } /** - * Removes every not whitelisted character from the identifier string + * Removes every not allowlisted character from the identifier string * * @param string $identifier * @@ -357,10 +361,10 @@ class Database { if ($this->connected) { switch ($this->driver) { - case 'pdo': + case self::PDO: return substr(@$this->connection->quote($str, PDO::PARAM_STR), 1, -1); - case 'mysqli': + case self::MYSQLI: return @$this->connection->real_escape_string($str); } } else { @@ -382,14 +386,14 @@ class Database } switch ($this->driver) { - case 'pdo': + case self::PDO: $r = $this->p("SELECT 1"); if ($this->isResult($r)) { $row = $this->toArray($r); $connected = ($row[0]['1'] == '1'); } break; - case 'mysqli': + case self::MYSQLI: $connected = $this->connection->ping(); break; } @@ -497,6 +501,7 @@ class Database $sql = "/*" . System::callstack() . " */ " . $sql; } + $is_error = false; $this->error = ''; $this->errorno = 0; $this->affected_rows = 0; @@ -518,14 +523,15 @@ class Database } switch ($this->driver) { - case 'pdo': + case self::PDO: // If there are no arguments we use "query" - if ($this->emulate_prepares || count($args) == 0) { + if (count($args) == 0) { if (!$retval = $this->connection->query($this->replaceParameters($sql, $args))) { $errorInfo = $this->connection->errorInfo(); $this->error = $errorInfo[2]; $this->errorno = $errorInfo[1]; $retval = false; + $is_error = true; break; } $this->affected_rows = $retval->rowCount(); @@ -538,6 +544,7 @@ class Database $this->error = $errorInfo[2]; $this->errorno = $errorInfo[1]; $retval = false; + $is_error = true; break; } @@ -555,24 +562,26 @@ class Database $this->error = $errorInfo[2]; $this->errorno = $errorInfo[1]; $retval = false; + $is_error = true; } else { $retval = $stmt; $this->affected_rows = $retval->rowCount(); } break; - case 'mysqli': + case self::MYSQLI: // There are SQL statements that cannot be executed with a prepared statement $parts = explode(' ', $orig_sql); $command = strtolower($parts[0]); $can_be_prepared = in_array($command, ['select', 'update', 'insert', 'delete']); // The fallback routine is called as well when there are no arguments - if ($this->emulate_prepares || !$can_be_prepared || (count($args) == 0)) { + if (!$can_be_prepared || (count($args) == 0)) { $retval = $this->connection->query($this->replaceParameters($sql, $args)); if ($this->connection->errno) { $this->error = $this->connection->error; $this->errorno = $this->connection->errno; $retval = false; + $is_error = true; } else { if (isset($retval->num_rows)) { $this->affected_rows = $retval->num_rows; @@ -589,6 +598,7 @@ class Database $this->error = $stmt->error; $this->errorno = $stmt->errno; $retval = false; + $is_error = true; break; } @@ -616,6 +626,7 @@ class Database $this->error = $this->connection->error; $this->errorno = $this->connection->errno; $retval = false; + $is_error = true; } else { $stmt->store_result(); $retval = $stmt; @@ -624,15 +635,29 @@ class Database break; } + // See issue https://github.com/friendica/friendica/issues/8572 + // Ensure that we always get an error message on an error. + if ($is_error && empty($this->errorno)) { + $this->errorno = -1; + } + + if ($is_error && empty($this->error)) { + $this->error = 'Unknown database error'; + } + // We are having an own error logging in the function "e" if (($this->errorno != 0) && !$called_from_e) { // We have to preserve the error code, somewhere in the logging it get lost $error = $this->error; $errorno = $this->errorno; + if ($this->testmode) { + throw new DatabaseException($error, $errorno, $this->replaceParameters($sql, $args)); + } + $this->logger->error('DB Error', [ - 'code' => $this->errorno, - 'error' => $this->error, + 'code' => $errorno, + 'error' => $error, 'callstack' => System::callstack(8), 'params' => $this->replaceParameters($sql, $args), ]); @@ -643,21 +668,21 @@ class Database // It doesn't make sense to continue when the database connection was lost if ($this->in_retrial) { $this->logger->notice('Giving up retrial because of database error', [ - 'code' => $this->errorno, - 'error' => $this->error, + 'code' => $errorno, + 'error' => $error, ]); } else { $this->logger->notice('Couldn\'t reconnect after database error', [ - 'code' => $this->errorno, - 'error' => $this->error, + 'code' => $errorno, + 'error' => $error, ]); } exit(1); } else { // We try it again $this->logger->notice('Reconnected after database error', [ - 'code' => $this->errorno, - 'error' => $this->error, + 'code' => $errorno, + 'error' => $error, ]); $this->in_retrial = true; $ret = $this->p($sql, $args); @@ -670,7 +695,7 @@ class Database $this->errorno = $errorno; } - $this->profiler->saveTimestamp($stamp1, 'database', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'database'); if ($this->configCache->get('system', 'db_log')) { $stamp2 = microtime(true); @@ -683,7 +708,7 @@ class Database @file_put_contents($this->configCache->get('system', 'db_log'), DateTimeFormat::utcNow() . "\t" . $duration . "\t" . basename($backtrace[1]["file"]) . "\t" . $backtrace[1]["line"] . "\t" . $backtrace[2]["function"] . "\t" . - substr($this->replaceParameters($sql, $args), 0, 2000) . "\n", FILE_APPEND); + substr($this->replaceParameters($sql, $args), 0, 4000) . "\n", FILE_APPEND); } } return $retval; @@ -729,9 +754,13 @@ class Database $error = $this->error; $errorno = $this->errorno; + if ($this->testmode) { + throw new DatabaseException($error, $errorno, $this->replaceParameters($sql, $params)); + } + $this->logger->error('DB Error', [ - 'code' => $this->errorno, - 'error' => $this->error, + 'code' => $errorno, + 'error' => $error, 'callstack' => System::callstack(8), 'params' => $this->replaceParameters($sql, $params), ]); @@ -740,8 +769,8 @@ class Database // A reconnect like in $this->p could be dangerous with modifications if ($errorno == 2006) { $this->logger->notice('Giving up because of database error', [ - 'code' => $this->errorno, - 'error' => $this->error, + 'code' => $errorno, + 'error' => $error, ]); exit(1); } @@ -750,7 +779,7 @@ class Database $this->errorno = $errorno; } - $this->profiler->saveTimestamp($stamp, "database_write", System::callstack()); + $this->profiler->saveTimestamp($stamp, "database_write"); return $retval; } @@ -847,9 +876,9 @@ class Database return 0; } switch ($this->driver) { - case 'pdo': + case self::PDO: return $stmt->columnCount(); - case 'mysqli': + case self::MYSQLI: return $stmt->field_count; } return 0; @@ -868,9 +897,9 @@ class Database return 0; } switch ($this->driver) { - case 'pdo': + case self::PDO: return $stmt->rowCount(); - case 'mysqli': + case self::MYSQLI: return $stmt->num_rows; } return 0; @@ -879,13 +908,12 @@ class Database /** * Fetch a single row * - * @param mixed $stmt statement object + * @param PDOStatement|mysqli_stmt $stmt statement object * - * @return array current row + * @return array|false current row */ public function fetch($stmt) { - $stamp1 = microtime(true); $columns = []; @@ -895,12 +923,15 @@ class Database } switch ($this->driver) { - case 'pdo': + case self::PDO: $columns = $stmt->fetch(PDO::FETCH_ASSOC); + if (!empty($stmt->table) && is_array($columns)) { + $columns = $this->castFields($stmt->table, $columns); + } break; - case 'mysqli': + case self::MYSQLI: if (get_class($stmt) == 'mysqli_result') { - $columns = $stmt->fetch_assoc(); + $columns = $stmt->fetch_assoc() ?? false; break; } @@ -931,7 +962,7 @@ class Database } } - $this->profiler->saveTimestamp($stamp1, 'database', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'database'); return $columns; } @@ -939,29 +970,37 @@ class Database /** * Insert a row into a table * - * @param string|array $table Table name or array [schema => table] - * @param array $param parameter array - * @param bool $on_duplicate_update Do an update on a duplicate entry + * @param string|array $table Table name or array [schema => table] + * @param array $param parameter array + * @param int $duplicate_mode What to do on a duplicated entry * * @return boolean was the insert successful? * @throws \Exception */ - public function insert($table, $param, $on_duplicate_update = false) + public function insert($table, array $param, int $duplicate_mode = self::INSERT_DEFAULT) { if (empty($table) || empty($param)) { $this->logger->info('Table and fields have to be set'); return false; } + $param = $this->castFields($table, $param); + $table_string = DBA::buildTableString($table); $fields_string = implode(', ', array_map([DBA::class, 'quoteIdentifier'], array_keys($param))); $values_string = substr(str_repeat("?, ", count($param)), 0, -2); - $sql = "INSERT INTO " . $table_string . " (" . $fields_string . ") VALUES (" . $values_string . ")"; + $sql = "INSERT "; - if ($on_duplicate_update) { + if ($duplicate_mode == self::INSERT_IGNORE) { + $sql .= "IGNORE "; + } + + $sql .= "INTO " . $table_string . " (" . $fields_string . ") VALUES (" . $values_string . ")"; + + if ($duplicate_mode == self::INSERT_UPDATE) { $fields_string = implode(' = ?, ', array_map([DBA::class, 'quoteIdentifier'], array_keys($param))); $sql .= " ON DUPLICATE KEY UPDATE " . $fields_string . " = ?"; @@ -970,6 +1009,41 @@ class Database $param = array_merge_recursive($values, $values); } + $result = $this->e($sql, $param); + if (!$result || ($duplicate_mode != self::INSERT_IGNORE)) { + return $result; + } + + return $this->affectedRows() != 0; + } + + /** + * Inserts a row with the provided data in the provided table. + * If the data corresponds to an existing row through a UNIQUE or PRIMARY index constraints, it updates the row instead. + * + * @param string|array $table Table name or array [schema => table] + * @param array $param parameter array + * + * @return boolean was the insert successful? + * @throws \Exception + */ + public function replace($table, array $param) + { + if (empty($table) || empty($param)) { + $this->logger->info('Table and fields have to be set'); + return false; + } + + $param = $this->castFields($table, $param); + + $table_string = DBA::buildTableString($table); + + $fields_string = implode(', ', array_map([DBA::class, 'quoteIdentifier'], array_keys($param))); + + $values_string = substr(str_repeat("?, ", count($param)), 0, -2); + + $sql = "REPLACE " . $table_string . " (" . $fields_string . ") VALUES (" . $values_string . ")"; + return $this->e($sql, $param); } @@ -981,14 +1055,14 @@ class Database public function lastInsertId() { switch ($this->driver) { - case 'pdo': + case self::PDO: $id = $this->connection->lastInsertId(); break; - case 'mysqli': + case self::MYSQLI: $id = $this->connection->insert_id; break; } - return $id; + return (int)$id; } /** @@ -1004,7 +1078,7 @@ class Database public function lock($table) { // See here: https://dev.mysql.com/doc/refman/5.7/en/lock-tables-and-transactions.html - if ($this->driver == 'pdo') { + if ($this->driver == self::PDO) { $this->e("SET autocommit=0"); $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, true); } else { @@ -1013,12 +1087,12 @@ class Database $success = $this->e("LOCK TABLES " . DBA::buildTableString($table) . " WRITE"); - if ($this->driver == 'pdo') { - $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); + if ($this->driver == self::PDO) { + $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, $this->pdo_emulate_prepares); } if (!$success) { - if ($this->driver == 'pdo') { + if ($this->driver == self::PDO) { $this->e("SET autocommit=1"); } else { $this->connection->autocommit(true); @@ -1040,14 +1114,14 @@ class Database // See here: https://dev.mysql.com/doc/refman/5.7/en/lock-tables-and-transactions.html $this->performCommit(); - if ($this->driver == 'pdo') { + if ($this->driver == self::PDO) { $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, true); } $success = $this->e("UNLOCK TABLES"); - if ($this->driver == 'pdo') { - $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); + if ($this->driver == self::PDO) { + $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, $this->pdo_emulate_prepares); $this->e("SET autocommit=1"); } else { $this->connection->autocommit(true); @@ -1069,13 +1143,13 @@ class Database } switch ($this->driver) { - case 'pdo': + case self::PDO: if (!$this->connection->inTransaction() && !$this->connection->beginTransaction()) { return false; } break; - case 'mysqli': + case self::MYSQLI: if (!$this->connection->begin_transaction()) { return false; } @@ -1089,14 +1163,14 @@ class Database protected function performCommit() { switch ($this->driver) { - case 'pdo': + case self::PDO: if (!$this->connection->inTransaction()) { return true; } return $this->connection->commit(); - case 'mysqli': + case self::MYSQLI: return $this->connection->commit(); } @@ -1127,7 +1201,7 @@ class Database $ret = false; switch ($this->driver) { - case 'pdo': + case self::PDO: if (!$this->connection->inTransaction()) { $ret = true; break; @@ -1135,7 +1209,7 @@ class Database $ret = $this->connection->rollBack(); break; - case 'mysqli': + case self::MYSQLI: $ret = $this->connection->rollback(); break; } @@ -1358,7 +1432,7 @@ class Database if (is_bool($old_fields)) { if ($do_insert) { $values = array_merge($condition, $fields); - return $this->insert($table, $values, $do_insert); + return $this->replace($table, $values); } $old_fields = []; } @@ -1374,6 +1448,8 @@ class Database return true; } + $fields = $this->castFields($table, $fields); + $table_string = DBA::buildTableString($table); $condition_string = DBA::buildCondition($condition); @@ -1434,24 +1510,30 @@ class Database /** * Select rows from a table * + * + * Example: + * $table = 'item'; + * or: + * $table = ['schema' => 'table']; + * @see DBA::buildTableString() + * + * $fields = ['id', 'uri', 'uid', 'network']; + * + * $condition = ['uid' => 1, 'network' => 'dspr', 'blocked' => true]; + * or: + * $condition = ['`uid` = ? AND `network` IN (?, ?)', 1, 'dfrn', 'dspr']; + * @see DBA::buildCondition() + * + * $params = ['order' => ['id', 'received' => true, 'created' => 'ASC'), 'limit' => 10]; + * @see DBA::buildParameter() + * + * $data = DBA::select($table, $fields, $condition, $params); + * * @param string|array $table Table name or array [schema => table] * @param array $fields Array of selected fields, empty for all * @param array $condition Array of fields for condition * @param array $params Array of several parameters - * * @return boolean|object - * - * Example: - * $table = "item"; - * $fields = array("id", "uri", "uid", "network"); - * - * $condition = array("uid" => 1, "network" => 'dspr'); - * or: - * $condition = array("`uid` = ? AND `network` IN (?, ?)", 1, 'dfrn', 'dspr'); - * - * $params = array("order" => array("id", "received" => true), "limit" => 10); - * - * $data = DBA::select($table, $fields, $condition, $params); * @throws \Exception */ public function select($table, array $fields = [], array $condition = [], array $params = []) @@ -1476,6 +1558,10 @@ class Database $result = $this->p($sql, $condition); + if (($this->driver == self::PDO) && !empty($result) && is_string($table)) { + $result->table = $table; + } + return $result; } @@ -1520,7 +1606,8 @@ class Database $row = $this->fetchFirst($sql, $condition); - return $row['count']; + // Ensure to always return either a "null" or a numeric value + return is_numeric($row['count']) ? (int)$row['count'] : $row['count']; } /** @@ -1549,6 +1636,71 @@ class Database return $data; } + /** + * Cast field types according to the table definition + * + * @param string $table + * @param array $fields + * @return array casted fields + */ + public function castFields(string $table, array $fields) { + // When there is no data, we don't need to do something + if (empty($fields)) { + return $fields; + } + + // We only need to cast fields with PDO + if ($this->driver != self::PDO) { + return $fields; + } + + // We only need to cast when emulating the prepares + if (!$this->connection->getAttribute(PDO::ATTR_EMULATE_PREPARES)) { + return $fields; + } + + $types = []; + + $tables = DBStructure::definition('', false); + if (empty($tables[$table])) { + // When a matching table wasn't found we check if it is a view + $views = View::definition('', false); + if (empty($views[$table])) { + return $fields; + } + + foreach(array_keys($fields) as $field) { + if (!empty($views[$table]['fields'][$field])) { + $viewdef = $views[$table]['fields'][$field]; + if (!empty($tables[$viewdef[0]]['fields'][$viewdef[1]]['type'])) { + $types[$field] = $tables[$viewdef[0]]['fields'][$viewdef[1]]['type']; + } + } + } + } else { + foreach ($tables[$table]['fields'] as $field => $definition) { + $types[$field] = $definition['type']; + } + } + + foreach ($fields as $field => $content) { + if (is_null($content) || empty($types[$field])) { + continue; + } + + if ((substr($types[$field], 0, 7) == 'tinyint') || (substr($types[$field], 0, 8) == 'smallint') || + (substr($types[$field], 0, 9) == 'mediumint') || (substr($types[$field], 0, 3) == 'int') || + (substr($types[$field], 0, 6) == 'bigint') || (substr($types[$field], 0, 7) == 'boolean')) { + $fields[$field] = (int)$content; + } + if ((substr($types[$field], 0, 5) == 'float') || (substr($types[$field], 0, 6) == 'double')) { + $fields[$field] = (float)$content; + } + } + + return $fields; + } + /** * Returns the error number of the last query * @@ -1586,10 +1738,10 @@ class Database } switch ($this->driver) { - case 'pdo': + case self::PDO: $ret = $stmt->closeCursor(); break; - case 'mysqli': + case self::MYSQLI: // MySQLi offers both a mysqli_stmt and a mysqli_result class. // We should be careful not to assume the object type of $stmt // because DBA::p() has been able to return both types. @@ -1605,7 +1757,7 @@ class Database break; } - $this->profiler->saveTimestamp($stamp1, 'database', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'database'); return $ret; } diff --git a/src/Database/DatabaseException.php b/src/Database/DatabaseException.php new file mode 100644 index 0000000000..8bf5d8a6c2 --- /dev/null +++ b/src/Database/DatabaseException.php @@ -0,0 +1,39 @@ +query = $query; + } + + /** + * {@inheritDoc} + */ + public function __toString() + { + return sprintf('Database error %d "%s" at "%s"', $this->message, $this->code, $this->query); + } +} diff --git a/src/Database/PostUpdate.php b/src/Database/PostUpdate.php index 82cc07c6d6..6e6e0dd06f 100644 --- a/src/Database/PostUpdate.php +++ b/src/Database/PostUpdate.php @@ -25,13 +25,15 @@ use Friendica\Core\Logger; use Friendica\Core\Protocol; use Friendica\DI; use Friendica\Model\Contact; +use Friendica\Model\GServer; use Friendica\Model\Item; use Friendica\Model\ItemURI; use Friendica\Model\PermissionSet; +use Friendica\Model\Photo; use Friendica\Model\Post\Category; use Friendica\Model\Tag; -use Friendica\Model\Term; use Friendica\Model\UserItem; +use Friendica\Model\Verb; use Friendica\Util\Strings; /** @@ -42,6 +44,10 @@ use Friendica\Util\Strings; */ class PostUpdate { + // Needed for the helper function to read from the legacy term table + const OBJECT_TYPE_POST = 1; + const VERSION = 1384; + /** * Calls the post update functions */ @@ -80,6 +86,21 @@ class PostUpdate if (!self::update1346()) { return false; } + if (!self::update1347()) { + return false; + } + if (!self::update1348()) { + return false; + } + if (!self::update1349()) { + return false; + } + if (!self::update1383()) { + return false; + } + if (!self::update1384()) { + return false; + } return true; } @@ -97,7 +118,7 @@ class PostUpdate return true; } - Logger::log("Start", Logger::DEBUG); + Logger::info("Start"); $end_id = DI::config()->get("system", "post_update_1194_end"); if (!$end_id) { @@ -108,7 +129,7 @@ class PostUpdate } } - Logger::log("End ID: ".$end_id, Logger::DEBUG); + Logger::info("End ID: ".$end_id); $start_id = DI::config()->get("system", "post_update_1194_start"); @@ -127,14 +148,14 @@ class PostUpdate DBA::escape(Protocol::DFRN), DBA::escape(Protocol::DIASPORA), DBA::escape(Protocol::OSTATUS)); if (!$r) { DI::config()->set("system", "post_update_version", 1194); - Logger::log("Update is done", Logger::DEBUG); + Logger::info("Update is done"); return true; } else { DI::config()->set("system", "post_update_1194_start", $r[0]["id"]); $start_id = DI::config()->get("system", "post_update_1194_start"); } - Logger::log("Start ID: ".$start_id, Logger::DEBUG); + Logger::info("Start ID: ".$start_id); $r = q($query1.$query2.$query3." ORDER BY `item`.`id` LIMIT 1000,1", intval($start_id), intval($end_id), @@ -144,13 +165,13 @@ class PostUpdate } else { $pos_id = $end_id; } - Logger::log("Progress: Start: ".$start_id." position: ".$pos_id." end: ".$end_id, Logger::DEBUG); + Logger::info("Progress: Start: ".$start_id." position: ".$pos_id." end: ".$end_id); q("UPDATE `item` ".$query2." SET `item`.`global` = 1 ".$query3, intval($start_id), intval($pos_id), DBA::escape(Protocol::DFRN), DBA::escape(Protocol::DIASPORA), DBA::escape(Protocol::OSTATUS)); - Logger::log("Done", Logger::DEBUG); + Logger::info("Done"); } /** @@ -168,7 +189,7 @@ class PostUpdate return true; } - Logger::log("Start", Logger::DEBUG); + Logger::info("Start"); $r = q("SELECT `contact`.`id`, `contact`.`last-item`, (SELECT MAX(`changed`) FROM `item` USE INDEX (`uid_wall_changed`) WHERE `wall` AND `uid` = `user`.`uid`) AS `lastitem_date` FROM `user` @@ -184,7 +205,7 @@ class PostUpdate } DI::config()->set("system", "post_update_version", 1206); - Logger::log("Done", Logger::DEBUG); + Logger::info("Done"); return true; } @@ -204,7 +225,7 @@ class PostUpdate $id = DI::config()->get("system", "post_update_version_1279_id", 0); - Logger::log("Start from item " . $id, Logger::DEBUG); + Logger::info("Start from item " . $id); $fields = array_merge(Item::MIXED_CONTENT_FIELDLIST, ['network', 'author-id', 'owner-id', 'tag', 'file', 'author-name', 'author-avatar', 'author-link', 'owner-name', 'owner-avatar', 'owner-link', 'id', @@ -218,7 +239,7 @@ class PostUpdate $items = Item::select($fields, $condition, $params); if (DBA::errorNo() != 0) { - Logger::log('Database error ' . DBA::errorNo() . ':' . DBA::errorMessage()); + Logger::info('Database error ' . DBA::errorNo() . ':' . DBA::errorMessage()); return false; } @@ -229,14 +250,14 @@ class PostUpdate $default = ['url' => $item['author-link'], 'name' => $item['author-name'], 'photo' => $item['author-avatar'], 'network' => $item['network']]; - $item['author-id'] = Contact::getIdForURL($item["author-link"], 0, false, $default); + $item['author-id'] = Contact::getIdForURL($item["author-link"], 0, null, $default); } if (empty($item['owner-id'])) { $default = ['url' => $item['owner-link'], 'name' => $item['owner-name'], 'photo' => $item['owner-avatar'], 'network' => $item['network']]; - $item['owner-id'] = Contact::getIdForURL($item["owner-link"], 0, false, $default); + $item['owner-id'] = Contact::getIdForURL($item["owner-link"], 0, null, $default); } if (empty($item['psid'])) { @@ -279,7 +300,7 @@ class PostUpdate DI::config()->set("system", "post_update_version_1279_id", $id); - Logger::log("Processed rows: " . $rows . " - last processed item: " . $id, Logger::DEBUG); + Logger::info("Processed rows: " . $rows . " - last processed item: " . $id); if ($start_id == $id) { // Set all deprecated fields to "null" if they contain an empty string @@ -291,13 +312,13 @@ class PostUpdate foreach ($nullfields as $field) { $fields = [$field => null]; $condition = [$field => '']; - Logger::log("Setting '" . $field . "' to null if empty.", Logger::DEBUG); + Logger::info("Setting '" . $field . "' to null if empty."); // Important: This has to be a "DBA::update", not a "Item::update" DBA::update('item', $fields, $condition); } DI::config()->set("system", "post_update_version", 1279); - Logger::log("Done", Logger::DEBUG); + Logger::info("Done"); return true; } @@ -361,7 +382,7 @@ class PostUpdate $id = DI::config()->get("system", "post_update_version_1281_id", 0); - Logger::log("Start from item " . $id, Logger::DEBUG); + Logger::info("Start from item " . $id); $fields = ['id', 'guid', 'uri', 'uri-id', 'parent-uri', 'parent-uri-id', 'thr-parent', 'thr-parent-id']; @@ -372,7 +393,7 @@ class PostUpdate $items = DBA::select('item', $fields, $condition, $params); if (DBA::errorNo() != 0) { - Logger::log('Database error ' . DBA::errorNo() . ':' . DBA::errorMessage()); + Logger::info('Database error ' . DBA::errorNo() . ':' . DBA::errorMessage()); return false; } @@ -413,17 +434,17 @@ class PostUpdate DI::config()->set("system", "post_update_version_1281_id", $id); - Logger::log("Processed rows: " . $rows . " - last processed item: " . $id, Logger::DEBUG); + Logger::info("Processed rows: " . $rows . " - last processed item: " . $id); if ($start_id == $id) { - Logger::log("Updating item-uri in item-activity", Logger::DEBUG); + Logger::info("Updating item-uri in item-activity"); DBA::e("UPDATE `item-activity` INNER JOIN `item-uri` ON `item-uri`.`uri` = `item-activity`.`uri` SET `item-activity`.`uri-id` = `item-uri`.`id` WHERE `item-activity`.`uri-id` IS NULL"); - Logger::log("Updating item-uri in item-content", Logger::DEBUG); + Logger::info("Updating item-uri in item-content"); DBA::e("UPDATE `item-content` INNER JOIN `item-uri` ON `item-uri`.`uri` = `item-content`.`uri` SET `item-content`.`uri-id` = `item-uri`.`id` WHERE `item-content`.`uri-id` IS NULL"); DI::config()->set("system", "post_update_version", 1281); - Logger::log("Done", Logger::DEBUG); + Logger::info("Done"); return true; } @@ -443,6 +464,11 @@ class PostUpdate return true; } + if (!DBStructure::existsTable('item-delivery-data')) { + DI::config()->set('system', 'post_update_version', 1297); + return true; + } + $max_item_delivery_data = DBA::selectFirst('item-delivery-data', ['iid'], ['queue_count > 0 OR queue_done > 0'], ['order' => ['iid']]); $max_iid = $max_item_delivery_data['iid']; @@ -616,6 +642,11 @@ class PostUpdate return true; } + if (!DBStructure::existsTable('term')) { + DI::config()->set('system', 'post_update_version', 1342); + return true; + } + $id = DI::config()->get('system', 'post_update_version_1342_id', 0); Logger::info('Start', ['item' => $id]); @@ -687,6 +718,11 @@ class PostUpdate return true; } + if (!DBStructure::existsTable('item-delivery-data')) { + DI::config()->set('system', 'post_update_version', 1345); + return true; + } + $id = DI::config()->get('system', 'post_update_version_1345_id', 0); Logger::info('Start', ['item' => $id]); @@ -707,7 +743,7 @@ class PostUpdate while ($delivery = DBA::fetch($deliveries)) { $id = $delivery['iid']; unset($delivery['iid']); - DBA::insert('post-delivery-data', $delivery, true); + DBA::insert('post-delivery-data', $delivery, Database::INSERT_UPDATE); ++$rows; } DBA::close($deliveries); @@ -727,6 +763,31 @@ class PostUpdate return false; } + /** + * Generates the legacy item.file field string from an item ID. + * Includes only file and category terms. + * + * @param int $item_id + * @return string + * @throws \Exception + */ + private static function fileTextFromItemId($item_id) + { + $file_text = ''; + + $condition = ['otype' => self::OBJECT_TYPE_POST, 'oid' => $item_id, 'type' => [Category::FILE, Category::CATEGORY]]; + $tags = DBA::selectToArray('term', ['type', 'term', 'url'], $condition); + foreach ($tags as $tag) { + if ($tag['type'] == Category::CATEGORY) { + $file_text .= '<' . $tag['term'] . '>'; + } else { + $file_text .= '[' . $tag['term'] . ']'; + } + } + + return $file_text; + } + /** * Fill the "tag" table with tags and mentions from the "term" table * @@ -740,6 +801,11 @@ class PostUpdate return true; } + if (!DBStructure::existsTable('term')) { + DI::config()->set('system', 'post_update_version', 1346); + return true; + } + $id = DI::config()->get('system', 'post_update_version_1346_id', 0); Logger::info('Start', ['item' => $id]); @@ -761,7 +827,7 @@ class PostUpdate continue; } - $file = Term::fileTextFromItemId($term['oid']); + $file = self::fileTextFromItemId($term['oid']); if (!empty($file)) { Category::storeTextByURIId($item['uri-id'], $item['uid'], $file); } @@ -787,5 +853,262 @@ class PostUpdate } return false; - } + } + + /** + * update the "vid" (verb) field in the item table + * + * @return bool "true" when the job is done + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + private static function update1347() + { + // Was the script completed? + if (DI::config()->get("system", "post_update_version") >= 1347) { + return true; + } + + $id = DI::config()->get("system", "post_update_version_1347_id", 0); + + Logger::info('Start', ['item' => $id]); + + $start_id = $id; + $rows = 0; + + $items = DBA::p("SELECT `item`.`id`, `item`.`verb` AS `item-verb`, `item-content`.`verb`, `item-activity`.`activity` + FROM `item` LEFT JOIN `item-content` ON `item-content`.`uri-id` = `item`.`uri-id` + LEFT JOIN `item-activity` ON `item-activity`.`uri-id` = `item`.`uri-id` AND `item`.`gravity` = ? + WHERE `item`.`id` >= ? AND `item`.`vid` IS NULL ORDER BY `item`.`id` LIMIT 10000", GRAVITY_ACTIVITY, $id); + + if (DBA::errorNo() != 0) { + Logger::error('Database error', ['no' => DBA::errorNo(), 'message' => DBA::errorMessage()]); + return false; + } + + while ($item = DBA::fetch($items)) { + $id = $item['id']; + $verb = $item['item-verb']; + if (empty($verb)) { + $verb = $item['verb']; + } + if (empty($verb) && is_int($item['activity'])) { + $verb = Item::ACTIVITIES[$item['activity']]; + } + if (empty($verb)) { + continue; + } + + DBA::update('item', ['vid' => Verb::getID($verb)], ['id' => $item['id']]); + ++$rows; + } + DBA::close($items); + + DI::config()->set("system", "post_update_version_1347_id", $id); + + Logger::info('Processed', ['rows' => $rows, 'last' => $id]); + + if ($start_id == $id) { + DI::config()->set("system", "post_update_version", 1347); + Logger::info('Done'); + return true; + } + + return false; + } + + /** + * update the "gsid" (global server id) field in the contact table + * + * @return bool "true" when the job is done + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + private static function update1348() + { + // Was the script completed? + if (DI::config()->get("system", "post_update_version") >= 1348) { + return true; + } + + $id = DI::config()->get("system", "post_update_version_1348_id", 0); + + Logger::info('Start', ['contact' => $id]); + + $start_id = $id; + $rows = 0; + $condition = ["`id` > ? AND `gsid` IS NULL AND `baseurl` != '' AND NOT `baseurl` IS NULL", $id]; + $params = ['order' => ['id'], 'limit' => 10000]; + $contacts = DBA::select('contact', ['id', 'baseurl'], $condition, $params); + + if (DBA::errorNo() != 0) { + Logger::error('Database error', ['no' => DBA::errorNo(), 'message' => DBA::errorMessage()]); + return false; + } + + while ($contact = DBA::fetch($contacts)) { + $id = $contact['id']; + + DBA::update('contact', + ['gsid' => GServer::getID($contact['baseurl'], true), 'baseurl' => GServer::cleanURL($contact['baseurl'])], + ['id' => $contact['id']]); + + ++$rows; + } + DBA::close($contacts); + + DI::config()->set("system", "post_update_version_1348_id", $id); + + Logger::info('Processed', ['rows' => $rows, 'last' => $id]); + + if ($start_id == $id) { + DI::config()->set("system", "post_update_version", 1348); + Logger::info('Done'); + return true; + } + + return false; + } + + /** + * update the "gsid" (global server id) field in the apcontact table + * + * @return bool "true" when the job is done + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + private static function update1349() + { + // Was the script completed? + if (DI::config()->get("system", "post_update_version") >= 1349) { + return true; + } + + $id = DI::config()->get("system", "post_update_version_1349_id", ''); + + Logger::info('Start', ['apcontact' => $id]); + + $start_id = $id; + $rows = 0; + $condition = ["`url` > ? AND `gsid` IS NULL AND `baseurl` != '' AND NOT `baseurl` IS NULL", $id]; + $params = ['order' => ['url'], 'limit' => 10000]; + $apcontacts = DBA::select('apcontact', ['url', 'baseurl'], $condition, $params); + + if (DBA::errorNo() != 0) { + Logger::error('Database error', ['no' => DBA::errorNo(), 'message' => DBA::errorMessage()]); + return false; + } + + while ($apcontact = DBA::fetch($apcontacts)) { + $id = $apcontact['url']; + + DBA::update('apcontact', + ['gsid' => GServer::getID($apcontact['baseurl'], true), 'baseurl' => GServer::cleanURL($apcontact['baseurl'])], + ['url' => $apcontact['url']]); + + ++$rows; + } + DBA::close($apcontacts); + + DI::config()->set("system", "post_update_version_1349_id", $id); + + Logger::info('Processed', ['rows' => $rows, 'last' => $id]); + + if ($start_id == $id) { + DI::config()->set("system", "post_update_version", 1349); + Logger::info('Done'); + return true; + } + + return false; + } + + /** + * Remove orphaned photo entries + * + * @return bool "true" when the job is done + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + private static function update1383() + { + // Was the script completed? + if (DI::config()->get("system", "post_update_version") >= 1383) { + return true; + } + + Logger::info('Start'); + + $deleted = 0; + $avatar = [4 => 'photo', 5 => 'thumb', 6 => 'micro']; + + $photos = DBA::select('photo', ['id', 'contact-id', 'resource-id', 'scale'], ["`contact-id` != ? AND `album` = ?", 0, Photo::CONTACT_PHOTOS]); + while ($photo = DBA::fetch($photos)) { + $delete = !in_array($photo['scale'], [4, 5, 6]); + + if (!$delete) { + // Check if there is a contact entry with that photo + $delete = !DBA::exists('contact', ["`id` = ? AND `" . $avatar[$photo['scale']] . "` LIKE ?", + $photo['contact-id'], '%' . $photo['resource-id'] . '%']); + } + + if ($delete) { + Photo::delete(['id' => $photo['id']]); + $deleted++; + } + } + DBA::close($photos); + + DI::config()->set("system", "post_update_version", 1383); + Logger::info('Done', ['deleted' => $deleted]); + return true; + } + + /** + * update the "hash" field in the photo table + * + * @return bool "true" when the job is done + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + private static function update1384() + { + // Was the script completed? + if (DI::config()->get("system", "post_update_version") >= 1384) { + return true; + } + + $condition = ["`hash` IS NULL"]; + Logger::info('Start', ['rest' => DBA::count('photo', $condition)]); + + $rows = 0; + $photos = DBA::select('photo', [], $condition, ['limit' => 10000]); + + if (DBA::errorNo() != 0) { + Logger::error('Database error', ['no' => DBA::errorNo(), 'message' => DBA::errorMessage()]); + return false; + } + + while ($photo = DBA::fetch($photos)) { + $img = Photo::getImageForPhoto($photo); + if (!empty($img)) { + $md5 = md5($img->asString()); + } else { + $md5 = ''; + } + DBA::update('photo', ['hash' => $md5], ['id' => $photo['id']]); + ++$rows; + } + DBA::close($photos); + + Logger::info('Processed', ['rows' => $rows]); + + if ($rows <= 100) { + DI::config()->set("system", "post_update_version", 1384); + Logger::info('Done'); + return true; + } + + return false; + } } diff --git a/src/Database/View.php b/src/Database/View.php index e1335d9df6..e49eb8cc51 100644 --- a/src/Database/View.php +++ b/src/Database/View.php @@ -111,13 +111,17 @@ class View } } - $sql = sprintf("DROP VIEW IF EXISTS `%s`", DBA::escape($name)); + if (self::isView($name)) { + $sql = sprintf("DROP VIEW IF EXISTS `%s`", DBA::escape($name)); + } elseif (self::isTable($name)) { + $sql = sprintf("DROP TABLE IF EXISTS `%s`", DBA::escape($name)); + } - if ($verbose) { + if (!empty($sql) && $verbose) { echo $sql . ";\n"; } - if ($action) { + if (!empty($sql) && $action) { DBA::e($sql); } @@ -134,4 +138,40 @@ class View return $r; } + + /** + * Check if the given table/view is a view + * + * @param string $view + * @return boolean "true" if it's a view + */ + private static function isView(string $view) + { + $status = DBA::selectFirst(['INFORMATION_SCHEMA' => 'TABLES'], ['TABLE_TYPE'], + ['TABLE_SCHEMA' => DBA::databaseName(), 'TABLE_NAME' => $view]); + + if (empty($status['TABLE_TYPE'])) { + return false; + } + + return $status['TABLE_TYPE'] == 'VIEW'; + } + + /** + * Check if the given table/view is a table + * + * @param string $table + * @return boolean "true" if it's a table + */ + private static function isTable(string $table) + { + $status = DBA::selectFirst(['INFORMATION_SCHEMA' => 'TABLES'], ['TABLE_TYPE'], + ['TABLE_SCHEMA' => DBA::databaseName(), 'TABLE_NAME' => $table]); + + if (empty($status['TABLE_TYPE'])) { + return false; + } + + return $status['TABLE_TYPE'] == 'BASE TABLE'; + } } diff --git a/src/Factory/Api/Mastodon/Account.php b/src/Factory/Api/Mastodon/Account.php index b0c31c09b4..a459f8d590 100644 --- a/src/Factory/Api/Mastodon/Account.php +++ b/src/Factory/Api/Mastodon/Account.php @@ -67,9 +67,21 @@ class Account extends BaseFactory $userContact = []; } + if (empty($publicContact)) { + throw new HTTPException\NotFoundException('Contact ' . $contactId . ' not found'); + } + $apcontact = APContact::getByURL($publicContact['url'], false); - return new \Friendica\Object\Api\Mastodon\Account($this->baseUrl, $publicContact, new Fields(), $apcontact, $userContact); + $self_contact = Contact::selectFirst(['uid'], ['nurl' => $publicContact['nurl'], 'self' => true]); + if (!empty($self_contact['uid'])) { + $profileFields = $this->profileField->select(['uid' => $self_contact['uid'], 'psid' => PermissionSet::PUBLIC]); + $fields = $this->mstdnField->createFromProfileFields($profileFields); + } else { + $fields = new Fields(); + } + + return new \Friendica\Object\Api\Mastodon\Account($this->baseUrl, $publicContact, $fields, $apcontact, $userContact); } /** diff --git a/src/Factory/Api/Mastodon/Attachment.php b/src/Factory/Api/Mastodon/Attachment.php new file mode 100644 index 0000000000..e681b34c2d --- /dev/null +++ b/src/Factory/Api/Mastodon/Attachment.php @@ -0,0 +1,96 @@ +. + * + */ + +namespace Friendica\Factory\Api\Mastodon; + +use Friendica\App\BaseURL; +use Friendica\BaseFactory; +use Friendica\Network\HTTPException; +use Friendica\Model\Post; +use Friendica\Repository\ProfileField; +use Friendica\Util\Proxy; +use Psr\Log\LoggerInterface; + +class Attachment extends BaseFactory +{ + /** @var BaseURL */ + protected $baseUrl; + /** @var ProfileField */ + protected $profileField; + /** @var Field */ + protected $mstdnField; + + public function __construct(LoggerInterface $logger, BaseURL $baseURL, ProfileField $profileField, Field $mstdnField) + { + parent::__construct($logger); + + $this->baseUrl = $baseURL; + $this->profileField = $profileField; + $this->mstdnField = $mstdnField; + } + + /** + * @param int $uriId Uri-ID of the attachments + * @return array + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public function createFromUriId(int $uriId) + { + $attachments = []; + foreach (Post\Media::getByURIId($uriId) as $attachment) { + + $filetype = !empty($attachment['mimetype']) ? strtolower(substr($attachment['mimetype'], 0, strpos($attachment['mimetype'], '/'))) : ''; + + if (($filetype == 'audio') || ($attachment['type'] == Post\Media::AUDIO)) { + $type = 'audio'; + } elseif (($filetype == 'video') || ($attachment['type'] == Post\Media::VIDEO)) { + $type = 'video'; + } elseif ($attachment['mimetype'] == 'image/gif') { + $type = 'gifv'; + } elseif (($filetype == 'image') || ($attachment['type'] == Post\Media::IMAGE)) { + $type = 'image'; + } else { + $type = 'unknown'; + } + + $remote = $attachment['url']; + if ($type == 'image') { + if (Proxy::isLocalImage($attachment['url'])) { + $url = $attachment['url']; + $preview = $attachment['preview'] ?? $url; + $remote = ''; + } else { + $url = Proxy::proxifyUrl($attachment['url']); + $preview = Proxy::proxifyUrl($attachment['url'], false, Proxy::SIZE_SMALL); + } + } else { + $url = ''; + $preview = ''; + } + + $object = new \Friendica\Object\Api\Mastodon\Attachment($attachment, $type, $url, $preview, $remote); + $attachments[] = $object->toArray(); + } + + return $attachments; + } +} diff --git a/src/Factory/Api/Mastodon/Emoji.php b/src/Factory/Api/Mastodon/Emoji.php index a9b4bba549..f02ca87898 100644 --- a/src/Factory/Api/Mastodon/Emoji.php +++ b/src/Factory/Api/Mastodon/Emoji.php @@ -21,13 +21,8 @@ namespace Friendica\Factory\Api\Mastodon; -use Friendica\App\BaseURL; use Friendica\BaseFactory; use Friendica\Collection\Api\Mastodon\Emojis; -use Friendica\Model\APContact; -use Friendica\Model\Contact; -use Friendica\Network\HTTPException; -use Psr\Log\LoggerInterface; class Emoji extends BaseFactory { diff --git a/mod/update_network.php b/src/Factory/Api/Mastodon/Error.php similarity index 63% rename from mod/update_network.php rename to src/Factory/Api/Mastodon/Error.php index aafc0e22f4..e7f645fdfa 100644 --- a/mod/update_network.php +++ b/src/Factory/Api/Mastodon/Error.php @@ -17,28 +17,22 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * - * See update_profile.php for documentation */ -use Friendica\App; +namespace Friendica\Factory\Api\Mastodon; + +use Friendica\BaseFactory; use Friendica\Core\System; use Friendica\DI; -require_once "mod/network.php"; - -function update_network_content(App $a) +class Error extends BaseFactory { - if (!isset($_GET['p']) || !isset($_GET['item'])) { - exit(); - } + public function RecordNotFound() + { + $error = DI::l10n()->t('Record not found'); + $error_description = ''; + $errorobj = New \Friendica\Object\Api\Mastodon\Error($error, $error_description); - $profile_uid = intval($_GET['p']); - $parent = intval($_GET['item']); - - if (!DI::pConfig()->get($profile_uid, "system", "no_auto_update") || ($_GET["force"] == 1)) { - $text = network_content($a, $profile_uid, $parent); - } else { - $text = ""; + System::jsonError(404, $errorobj->toArray()); } - System::htmlUpdateExit($text); } diff --git a/src/Factory/Api/Mastodon/Field.php b/src/Factory/Api/Mastodon/Field.php index 6570ab8845..d357ee2fa5 100644 --- a/src/Factory/Api/Mastodon/Field.php +++ b/src/Factory/Api/Mastodon/Field.php @@ -37,7 +37,7 @@ class Field extends BaseFactory */ public function createFromProfileField(ProfileField $profileField) { - return new \Friendica\Api\Entity\Mastodon\Field($profileField->label, BBCode::convert($profileField->value, false, 9)); + return new \Friendica\Object\Api\Mastodon\Field($profileField->label, BBCode::convert($profileField->value, false, BBCode::ACTIVITYPUB)); } /** diff --git a/src/Factory/Api/Mastodon/Mention.php b/src/Factory/Api/Mastodon/Mention.php new file mode 100644 index 0000000000..5ab82d710f --- /dev/null +++ b/src/Factory/Api/Mastodon/Mention.php @@ -0,0 +1,67 @@ +. + * + */ + +namespace Friendica\Factory\Api\Mastodon; + +use Friendica\App\BaseURL; +use Friendica\BaseFactory; +use Friendica\Model\Contact; +use Friendica\Model\Tag; +use Friendica\Network\HTTPException; +use Friendica\Repository\ProfileField; +use Psr\Log\LoggerInterface; + +class Mention extends BaseFactory +{ + /** @var BaseURL */ + protected $baseUrl; + /** @var ProfileField */ + protected $profileField; + /** @var Field */ + protected $mstdnField; + + public function __construct(LoggerInterface $logger, BaseURL $baseURL, ProfileField $profileField, Field $mstdnField) + { + parent::__construct($logger); + + $this->baseUrl = $baseURL; + $this->profileField = $profileField; + $this->mstdnField = $mstdnField; + } + + /** + * @param int $uriId Uri-ID of the item + * @return array + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public function createFromUriId(int $uriId) + { + $mentions = []; + $tags = Tag::getByURIId($uriId, [Tag::MENTION, Tag::EXCLUSIVE_MENTION, Tag::IMPLICIT_MENTION]); + foreach ($tags as $tag) { + $contact = Contact::getByURL($tag['url'], false); + $mention = new \Friendica\Object\Api\Mastodon\Mention($this->baseUrl, $tag, $contact); + $mentions[] = $mention->toArray(); + } + return $mentions; + } +} diff --git a/src/Factory/Api/Mastodon/Status.php b/src/Factory/Api/Mastodon/Status.php new file mode 100644 index 0000000000..e1c11de6cb --- /dev/null +++ b/src/Factory/Api/Mastodon/Status.php @@ -0,0 +1,105 @@ +. + * + */ + +namespace Friendica\Factory\Api\Mastodon; + +use Friendica\App\BaseURL; +use Friendica\BaseFactory; +use Friendica\Content\Text\BBCode; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Item; +use Friendica\Model\Verb; +use Friendica\Network\HTTPException; +use Friendica\Protocol\Activity; +use Friendica\Repository\ProfileField; +use Psr\Log\LoggerInterface; + +class Status extends BaseFactory +{ + /** @var BaseURL */ + protected $baseUrl; + /** @var ProfileField */ + protected $profileField; + /** @var Field */ + protected $mstdnField; + + public function __construct(LoggerInterface $logger, BaseURL $baseURL, ProfileField $profileField, Field $mstdnField) + { + parent::__construct($logger); + + $this->baseUrl = $baseURL; + $this->profileField = $profileField; + $this->mstdnField = $mstdnField; + } + + /** + * @param int $uriId Uri-ID of the item + * @param int $uid Item user + * @return \Friendica\Object\Api\Mastodon\Status + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public function createFromUriId(int $uriId, $uid = 0) + { + $item = Item::selectFirst([], ['uri-id' => $uriId, 'uid' => $uid]); + if (!$item) { + throw new HTTPException\NotFoundException('Item with URI ID ' . $uriId . 'not found' . ($uid ? ' for user ' . $uid : '.')); + } + + $account = DI::mstdnAccount()->createFromContactId($item['author-id']); + + $counts = new \Friendica\Object\Api\Mastodon\Status\Counts( + DBA::count('item', ['thr-parent-id' => $uriId, 'uid' => $uid, 'gravity' => GRAVITY_COMMENT]), + DBA::count('item', ['thr-parent-id' => $uriId, 'uid' => $uid, 'gravity' => GRAVITY_ACTIVITY, 'vid' => Verb::getID(Activity::ANNOUNCE)]), + DBA::count('item', ['thr-parent-id' => $uriId, 'uid' => $uid, 'gravity' => GRAVITY_ACTIVITY, 'vid' => Verb::getID(Activity::LIKE)]) + ); + + $userAttributes = new \Friendica\Object\Api\Mastodon\Status\UserAttributes( + DBA::exists('item', ['thr-parent-id' => $uriId, 'uid' => $uid, 'origin' => true, 'gravity' => GRAVITY_ACTIVITY, 'vid' => Verb::getID(Activity::LIKE)]), + DBA::exists('item', ['thr-parent-id' => $uriId, 'uid' => $uid, 'origin' => true, 'gravity' => GRAVITY_ACTIVITY, 'vid' => Verb::getID(Activity::ANNOUNCE)]), + DBA::exists('thread', ['iid' => $item['id'], 'uid' => $item['uid'], 'ignored' => true]), + (bool)$item['starred'], + DBA::exists('user-item', ['iid' => $item['id'], 'uid' => $item['uid'], 'pinned' => true]) + ); + + $sensitive = DBA::exists('tag-view', ['uri-id' => $uriId, 'name' => 'nsfw']); + $application = new \Friendica\Object\Api\Mastodon\Application($item['app'] ?? ''); + $mentions = DI::mstdnMention()->createFromUriId($uriId); + $tags = DI::mstdnTag()->createFromUriId($uriId); + + $data = BBCode::getAttachmentData($item['body']); + $card = new \Friendica\Object\Api\Mastodon\Card($data); + + $attachments = DI::mstdnAttachment()->createFromUriId($uriId); + + if ($item['vid'] == Verb::getID(Activity::ANNOUNCE)) { + $reshare = $this->createFromUriId($item['thr-parent-id'], $uid)->toArray(); + $reshared_item = Item::selectFirst(['title', 'body'], ['uri-id' => $item['thr-parent-id'], 'uid' => $uid]); + $item['title'] = $reshared_item['title'] ?? $item['title']; + $item['body'] = $reshared_item['body'] ?? $item['body']; + } else { + $reshare = []; + } + + return new \Friendica\Object\Api\Mastodon\Status($item, $account, $counts, $userAttributes, $sensitive, $application, $mentions, $tags, $card, $attachments, $reshare); + } +} diff --git a/src/Factory/Api/Mastodon/Tag.php b/src/Factory/Api/Mastodon/Tag.php new file mode 100644 index 0000000000..9b81e6d691 --- /dev/null +++ b/src/Factory/Api/Mastodon/Tag.php @@ -0,0 +1,65 @@ +. + * + */ + +namespace Friendica\Factory\Api\Mastodon; + +use Friendica\App\BaseURL; +use Friendica\BaseFactory; +use Friendica\Model\Tag as TagModel; +use Friendica\Network\HTTPException; +use Friendica\Repository\ProfileField; +use Psr\Log\LoggerInterface; + +class Tag extends BaseFactory +{ + /** @var BaseURL */ + protected $baseUrl; + /** @var ProfileField */ + protected $profileField; + /** @var Field */ + protected $mstdnField; + + public function __construct(LoggerInterface $logger, BaseURL $baseURL, ProfileField $profileField, Field $mstdnField) + { + parent::__construct($logger); + + $this->baseUrl = $baseURL; + $this->profileField = $profileField; + $this->mstdnField = $mstdnField; + } + + /** + * @param int $uriId Uri-ID of the item + * @return array + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public function createFromUriId(int $uriId) + { + $hashtags = []; + $tags = TagModel::getByURIId($uriId, [TagModel::HASHTAG]); + foreach ($tags as $tag) { + $hashtag = new \Friendica\Object\Api\Mastodon\Tag($this->baseUrl, $tag); + $hashtags[] = $hashtag->toArray(); + } + return $hashtags; + } +} diff --git a/src/Factory/Api/Twitter/User.php b/src/Factory/Api/Twitter/User.php new file mode 100644 index 0000000000..6c3c3cc1fc --- /dev/null +++ b/src/Factory/Api/Twitter/User.php @@ -0,0 +1,55 @@ +. + * + */ + +namespace Friendica\Factory\Api\Twitter; + +use Friendica\BaseFactory; +use Friendica\Model\APContact; +use Friendica\Model\Contact; +use Friendica\Network\HTTPException; + +class User extends BaseFactory +{ + /** + * @param int $contactId + * @param int $uid Public contact (=0) or owner user id + * @param bool $skip_status + * @param bool $include_user_entities + * @return \Friendica\Object\Api\Twitter\User + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public function createFromContactId(int $contactId, $uid = 0, $skip_status = false, $include_user_entities = true) + { + $cdata = Contact::getPublicAndUserContacID($contactId, $uid); + if (!empty($cdata)) { + $publicContact = Contact::getById($cdata['public']); + $userContact = Contact::getById($cdata['user']); + } else { + $publicContact = Contact::getById($contactId); + $userContact = []; + } + + $apcontact = APContact::getByURL($publicContact['url'], false); + + return new \Friendica\Object\Api\Twitter\User($publicContact, $apcontact, $userContact, $skip_status, $include_user_entities); + } +} diff --git a/src/Factory/ConfigFactory.php b/src/Factory/ConfigFactory.php index 954f893959..3110490fd3 100644 --- a/src/Factory/ConfigFactory.php +++ b/src/Factory/ConfigFactory.php @@ -37,10 +37,10 @@ class ConfigFactory * * @throws Exception */ - public function createCache(ConfigFileLoader $loader) + public function createCache(ConfigFileLoader $loader, array $server = []) { $configCache = new Cache(); - $loader->setupCache($configCache); + $loader->setupCache($configCache, $server); return $configCache; } diff --git a/src/Factory/LockFactory.php b/src/Factory/LockFactory.php index afdf5213c6..4a45656de6 100644 --- a/src/Factory/LockFactory.php +++ b/src/Factory/LockFactory.php @@ -122,7 +122,7 @@ class LockFactory try { return new Lock\SemaphoreLock(); } catch (\Exception $exception) { - $this->logger->debug('Using Semaphore driver for locking failed.', ['exception' => $exception]); + $this->logger->warning('Using Semaphore driver for locking failed.', ['exception' => $exception]); } } @@ -135,7 +135,7 @@ class LockFactory return new Lock\CacheLock($cache); } } catch (\Exception $exception) { - $this->logger->debug('Using Cache driver for locking failed.', ['exception' => $exception]); + $this->logger->warning('Using Cache driver for locking failed.', ['exception' => $exception]); } } diff --git a/src/Factory/Notification/Introduction.php b/src/Factory/Notification/Introduction.php index ddfb56948a..8097da4712 100644 --- a/src/Factory/Notification/Introduction.php +++ b/src/Factory/Notification/Introduction.php @@ -99,17 +99,13 @@ class Introduction extends BaseFactory $formattedNotifications = []; try { - /// @todo Fetch contact details by "Contact::getDetailsByUrl" instead of queries to contact, fcontact and gcontact + /// @todo Fetch contact details by "Contact::getByUrl" instead of queries to contact and fcontact $stmtNotifications = $this->dba->p( "SELECT `intro`.`id` AS `intro_id`, `intro`.*, `contact`.*, `fcontact`.`name` AS `fname`, `fcontact`.`url` AS `furl`, `fcontact`.`addr` AS `faddr`, - `fcontact`.`photo` AS `fphoto`, `fcontact`.`request` AS `frequest`, - `gcontact`.`location` AS `glocation`, `gcontact`.`about` AS `gabout`, - `gcontact`.`keywords` AS `gkeywords`, - `gcontact`.`network` AS `gnetwork`, `gcontact`.`addr` AS `gaddr` + `fcontact`.`photo` AS `fphoto`, `fcontact`.`request` AS `frequest` FROM `intro` LEFT JOIN `contact` ON `contact`.`id` = `intro`.`contact-id` - LEFT JOIN `gcontact` ON `gcontact`.`nurl` = `contact`.`nurl` LEFT JOIN `fcontact` ON `intro`.`fid` = `fcontact`.`id` WHERE `intro`.`uid` = ? $sql_extra LIMIT ?, ?", @@ -119,10 +115,17 @@ class Introduction extends BaseFactory ); while ($notification = $this->dba->fetch($stmtNotifications)) { - // There are two kind of introduction. Contacts suggested by other contacts and normal connection requests. + if (empty($notification['url'])) { + continue; + } + + // There are two kind of introduction. Contacts suggested by other contacts and normal connection requests. // We have to distinguish between these two because they use different data. // Contact suggestions if ($notification['fid'] ?? '') { + if (empty($notification['furl'])) { + continue; + } $return_addr = bin2hex($this->nick . '@' . $this->baseUrl->getHostName() . (($this->baseUrl->getURLPath()) ? '/' . $this->baseUrl->getURLPath() : '')); @@ -136,7 +139,7 @@ class Introduction extends BaseFactory 'madeby_zrl' => Contact::magicLink($notification['url']), 'madeby_addr' => $notification['addr'], 'contact_id' => $notification['contact-id'], - 'photo' => (!empty($notification['fphoto']) ? Proxy::proxifyUrl($notification['fphoto'], false, Proxy::SIZE_SMALL) : "images/person-300.jpg"), + 'photo' => (!empty($notification['fphoto']) ? Proxy::proxifyUrl($notification['fphoto'], false, Proxy::SIZE_SMALL) : Contact::DEFAULT_AVATAR_PHOTO), 'name' => $notification['fname'], 'url' => $notification['furl'], 'zrl' => Contact::magicLink($notification['furl']), @@ -147,16 +150,10 @@ class Introduction extends BaseFactory // Normal connection requests } else { - $notification = $this->getMissingData($notification); - - if (empty($notification['url'])) { - continue; - } - // Don't show these data until you are connected. Diaspora is doing the same. - if ($notification['gnetwork'] === Protocol::DIASPORA) { - $notification['glocation'] = ""; - $notification['gabout'] = ""; + if ($notification['network'] === Protocol::DIASPORA) { + $notification['location'] = ""; + $notification['about'] = ""; } $formattedNotifications[] = new Notification\Introduction([ @@ -166,17 +163,17 @@ class Introduction extends BaseFactory 'uid' => $this->session->get('uid'), 'intro_id' => $notification['intro_id'], 'contact_id' => $notification['contact-id'], - 'photo' => (!empty($notification['photo']) ? Proxy::proxifyUrl($notification['photo'], false, Proxy::SIZE_SMALL) : "images/person-300.jpg"), + 'photo' => Contact::getPhoto($notification), 'name' => $notification['name'], - 'location' => BBCode::convert($notification['glocation'], false), - 'about' => BBCode::convert($notification['gabout'], false), - 'keywords' => $notification['gkeywords'], + 'location' => BBCode::convert($notification['location'], false), + 'about' => BBCode::convert($notification['about'], false), + 'keywords' => $notification['keywords'], 'hidden' => $notification['hidden'] == 1, 'post_newfriend' => (intval($this->pConfig->get(local_user(), 'system', 'post_newfriend')) ? '1' : 0), 'url' => $notification['url'], 'zrl' => Contact::magicLink($notification['url']), - 'addr' => $notification['gaddr'], - 'network' => $notification['gnetwork'], + 'addr' => $notification['addr'], + 'network' => $notification['network'], 'knowyou' => $notification['knowyou'], 'note' => $notification['note'], ]); @@ -188,41 +185,4 @@ class Introduction extends BaseFactory return $formattedNotifications; } - - /** - * Check for missing contact data and try to fetch the data from - * from other sources - * - * @param array $intro The input array with the intro data - * - * @return array The array with the intro data - * - * @throws InternalServerErrorException - */ - private function getMissingData(array $intro) - { - // If the network and the addr isn't available from the gcontact - // table entry, take the one of the contact table entry - if (empty($intro['gnetwork']) && !empty($intro['network'])) { - $intro['gnetwork'] = $intro['network']; - } - if (empty($intro['gaddr']) && !empty($intro['addr'])) { - $intro['gaddr'] = $intro['addr']; - } - - // If the network and addr is still not available - // get the missing data data from other sources - if (empty($intro['gnetwork']) || empty($intro['gaddr'])) { - $ret = Contact::getDetailsByURL($intro['url']); - - if (empty($intro['gnetwork']) && !empty($ret['network'])) { - $intro['gnetwork'] = $ret['network']; - } - if (empty($intro['gaddr']) && !empty($ret['addr'])) { - $intro['gaddr'] = $ret['addr']; - } - } - - return $intro; - } } diff --git a/src/Factory/Notification/Notification.php b/src/Factory/Notification/Notification.php index 990d274a01..ba36b0cef4 100644 --- a/src/Factory/Notification/Notification.php +++ b/src/Factory/Notification/Notification.php @@ -95,11 +95,11 @@ class Notification extends BaseFactory $item['author-avatar'] = $item['contact-avatar']; } - $item['label'] = (($item['id'] == $item['parent']) ? 'post' : 'comment'); + $item['label'] = (($item['gravity'] == GRAVITY_PARENT) ? 'post' : 'comment'); $item['link'] = $this->baseUrl->get(true) . '/display/' . $item['parent-guid']; - $item['image'] = Proxy::proxifyUrl($item['author-avatar'], false, Proxy::SIZE_MICRO); + $item['image'] = $item['author-avatar']; $item['url'] = $item['author-link']; - $item['text'] = (($item['id'] == $item['parent']) + $item['text'] = (($item['gravity'] == GRAVITY_PARENT) ? $this->l10n->t("%s created a new post", $item['author-name']) : $this->l10n->t("%s commented on %s's post", $item['author-name'], $item['parent-author-name'])); $item['when'] = DateTimeFormat::local($item['created'], 'r'); @@ -125,7 +125,7 @@ class Notification extends BaseFactory return new \Friendica\Object\Notification\Notification([ 'label' => 'like', 'link' => $this->baseUrl->get(true) . '/display/' . $item['parent-guid'], - 'image' => Proxy::proxifyUrl($item['author-avatar'], false, Proxy::SIZE_MICRO), + 'image' => $item['author-avatar'], 'url' => $item['author-link'], 'text' => $this->l10n->t("%s liked %s's post", $item['author-name'], $item['parent-author-name']), 'when' => $item['when'], @@ -136,7 +136,7 @@ class Notification extends BaseFactory return new \Friendica\Object\Notification\Notification([ 'label' => 'dislike', 'link' => $this->baseUrl->get(true) . '/display/' . $item['parent-guid'], - 'image' => Proxy::proxifyUrl($item['author-avatar'], false, Proxy::SIZE_MICRO), + 'image' => $item['author-avatar'], 'url' => $item['author-link'], 'text' => $this->l10n->t("%s disliked %s's post", $item['author-name'], $item['parent-author-name']), 'when' => $item['when'], @@ -147,7 +147,7 @@ class Notification extends BaseFactory return new \Friendica\Object\Notification\Notification([ 'label' => 'attend', 'link' => $this->baseUrl->get(true) . '/display/' . $item['parent-guid'], - 'image' => Proxy::proxifyUrl($item['author-avatar'], false, Proxy::SIZE_MICRO), + 'image' => $item['author-avatar'], 'url' => $item['author-link'], 'text' => $this->l10n->t("%s is attending %s's event", $item['author-name'], $item['parent-author-name']), 'when' => $item['when'], @@ -158,7 +158,7 @@ class Notification extends BaseFactory return new \Friendica\Object\Notification\Notification([ 'label' => 'attendno', 'link' => $this->baseUrl->get(true) . '/display/' . $item['parent-guid'], - 'image' => Proxy::proxifyUrl($item['author-avatar'], false, Proxy::SIZE_MICRO), + 'image' => $item['author-avatar'], 'url' => $item['author-link'], 'text' => $this->l10n->t("%s is not attending %s's event", $item['author-name'], $item['parent-author-name']), 'when' => $item['when'], @@ -169,7 +169,7 @@ class Notification extends BaseFactory return new \Friendica\Object\Notification\Notification([ 'label' => 'attendmaybe', 'link' => $this->baseUrl->get(true) . '/display/' . $item['parent-guid'], - 'image' => Proxy::proxifyUrl($item['author-avatar'], false, Proxy::SIZE_MICRO), + 'image' => $item['author-avatar'], 'url' => $item['author-link'], 'text' => $this->l10n->t("%s may attending %s's event", $item['author-name'], $item['parent-author-name']), 'when' => $item['when'], @@ -196,7 +196,7 @@ class Notification extends BaseFactory return new \Friendica\Object\Notification\Notification([ 'label' => 'friend', 'link' => $this->baseUrl->get(true) . '/display/' . $item['parent-guid'], - 'image' => Proxy::proxifyUrl($item['author-avatar'], false, Proxy::SIZE_MICRO), + 'image' => $item['author-avatar'], 'url' => $item['author-link'], 'text' => $this->l10n->t("%s is now friends with %s", $item['author-name'], $item['fname']), 'when' => $item['when'], @@ -272,7 +272,7 @@ class Notification extends BaseFactory } $fields = ['id', 'parent', 'verb', 'author-name', 'unseen', 'author-link', 'author-avatar', 'contact-avatar', - 'network', 'created', 'object', 'parent-author-name', 'parent-author-link', 'parent-guid']; + 'network', 'created', 'object', 'parent-author-name', 'parent-author-link', 'parent-guid', 'gravity']; $params = ['order' => ['received' => true], 'limit' => [$start, $limit]]; $formattedNotifications = []; @@ -313,7 +313,7 @@ class Notification extends BaseFactory } $fields = ['id', 'parent', 'verb', 'author-name', 'unseen', 'author-link', 'author-avatar', 'contact-avatar', - 'network', 'created', 'object', 'parent-author-name', 'parent-author-link', 'parent-guid']; + 'network', 'created', 'object', 'parent-author-name', 'parent-author-link', 'parent-guid', 'gravity']; $params = ['order' => ['received' => true], 'limit' => [$start, $limit]]; $formattedNotifications = []; @@ -350,7 +350,7 @@ class Notification extends BaseFactory } $fields = ['id', 'parent', 'verb', 'author-name', 'unseen', 'author-link', 'author-avatar', 'contact-avatar', - 'network', 'created', 'object', 'parent-author-name', 'parent-author-link', 'parent-guid']; + 'network', 'created', 'object', 'parent-author-name', 'parent-author-link', 'parent-guid', 'gravity']; $params = ['order' => ['received' => true], 'limit' => [$start, $limit]]; $formattedNotifications = []; diff --git a/src/Factory/SessionFactory.php b/src/Factory/SessionFactory.php index f513eef35f..116afe18a7 100644 --- a/src/Factory/SessionFactory.php +++ b/src/Factory/SessionFactory.php @@ -85,7 +85,7 @@ class SessionFactory $session = new Session\Native($baseURL, $handler); } } finally { - $profiler->saveTimestamp($stamp1, 'parser', System::callstack()); + $profiler->saveTimestamp($stamp1, 'parser'); return $session; } } diff --git a/src/Model/APContact.php b/src/Model/APContact.php index ec33864f4d..68e81072e6 100644 --- a/src/Model/APContact.php +++ b/src/Model/APContact.php @@ -22,69 +22,72 @@ namespace Friendica\Model; use Friendica\Content\Text\HTML; +use Friendica\Core\Cache\Duration; use Friendica\Core\Logger; +use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Network\Probe; +use Friendica\Protocol\ActivityNamespace; use Friendica\Protocol\ActivityPub; use Friendica\Util\Crypto; -use Friendica\Util\Network; -use Friendica\Util\JsonLD; use Friendica\Util\DateTimeFormat; -use Friendica\Util\Strings; +use Friendica\Util\HTTPSignature; +use Friendica\Util\JsonLD; +use Friendica\Util\Network; class APContact { /** - * Resolves the profile url from the address by using webfinger + * Fetch webfinger data * - * @param string $addr profile address (user@domain.tld) - * @param string $url profile URL. When set then we return "true" when this profile url can be found at the address - * @return string|boolean url - * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @param string $addr Address + * @return array webfinger data */ - private static function addrToUrl($addr, $url = null) + private static function fetchWebfingerData(string $addr) { $addr_parts = explode('@', $addr); if (count($addr_parts) != 2) { - return false; + return []; } - $xrd_timeout = DI::config()->get('system', 'xrd_timeout'); - - $webfinger = 'https://' . $addr_parts[1] . '/.well-known/webfinger?resource=acct:' . urlencode($addr); - - $curlResult = Network::curl($webfinger, false, ['timeout' => $xrd_timeout, 'accept_content' => 'application/jrd+json,application/json']); - if (!$curlResult->isSuccess() || empty($curlResult->getBody())) { - $webfinger = Strings::normaliseLink($webfinger); - - $curlResult = Network::curl($webfinger, false, ['timeout' => $xrd_timeout, 'accept_content' => 'application/jrd+json,application/json']); - - if (!$curlResult->isSuccess() || empty($curlResult->getBody())) { - return false; + $data = ['addr' => $addr]; + $template = 'https://' . $addr_parts[1] . '/.well-known/webfinger?resource=acct:' . urlencode($addr); + $webfinger = Probe::webfinger(str_replace('{uri}', urlencode($addr), $template), 'application/jrd+json'); + if (empty($webfinger['links'])) { + $template = 'http://' . $addr_parts[1] . '/.well-known/webfinger?resource=acct:' . urlencode($addr); + $webfinger = Probe::webfinger(str_replace('{uri}', urlencode($addr), $template), 'application/jrd+json'); + if (empty($webfinger['links'])) { + return []; } + $data['baseurl'] = 'http://' . $addr_parts[1]; + } else { + $data['baseurl'] = 'https://' . $addr_parts[1]; } - $data = json_decode($curlResult->getBody(), true); - - if (empty($data['links'])) { - return false; - } - - foreach ($data['links'] as $link) { - if (!empty($url) && !empty($link['href']) && ($link['href'] == $url)) { - return true; - } - - if (empty($link['href']) || empty($link['rel']) || empty($link['type'])) { + foreach ($webfinger['links'] as $link) { + if (empty($link['rel'])) { continue; } - if (empty($url) && ($link['rel'] == 'self') && ($link['type'] == 'application/activity+json')) { - return $link['href']; + if (!empty($link['template']) && ($link['rel'] == ActivityNamespace::OSTATUSSUB)) { + $data['subscribe'] = $link['template']; + } + + if (!empty($link['href']) && !empty($link['type']) && ($link['rel'] == 'self') && ($link['type'] == 'application/activity+json')) { + $data['url'] = $link['href']; + } + + if (!empty($link['href']) && !empty($link['type']) && ($link['rel'] == 'http://webfinger.net/rel/profile-page') && ($link['type'] == 'text/html')) { + $data['alias'] = $link['href']; } } - return false; + if (!empty($data['url']) && !empty($data['alias']) && ($data['url'] == $data['alias'])) { + unset($data['alias']); + } + + return $data; } /** @@ -133,25 +136,50 @@ class APContact } } - if (empty(parse_url($url, PHP_URL_SCHEME))) { - $url = self::addrToUrl($url); - if (empty($url)) { + $apcontact = []; + + $webfinger = empty(parse_url($url, PHP_URL_SCHEME)); + if ($webfinger) { + $apcontact = self::fetchWebfingerData($url); + if (empty($apcontact['url'])) { return $fetched_contact; } + $url = $apcontact['url']; } - $data = ActivityPub::fetchContent($url); - if (empty($data)) { + $curlResult = HTTPSignature::fetchRaw($url); + $failed = empty($curlResult) || empty($curlResult->getBody()) || + (!$curlResult->isSuccess() && ($curlResult->getReturnCode() != 410)); + + if (!$failed) { + $data = json_decode($curlResult->getBody(), true); + $failed = empty($data) || !is_array($data); + } + + if (!$failed && ($curlResult->getReturnCode() == 410)) { + $data = ['@context' => ActivityPub::CONTEXT, 'id' => $url, 'type' => 'Tombstone']; + } + + if ($failed) { + self::markForArchival($fetched_contact ?: []); return $fetched_contact; } $compacted = JsonLD::compact($data); - if (empty($compacted['@id'])) { return $fetched_contact; } - $apcontact = []; + // Detect multiple fast repeating request to the same address + // See https://github.com/friendica/friendica/issues/9303 + $cachekey = 'apcontact:getByURL:' . $url; + $result = DI::cache()->get($cachekey); + if (!is_null($result)) { + Logger::notice('Multiple requests for the address', ['url' => $url, 'update' => $update, 'callstack' => System::callstack(20), 'result' => $result]); + } else { + DI::cache()->set($cachekey, System::callstack(20), Duration::FIVE_MINUTES); + } + $apcontact['url'] = $compacted['@id']; $apcontact['uuid'] = JsonLD::fetchElement($compacted, 'diaspora:guid', '@value'); $apcontact['type'] = str_replace('as:', '', JsonLD::fetchElement($compacted, '@type')); @@ -182,14 +210,19 @@ class APContact $apcontact['photo'] = JsonLD::fetchElement($compacted['as:icon'], 'as:url', '@id'); } - $apcontact['alias'] = JsonLD::fetchElement($compacted, 'as:url', '@id'); - if (is_array($apcontact['alias'])) { - $apcontact['alias'] = JsonLD::fetchElement($compacted['as:url'], 'as:href', '@id'); + if (empty($apcontact['alias'])) { + $apcontact['alias'] = JsonLD::fetchElement($compacted, 'as:url', '@id'); + if (is_array($apcontact['alias'])) { + $apcontact['alias'] = JsonLD::fetchElement($compacted['as:url'], 'as:href', '@id'); + } } // Quit if none of the basic values are set - if (empty($apcontact['url']) || empty($apcontact['inbox']) || empty($apcontact['type'])) { + if (empty($apcontact['url']) || empty($apcontact['type']) || (($apcontact['type'] != 'Tombstone') && empty($apcontact['inbox']))) { return $fetched_contact; + } elseif ($apcontact['type'] == 'Tombstone') { + // The "inbox" field must have a content + $apcontact['inbox'] = ''; } // Quit if this doesn't seem to be an account at all @@ -201,10 +234,12 @@ class APContact unset($parts['scheme']); unset($parts['path']); - if (!empty($apcontact['nick'])) { - $apcontact['addr'] = $apcontact['nick'] . '@' . str_replace('//', '', Network::unparseURL($parts)); - } else { - $apcontact['addr'] = ''; + if (empty($apcontact['addr'])) { + if (!empty($apcontact['nick'])) { + $apcontact['addr'] = $apcontact['nick'] . '@' . str_replace('//', '', Network::unparseURL($parts)); + } else { + $apcontact['addr'] = ''; + } } $apcontact['pubkey'] = null; @@ -276,43 +311,109 @@ class APContact } } - $parts = parse_url($apcontact['url']); - unset($parts['path']); - $baseurl = Network::unparseURL($parts); + if (!$webfinger && !empty($apcontact['addr'])) { + $data = self::fetchWebfingerData($apcontact['addr']); + if (!empty($data)) { + $apcontact['baseurl'] = $data['baseurl']; - // Check if the address is resolvable or the profile url is identical with the base url of the system - if (self::addrToUrl($apcontact['addr'], $apcontact['url']) || Strings::compareLink($apcontact['url'], $baseurl)) { - $apcontact['baseurl'] = $baseurl; - } else { - $apcontact['addr'] = null; + if (empty($apcontact['alias']) && !empty($data['alias'])) { + $apcontact['alias'] = $data['alias']; + } + if (!empty($data['subscribe'])) { + $apcontact['subscribe'] = $data['subscribe']; + } + } else { + $apcontact['addr'] = null; + } } if (empty($apcontact['baseurl'])) { $apcontact['baseurl'] = null; } + if (empty($apcontact['subscribe'])) { + $apcontact['subscribe'] = null; + } + + if (!empty($apcontact['baseurl']) && empty($fetched_contact['gsid'])) { + $apcontact['gsid'] = GServer::getID($apcontact['baseurl']); + } elseif (!empty($fetched_contact['gsid'])) { + $apcontact['gsid'] = $fetched_contact['gsid']; + } else { + $apcontact['gsid'] = null; + } + if ($apcontact['url'] == $apcontact['alias']) { $apcontact['alias'] = null; } $apcontact['updated'] = DateTimeFormat::utcNow(); - DBA::update('apcontact', $apcontact, ['url' => $url], true); - // We delete the old entry when the URL is changed - if (($url != $apcontact['url']) && DBA::exists('apcontact', ['url' => $url]) && DBA::exists('apcontact', ['url' => $apcontact['url']])) { + if ($url != $apcontact['url']) { + Logger::info('Delete changed profile url', ['old' => $url, 'new' => $apcontact['url']]); DBA::delete('apcontact', ['url' => $url]); } - Logger::log('Updated profile for ' . $url, Logger::DEBUG); + if (DBA::exists('apcontact', ['url' => $apcontact['url']])) { + DBA::update('apcontact', $apcontact, ['url' => $apcontact['url']]); + } else { + DBA::replace('apcontact', $apcontact); + } + + Logger::info('Updated profile', ['url' => $url]); return $apcontact; } + /** + * Mark the given AP Contact as "to archive" + * + * @param array $apcontact + * @return void + */ + public static function markForArchival(array $apcontact) + { + if (!empty($apcontact['inbox'])) { + Logger::info('Set inbox status to failure', ['inbox' => $apcontact['inbox']]); + HTTPSignature::setInboxStatus($apcontact['inbox'], false); + } + + if (!empty($apcontact['sharedinbox'])) { + // Check if there are any available inboxes + $available = DBA::exists('apcontact', ["`sharedinbox` = ? AnD `inbox` IN (SELECT `url` FROM `inbox-status` WHERE `success` > `failure`)", + $apcontact['sharedinbox']]); + if (!$available) { + // If all known personal inboxes are failing then set their shared inbox to failure as well + Logger::info('Set shared inbox status to failure', ['sharedinbox' => $apcontact['sharedinbox']]); + HTTPSignature::setInboxStatus($apcontact['sharedinbox'], false, true); + } + } + } + + /** + * Unmark the given AP Contact as "to archive" + * + * @param array $apcontact + * @return void + */ + public static function unmarkForArchival(array $apcontact) + { + if (!empty($apcontact['inbox'])) { + Logger::info('Set inbox status to success', ['inbox' => $apcontact['inbox']]); + HTTPSignature::setInboxStatus($apcontact['inbox'], true); + } + if (!empty($apcontact['sharedinbox'])) { + Logger::info('Set shared inbox status to success', ['sharedinbox' => $apcontact['sharedinbox']]); + HTTPSignature::setInboxStatus($apcontact['sharedinbox'], true, true); + } + } + /** * Unarchive inboxes * - * @param string $url inbox url + * @param string $url inbox url + * @param boolean $shared Shared Inbox */ private static function unarchiveInbox($url, $shared) { @@ -320,15 +421,6 @@ class APContact return; } - $now = DateTimeFormat::utcNow(); - - $fields = ['archive' => false, 'success' => $now, 'shared' => $shared]; - - if (!DBA::exists('inbox-status', ['url' => $url])) { - $fields = array_merge($fields, ['url' => $url, 'created' => $now]); - DBA::insert('inbox-status', $fields); - } else { - DBA::update('inbox-status', $fields, ['url' => $url]); - } + HTTPSignature::setInboxStatus($url, true, $shared); } } diff --git a/src/Model/Attach.php b/src/Model/Attach.php index 8d91f90e95..b81c38762e 100644 --- a/src/Model/Attach.php +++ b/src/Model/Attach.php @@ -28,7 +28,7 @@ use Friendica\DI; use Friendica\Object\Image; use Friendica\Util\DateTimeFormat; use Friendica\Util\Mimetype; -use Friendica\Util\Security; +use Friendica\Security\Security; /** * Class to handle attach dabatase table @@ -159,7 +159,7 @@ class Attach */ public static function getData($item) { - $backendClass = DI::storageManager()->getByName($photo['backend-class'] ?? ''); + $backendClass = DI::storageManager()->getByName($item['backend-class'] ?? ''); if ($backendClass === null) { // legacy data storage in 'data' column $i = self::selectFirst(['data'], ['id' => $item['id']]); diff --git a/src/Model/Contact.php b/src/Model/Contact.php index 0d321189ff..b922265e24 100644 --- a/src/Model/Contact.php +++ b/src/Model/Contact.php @@ -23,12 +23,15 @@ namespace Friendica\Model; use Friendica\App\BaseURL; use Friendica\Content\Pager; +use Friendica\Content\Text\HTML; use Friendica\Core\Hook; use Friendica\Core\Logger; use Friendica\Core\Protocol; +use Friendica\Core\Renderer; use Friendica\Core\Session; use Friendica\Core\System; use Friendica\Core\Worker; +use Friendica\Database\Database; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Notify\Type; @@ -43,6 +46,7 @@ use Friendica\Protocol\Salmon; use Friendica\Util\DateTimeFormat; use Friendica\Util\Images; use Friendica\Util\Network; +use Friendica\Util\Proxy; use Friendica\Util\Strings; /** @@ -50,6 +54,10 @@ use Friendica\Util\Strings; */ class Contact { + const DEFAULT_AVATAR_PHOTO = '/images/person-300.jpg'; + const DEFAULT_AVATAR_THUMB = '/images/person-80.jpg'; + const DEFAULT_AVATAR_MICRO = '/images/person-48.jpg'; + /** * @deprecated since version 2019.03 * @see User::PAGE_FLAGS_NORMAL @@ -84,10 +92,12 @@ class Contact * @} */ + const LOCK_INSERT = 'contact-insert'; + /** * Account types * - * TYPE_UNKNOWN - the account has been imported from gcontact where this is the default type value + * TYPE_UNKNOWN - unknown type * * TYPE_PERSON - the account belongs to a person * Associated page types: PAGE_NORMAL, PAGE_SOAPBOX, PAGE_FREELOVE @@ -121,6 +131,7 @@ class Contact * Relationship types * @{ */ + const NOTHING = 0; const FOLLOWER = 1; const SHARING = 2; const FRIEND = 3; @@ -128,7 +139,12 @@ class Contact * @} */ - /** + const MIRROR_DEACTIVATED = 0; + const MIRROR_FORWARDED = 1; + const MIRROR_OWN_POST = 2; + const MIRROR_NATIVE_RESHARE = 3; + + /** * @param array $fields Array of selected fields, empty for all * @param array $condition Array of fields for condition * @param array $params Array of several parameters @@ -158,15 +174,23 @@ class Contact * Insert a row into the contact table * Important: You can't use DBA::lastInsertId() after this call since it will be set to 0. * - * @param array $fields field array - * @param bool $on_duplicate_update Do an update on a duplicate entry + * @param array $fields field array + * @param int $duplicate_mode Do an update on a duplicate entry * * @return boolean was the insert successful? * @throws \Exception */ - public static function insert(array $fields, bool $on_duplicate_update = false) + public static function insert(array $fields, int $duplicate_mode = Database::INSERT_DEFAULT) { - $ret = DBA::insert('contact', $fields, $on_duplicate_update); + if (!empty($fields['baseurl']) && empty($fields['gsid'])) { + $fields['gsid'] = GServer::getID($fields['baseurl'], true); + } + + if (empty($fields['created'])) { + $fields['created'] = DateTimeFormat::utcNow(); + } + + $ret = DBA::insert('contact', $fields, $duplicate_mode); $contact = DBA::selectFirst('contact', ['nurl', 'uid'], ['id' => DBA::lastInsertId()]); if (!DBA::isResult($contact)) { // Shouldn't happen @@ -190,6 +214,107 @@ class Contact return DBA::selectFirst('contact', $fields, ['id' => $id]); } + /** + * Fetches a contact by a given url + * + * @param string $url profile url + * @param boolean $update true = always update, false = never update, null = update when not found or outdated + * @param array $fields Field list + * @param integer $uid User ID of the contact + * @return array contact array + */ + public static function getByURL(string $url, $update = null, array $fields = [], int $uid = 0) + { + if ($update || is_null($update)) { + $cid = self::getIdForURL($url, $uid, $update); + if (empty($cid)) { + return []; + } + + $contact = self::getById($cid, $fields); + if (empty($contact)) { + return []; + } + return $contact; + } + + // Add internal fields + $removal = []; + if (!empty($fields)) { + foreach (['id', 'avatar', 'created', 'updated', 'last-update', 'success_update', 'failure_update', 'network'] as $internal) { + if (!in_array($internal, $fields)) { + $fields[] = $internal; + $removal[] = $internal; + } + } + } + + // We first try the nurl (http://server.tld/nick), most common case + $options = ['order' => ['id']]; + $contact = DBA::selectFirst('contact', $fields, ['nurl' => Strings::normaliseLink($url), 'uid' => $uid, 'deleted' => false], $options); + + // Then the addr (nick@server.tld) + if (!DBA::isResult($contact)) { + $contact = DBA::selectFirst('contact', $fields, ['addr' => str_replace('acct:', '', $url), 'uid' => $uid, 'deleted' => false], $options); + } + + // Then the alias (which could be anything) + if (!DBA::isResult($contact)) { + // The link could be provided as http although we stored it as https + $ssl_url = str_replace('http://', 'https://', $url); + $condition = ['`alias` IN (?, ?, ?) AND `uid` = ? AND NOT `deleted`', $url, Strings::normaliseLink($url), $ssl_url, $uid]; + $contact = DBA::selectFirst('contact', $fields, $condition, $options); + } + + if (!DBA::isResult($contact)) { + return []; + } + + // Update the contact in the background if needed + $updated = max($contact['success_update'], $contact['created'], $contact['updated'], $contact['last-update'], $contact['failure_update']); + if (($updated < DateTimeFormat::utc('now -7 days')) && in_array($contact['network'], Protocol::FEDERATED)) { + Worker::add(PRIORITY_LOW, "UpdateContact", $contact['id']); + } + + // Remove the internal fields + foreach ($removal as $internal) { + unset($contact[$internal]); + } + + return $contact; + } + + /** + * Fetches a contact for a given user by a given url. + * In difference to "getByURL" the function will fetch a public contact when no user contact had been found. + * + * @param string $url profile url + * @param integer $uid User ID of the contact + * @param boolean $update true = always update, false = never update, null = update when not found or outdated + * @param array $fields Field list + * @return array contact array + */ + public static function getByURLForUser(string $url, int $uid = 0, $update = false, array $fields = []) + { + if ($uid != 0) { + $contact = self::getByURL($url, $update, $fields, $uid); + if (!empty($contact)) { + if (!empty($contact['id'])) { + $contact['cid'] = $contact['id']; + $contact['zid'] = 0; + } + return $contact; + } + } + + $contact = self::getByURL($url, $update, $fields); + if (!empty($contact['id'])) { + $contact['cid'] = 0; + $contact['zid'] = $contact['id']; + } + return $contact; + } + /** * Tests if the given contact is a follower * @@ -202,7 +327,7 @@ class Contact */ public static function isFollower($cid, $uid) { - if (self::isBlockedByUser($cid, $uid)) { + if (Contact\User::isBlocked($cid, $uid)) { return false; } @@ -227,7 +352,7 @@ class Contact */ public static function isFollowerByURL($url, $uid) { - $cid = self::getIdForURL($url, $uid, true); + $cid = self::getIdForURL($url, $uid); if (empty($cid)) { return false; @@ -248,7 +373,7 @@ class Contact */ public static function isSharing($cid, $uid) { - if (self::isBlockedByUser($cid, $uid)) { + if (Contact\User::isBlocked($cid, $uid)) { return false; } @@ -273,7 +398,7 @@ class Contact */ public static function isSharingByURL($url, $uid) { - $cid = self::getIdForURL($url, $uid, true); + $cid = self::getIdForURL($url, $uid); if (empty($cid)) { return false; @@ -306,7 +431,7 @@ class Contact } // Update the existing contact - self::updateFromProbe($contact['id'], '', true); + self::updateFromProbe($contact['id']); // And fetch the result $contact = DBA::selectFirst('contact', ['baseurl'], ['id' => $contact['id']]); @@ -368,7 +493,7 @@ class Contact if (!DBA::isResult($self)) { return false; } - return self::getIdForURL($self['url'], 0, true); + return self::getIdForURL($self['url']); } /** @@ -398,14 +523,14 @@ class Contact } if ($contact['uid'] != 0) { - $pcid = Contact::getIdForURL($contact['url'], 0, true, ['url' => $contact['url']]); + $pcid = self::getIdForURL($contact['url'], 0, false, ['url' => $contact['url']]); if (empty($pcid)) { return []; } $ucid = $contact['id']; } else { $pcid = $contact['id']; - $ucid = Contact::getIdForURL($contact['url'], $uid, true); + $ucid = self::getIdForURL($contact['url'], $uid); } return ['public' => $pcid, 'user' => $ucid]; @@ -433,214 +558,6 @@ class Contact } } - /** - * Block contact id for user id - * - * @param int $cid Either public contact id or user's contact id - * @param int $uid User ID - * @param boolean $blocked Is the contact blocked or unblocked? - * @throws \Exception - */ - public static function setBlockedForUser($cid, $uid, $blocked) - { - $cdata = self::getPublicAndUserContacID($cid, $uid); - if (empty($cdata)) { - return; - } - - if ($cdata['user'] != 0) { - DBA::update('contact', ['blocked' => $blocked], ['id' => $cdata['user'], 'pending' => false]); - } - - DBA::update('user-contact', ['blocked' => $blocked], ['cid' => $cdata['public'], 'uid' => $uid], true); - } - - /** - * Returns "block" state for contact id and user id - * - * @param int $cid Either public contact id or user's contact id - * @param int $uid User ID - * - * @return boolean is the contact id blocked for the given user? - * @throws \Exception - */ - public static function isBlockedByUser($cid, $uid) - { - $cdata = self::getPublicAndUserContacID($cid, $uid); - if (empty($cdata)) { - return; - } - - $public_blocked = false; - - if (!empty($cdata['public'])) { - $public_contact = DBA::selectFirst('user-contact', ['blocked'], ['cid' => $cdata['public'], 'uid' => $uid]); - if (DBA::isResult($public_contact)) { - $public_blocked = $public_contact['blocked']; - } - } - - $user_blocked = $public_blocked; - - if (!empty($cdata['user'])) { - $user_contact = DBA::selectFirst('contact', ['blocked'], ['id' => $cdata['user'], 'pending' => false]); - if (DBA::isResult($user_contact)) { - $user_blocked = $user_contact['blocked']; - } - } - - if ($user_blocked != $public_blocked) { - DBA::update('user-contact', ['blocked' => $user_blocked], ['cid' => $cdata['public'], 'uid' => $uid], true); - } - - return $user_blocked; - } - - /** - * Ignore contact id for user id - * - * @param int $cid Either public contact id or user's contact id - * @param int $uid User ID - * @param boolean $ignored Is the contact ignored or unignored? - * @throws \Exception - */ - public static function setIgnoredForUser($cid, $uid, $ignored) - { - $cdata = self::getPublicAndUserContacID($cid, $uid); - if (empty($cdata)) { - return; - } - - if ($cdata['user'] != 0) { - DBA::update('contact', ['readonly' => $ignored], ['id' => $cdata['user'], 'pending' => false]); - } - - DBA::update('user-contact', ['ignored' => $ignored], ['cid' => $cdata['public'], 'uid' => $uid], true); - } - - /** - * Returns "ignore" state for contact id and user id - * - * @param int $cid Either public contact id or user's contact id - * @param int $uid User ID - * - * @return boolean is the contact id ignored for the given user? - * @throws \Exception - */ - public static function isIgnoredByUser($cid, $uid) - { - $cdata = self::getPublicAndUserContacID($cid, $uid); - if (empty($cdata)) { - return; - } - - $public_ignored = false; - - if (!empty($cdata['public'])) { - $public_contact = DBA::selectFirst('user-contact', ['ignored'], ['cid' => $cdata['public'], 'uid' => $uid]); - if (DBA::isResult($public_contact)) { - $public_ignored = $public_contact['ignored']; - } - } - - $user_ignored = $public_ignored; - - if (!empty($cdata['user'])) { - $user_contact = DBA::selectFirst('contact', ['readonly'], ['id' => $cdata['user'], 'pending' => false]); - if (DBA::isResult($user_contact)) { - $user_ignored = $user_contact['readonly']; - } - } - - if ($user_ignored != $public_ignored) { - DBA::update('user-contact', ['ignored' => $user_ignored], ['cid' => $cdata['public'], 'uid' => $uid], true); - } - - return $user_ignored; - } - - /** - * Set "collapsed" for contact id and user id - * - * @param int $cid Either public contact id or user's contact id - * @param int $uid User ID - * @param boolean $collapsed are the contact's posts collapsed or uncollapsed? - * @throws \Exception - */ - public static function setCollapsedForUser($cid, $uid, $collapsed) - { - $cdata = self::getPublicAndUserContacID($cid, $uid); - if (empty($cdata)) { - return; - } - - DBA::update('user-contact', ['collapsed' => $collapsed], ['cid' => $cdata['public'], 'uid' => $uid], true); - } - - /** - * Returns "collapsed" state for contact id and user id - * - * @param int $cid Either public contact id or user's contact id - * @param int $uid User ID - * - * @return boolean is the contact id blocked for the given user? - * @throws HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function isCollapsedByUser($cid, $uid) - { - $cdata = self::getPublicAndUserContacID($cid, $uid); - if (empty($cdata)) { - return; - } - - $collapsed = false; - - if (!empty($cdata['public'])) { - $public_contact = DBA::selectFirst('user-contact', ['collapsed'], ['cid' => $cdata['public'], 'uid' => $uid]); - if (DBA::isResult($public_contact)) { - $collapsed = $public_contact['collapsed']; - } - } - - return $collapsed; - } - - /** - * Returns a list of contacts belonging in a group - * - * @param int $gid - * @return array - * @throws \Exception - */ - public static function getByGroupId($gid) - { - $return = []; - - if (intval($gid)) { - $stmt = DBA::p('SELECT `group_member`.`contact-id`, `contact`.* - FROM `contact` - INNER JOIN `group_member` - ON `contact`.`id` = `group_member`.`contact-id` - WHERE `gid` = ? - AND `contact`.`uid` = ? - AND NOT `contact`.`self` - AND NOT `contact`.`deleted` - AND NOT `contact`.`blocked` - AND NOT `contact`.`pending` - ORDER BY `contact`.`name` ASC', - $gid, - local_user() - ); - - if (DBA::isResult($stmt)) { - $return = DBA::toArray($stmt); - } - } - - return $return; - } - /** * Creates the self-contact for the provided user id * @@ -655,7 +572,7 @@ class Contact return true; } - $user = DBA::selectFirst('user', ['uid', 'username', 'nickname'], ['uid' => $uid]); + $user = DBA::selectFirst('user', ['uid', 'username', 'nickname', 'pubkey', 'prvkey'], ['uid' => $uid]); if (!DBA::isResult($user)) { return false; } @@ -666,6 +583,8 @@ class Contact 'self' => 1, 'name' => $user['username'], 'nick' => $user['nickname'], + 'pubkey' => $user['pubkey'], + 'prvkey' => $user['prvkey'], 'photo' => DI::baseUrl() . '/photo/profile/' . $user['uid'] . '.jpg', 'thumb' => DI::baseUrl() . '/photo/avatar/' . $user['uid'] . '.jpg', 'micro' => DI::baseUrl() . '/photo/micro/' . $user['uid'] . '.jpg', @@ -697,7 +616,7 @@ class Contact */ public static function updateSelfFromUserID($uid, $update_avatar = false) { - $fields = ['id', 'name', 'nick', 'location', 'about', 'keywords', 'avatar', + $fields = ['id', 'name', 'nick', 'location', 'about', 'keywords', 'avatar', 'prvkey', 'pubkey', 'xmpp', 'contact-type', 'forum', 'prv', 'avatar-date', 'url', 'nurl', 'unsearchable', 'photo', 'thumb', 'micro', 'addr', 'request', 'notify', 'poll', 'confirm', 'poco']; $self = DBA::selectFirst('contact', $fields, ['uid' => $uid, 'self' => true]); @@ -705,7 +624,7 @@ class Contact return; } - $fields = ['nickname', 'page-flags', 'account-type']; + $fields = ['nickname', 'page-flags', 'account-type', 'prvkey', 'pubkey']; $user = DBA::selectFirst('user', $fields, ['uid' => $uid]); if (!DBA::isResult($user)) { return; @@ -723,8 +642,18 @@ class Contact $fields = ['name' => $profile['name'], 'nick' => $user['nickname'], 'avatar-date' => $self['avatar-date'], 'location' => Profile::formatLocation($profile), 'about' => $profile['about'], 'keywords' => $profile['pub_keywords'], - 'contact-type' => $user['account-type'], - 'xmpp' => $profile['xmpp']]; + 'contact-type' => $user['account-type'], 'prvkey' => $user['prvkey'], + 'pubkey' => $user['pubkey'], 'xmpp' => $profile['xmpp']]; + + // it seems as if ported accounts can have wrong values, so we make sure that now everything is fine. + $fields['url'] = DI::baseUrl() . '/profile/' . $user['nickname']; + $fields['nurl'] = Strings::normaliseLink($fields['url']); + $fields['addr'] = $user['nickname'] . '@' . substr(DI::baseUrl(), strpos(DI::baseUrl(), '://') + 3); + $fields['request'] = DI::baseUrl() . '/dfrn_request/' . $user['nickname']; + $fields['notify'] = DI::baseUrl() . '/dfrn_notify/' . $user['nickname']; + $fields['poll'] = DI::baseUrl() . '/dfrn_poll/'. $user['nickname']; + $fields['confirm'] = DI::baseUrl() . '/dfrn_confirm/' . $user['nickname']; + $fields['poco'] = DI::baseUrl() . '/poco/' . $user['nickname']; $avatar = Photo::selectFirst(['resource-id', 'type'], ['uid' => $uid, 'profile' => true]); if (DBA::isResult($avatar)) { @@ -749,9 +678,9 @@ class Contact $fields['micro'] = $prefix . '6' . $suffix; } else { // We hadn't found a photo entry, so we use the default avatar - $fields['photo'] = DI::baseUrl() . '/images/person-300.jpg'; - $fields['thumb'] = DI::baseUrl() . '/images/person-80.jpg'; - $fields['micro'] = DI::baseUrl() . '/images/person-48.jpg'; + $fields['photo'] = self::getDefaultAvatar($fields, Proxy::SIZE_SMALL); + $fields['thumb'] = self::getDefaultAvatar($fields, Proxy::SIZE_THUMB); + $fields['micro'] = self::getDefaultAvatar($fields, Proxy::SIZE_MICRO); } $fields['avatar'] = DI::baseUrl() . '/photo/profile/' .$uid . '.' . $file_suffix; @@ -759,16 +688,6 @@ class Contact $fields['prv'] = $user['page-flags'] == User::PAGE_FLAGS_PRVGROUP; $fields['unsearchable'] = !$profile['net-publish']; - // it seems as if ported accounts can have wrong values, so we make sure that now everything is fine. - $fields['url'] = DI::baseUrl() . '/profile/' . $user['nickname']; - $fields['nurl'] = Strings::normaliseLink($fields['url']); - $fields['addr'] = $user['nickname'] . '@' . substr(DI::baseUrl(), strpos(DI::baseUrl(), '://') + 3); - $fields['request'] = DI::baseUrl() . '/dfrn_request/' . $user['nickname']; - $fields['notify'] = DI::baseUrl() . '/dfrn_notify/' . $user['nickname']; - $fields['poll'] = DI::baseUrl() . '/dfrn_poll/'. $user['nickname']; - $fields['confirm'] = DI::baseUrl() . '/dfrn_confirm/' . $user['nickname']; - $fields['poco'] = DI::baseUrl() . '/poco/' . $user['nickname']; - $update = false; foreach ($fields as $field => $content) { @@ -805,7 +724,7 @@ class Contact { // We want just to make sure that we don't delete our "self" contact $contact = DBA::selectFirst('contact', ['uid'], ['id' => $id, 'self' => false]); - if (!DBA::isResult($contact) || !intval($contact['uid'])) { + if (!DBA::isResult($contact)) { return; } @@ -843,12 +762,12 @@ class Contact // create an unfollow slap $item = []; $item['verb'] = Activity::O_UNFOLLOW; + $item['gravity'] = GRAVITY_ACTIVITY; $item['follow'] = $contact["url"]; $item['body'] = ''; $item['title'] = ''; $item['guid'] = ''; $item['uri-id'] = 0; - $item['attach'] = ''; $slap = OStatus::salmon($item, $user); if (!empty($contact['notify'])) { @@ -887,10 +806,10 @@ class Contact return; } } elseif (!isset($contact['url'])) { - Logger::log('Empty contact: ' . json_encode($contact) . ' - ' . System::callstack(20), Logger::DEBUG); + Logger::info('Empty contact', ['contact' => $contact, 'callstack' => System::callstack(20)]); } - Logger::log('Contact '.$contact['id'].' is marked for archival', Logger::DEBUG); + Logger::info('Contact is marked for archival', ['id' => $contact['id'], 'term-date' => $contact['term-date']]); // Contact already archived or "self" contact? => nothing to do if ($contact['archive'] || $contact['self']) { @@ -918,7 +837,6 @@ class Contact */ DBA::update('contact', ['archive' => true], ['id' => $contact['id']]); DBA::update('contact', ['archive' => true], ['nurl' => Strings::normaliseLink($contact['url']), 'self' => false]); - GContact::updateFromPublicContactURL($contact['url']); } } } @@ -936,7 +854,7 @@ class Contact { // Always unarchive the relay contact entry if (!empty($contact['batch']) && !empty($contact['term-date']) && ($contact['term-date'] > DBA::NULL_DATETIME)) { - $fields = ['term-date' => DBA::NULL_DATETIME, 'archive' => false]; + $fields = ['failed' => false, 'term-date' => DBA::NULL_DATETIME, 'archive' => false]; $condition = ['uid' => 0, 'network' => Protocol::FEDERATED, 'batch' => $contact['batch'], 'contact-type' => self::TYPE_RELAY]; DBA::update('contact', $fields, $condition); } @@ -949,7 +867,7 @@ class Contact return; } - Logger::log('Contact '.$contact['id'].' is marked as vital again', Logger::DEBUG); + Logger::info('Contact is marked as vital again', ['id' => $contact['id'], 'term-date' => $contact['term-date']]); if (!isset($contact['url']) && !empty($contact['id'])) { $fields = ['id', 'url', 'batch']; @@ -960,220 +878,9 @@ class Contact } // It's a miracle. Our dead contact has inexplicably come back to life. - $fields = ['term-date' => DBA::NULL_DATETIME, 'archive' => false]; + $fields = ['failed' => false, 'term-date' => DBA::NULL_DATETIME, 'archive' => false]; DBA::update('contact', $fields, ['id' => $contact['id']]); DBA::update('contact', $fields, ['nurl' => Strings::normaliseLink($contact['url']), 'self' => false]); - GContact::updateFromPublicContactURL($contact['url']); - } - - /** - * Get contact data for a given profile link - * - * The function looks at several places (contact table and gcontact table) for the contact - * It caches its result for the same script execution to prevent duplicate calls - * - * @param string $url The profile link - * @param int $uid User id - * @param array $default If not data was found take this data as default value - * - * @return array Contact data - * @throws HTTPException\InternalServerErrorException - */ - public static function getDetailsByURL($url, $uid = -1, array $default = []) - { - static $cache = []; - - if ($url == '') { - return $default; - } - - if ($uid == -1) { - $uid = local_user(); - } - - if (isset($cache[$url][$uid])) { - return $cache[$url][$uid]; - } - - $ssl_url = str_replace('http://', 'https://', $url); - - $nurl = Strings::normaliseLink($url); - - // Fetch contact data from the contact table for the given user - $s = DBA::p("SELECT `id`, `id` AS `cid`, 0 AS `gid`, 0 AS `zid`, `uid`, `url`, `nurl`, `alias`, `network`, `name`, `nick`, `addr`, `location`, `about`, `xmpp`, - `keywords`, `photo`, `thumb`, `micro`, `forum`, `prv`, (`forum` | `prv`) AS `community`, `contact-type`, `bd` AS `birthday`, `self`, `rel`, `pending` - FROM `contact` WHERE `nurl` = ? AND `uid` = ?", $nurl, $uid); - $r = DBA::toArray($s); - - // Fetch contact data from the contact table for the given user, checking with the alias - if (!DBA::isResult($r)) { - $s = DBA::p("SELECT `id`, `id` AS `cid`, 0 AS `gid`, 0 AS `zid`, `uid`, `url`, `nurl`, `alias`, `network`, `name`, `nick`, `addr`, `location`, `about`, `xmpp`, - `keywords`, `photo`, `thumb`, `micro`, `forum`, `prv`, (`forum` | `prv`) AS `community`, `contact-type`, `bd` AS `birthday`, `self`, `rel`, `pending` - FROM `contact` WHERE `alias` IN (?, ?, ?) AND `uid` = ?", $nurl, $url, $ssl_url, $uid); - $r = DBA::toArray($s); - } - - // Fetch the data from the contact table with "uid=0" (which is filled automatically) - if (!DBA::isResult($r)) { - $s = DBA::p("SELECT `id`, 0 AS `cid`, `id` AS `zid`, 0 AS `gid`, `uid`, `url`, `nurl`, `alias`, `network`, `name`, `nick`, `addr`, `location`, `about`, `xmpp`, - `keywords`, `photo`, `thumb`, `micro`, `forum`, `prv`, (`forum` | `prv`) AS `community`, `contact-type`, `bd` AS `birthday`, 0 AS `self`, `rel`, `pending` - FROM `contact` WHERE `nurl` = ? AND `uid` = 0", $nurl); - $r = DBA::toArray($s); - } - - // Fetch the data from the contact table with "uid=0" (which is filled automatically) - checked with the alias - if (!DBA::isResult($r)) { - $s = DBA::p("SELECT `id`, 0 AS `cid`, `id` AS `zid`, 0 AS `gid`, `uid`, `url`, `nurl`, `alias`, `network`, `name`, `nick`, `addr`, `location`, `about`, `xmpp`, - `keywords`, `photo`, `thumb`, `micro`, `forum`, `prv`, (`forum` | `prv`) AS `community`, `contact-type`, `bd` AS `birthday`, 0 AS `self`, `rel`, `pending` - FROM `contact` WHERE `alias` IN (?, ?, ?) AND `uid` = 0", $nurl, $url, $ssl_url); - $r = DBA::toArray($s); - } - - // Fetch the data from the gcontact table - if (!DBA::isResult($r)) { - $s = DBA::p("SELECT 0 AS `id`, 0 AS `cid`, `id` AS `gid`, 0 AS `zid`, 0 AS `uid`, `url`, `nurl`, `alias`, `network`, `name`, `nick`, `addr`, `location`, `about`, '' AS `xmpp`, - `keywords`, `photo`, `photo` AS `thumb`, `photo` AS `micro`, 0 AS `forum`, 0 AS `prv`, `community`, `contact-type`, `birthday`, 0 AS `self`, 2 AS `rel`, 0 AS `pending` - FROM `gcontact` WHERE `nurl` = ?", $nurl); - $r = DBA::toArray($s); - } - - if (DBA::isResult($r)) { - $authoritativeResult = true; - // If there is more than one entry we filter out the connector networks - if (count($r) > 1) { - foreach ($r as $id => $result) { - if (!in_array($result["network"], Protocol::NATIVE_SUPPORT)) { - unset($r[$id]); - } - } - } - - $profile = array_shift($r); - - // "bd" always contains the upcoming birthday of a contact. - // "birthday" might contain the birthday including the year of birth. - if ($profile["birthday"] > DBA::NULL_DATE) { - $bd_timestamp = strtotime($profile["birthday"]); - $month = date("m", $bd_timestamp); - $day = date("d", $bd_timestamp); - - $current_timestamp = time(); - $current_year = date("Y", $current_timestamp); - $current_month = date("m", $current_timestamp); - $current_day = date("d", $current_timestamp); - - $profile["bd"] = $current_year . "-" . $month . "-" . $day; - $current = $current_year . "-" . $current_month . "-" . $current_day; - - if ($profile["bd"] < $current) { - $profile["bd"] = ( ++$current_year) . "-" . $month . "-" . $day; - } - } else { - $profile["bd"] = DBA::NULL_DATE; - } - } else { - $authoritativeResult = false; - $profile = $default; - } - - if (empty($profile["photo"]) && isset($default["photo"])) { - $profile["photo"] = $default["photo"]; - } - - if (empty($profile["name"]) && isset($default["name"])) { - $profile["name"] = $default["name"]; - } - - if (empty($profile["network"]) && isset($default["network"])) { - $profile["network"] = $default["network"]; - } - - if (empty($profile["thumb"]) && isset($profile["photo"])) { - $profile["thumb"] = $profile["photo"]; - } - - if (empty($profile["micro"]) && isset($profile["thumb"])) { - $profile["micro"] = $profile["thumb"]; - } - - if ((empty($profile["addr"]) || empty($profile["name"])) && !empty($profile["gid"]) - && in_array($profile["network"], Protocol::FEDERATED) - ) { - Worker::add(PRIORITY_LOW, "UpdateGContact", $url); - } - - // Show contact details of Diaspora contacts only if connected - if (empty($profile["cid"]) && ($profile["network"] ?? "") == Protocol::DIASPORA) { - $profile["location"] = ""; - $profile["about"] = ""; - $profile["birthday"] = DBA::NULL_DATE; - } - - // Only cache the result if it came from the DB since this method is used in widely different contexts - // @see display_fetch_author for an example of $default parameter diverging from the DB result - if ($authoritativeResult) { - $cache[$url][$uid] = $profile; - } - - return $profile; - } - - /** - * Get contact data for a given address - * - * The function looks at several places (contact table and gcontact table) for the contact - * - * @param string $addr The profile link - * @param int $uid User id - * - * @return array Contact data - * @throws HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function getDetailsByAddr($addr, $uid = -1) - { - if ($addr == '') { - return []; - } - - if ($uid == -1) { - $uid = local_user(); - } - - // Fetch contact data from the contact table for the given user - $r = q("SELECT `id`, `id` AS `cid`, 0 AS `gid`, 0 AS `zid`, `uid`, `url`, `nurl`, `alias`, `network`, `name`, `nick`, `addr`, `location`, `about`, `xmpp`, - `keywords`, `photo`, `thumb`, `micro`, `forum`, `prv`, (`forum` | `prv`) AS `community`, `contact-type`, `bd` AS `birthday`, `self`, `rel`, `pending`,`baseurl` - FROM `contact` WHERE `addr` = '%s' AND `uid` = %d AND NOT `deleted`", - DBA::escape($addr), - intval($uid) - ); - // Fetch the data from the contact table with "uid=0" (which is filled automatically) - if (!DBA::isResult($r)) { - $r = q("SELECT `id`, 0 AS `cid`, `id` AS `zid`, 0 AS `gid`, `uid`, `url`, `nurl`, `alias`, `network`, `name`, `nick`, `addr`, `location`, `about`, `xmpp`, - `keywords`, `photo`, `thumb`, `micro`, `forum`, `prv`, (`forum` | `prv`) AS `community`, `contact-type`, `bd` AS `birthday`, 0 AS `self`, `rel`, `pending`, `baseurl` - FROM `contact` WHERE `addr` = '%s' AND `uid` = 0 AND NOT `deleted`", - DBA::escape($addr) - ); - } - - // Fetch the data from the gcontact table - if (!DBA::isResult($r)) { - $r = q("SELECT 0 AS `id`, 0 AS `cid`, `id` AS `gid`, 0 AS `zid`, 0 AS `uid`, `url`, `nurl`, `alias`, `network`, `name`, `nick`, `addr`, `location`, `about`, '' AS `xmpp`, - `keywords`, `photo`, `photo` AS `thumb`, `photo` AS `micro`, `community` AS `forum`, 0 AS `prv`, `community`, `contact-type`, `birthday`, 0 AS `self`, 2 AS `rel`, 0 AS `pending`, `server_url` AS `baseurl` - FROM `gcontact` WHERE `addr` = '%s'", - DBA::escape($addr) - ); - } - - if (!DBA::isResult($r)) { - $data = Probe::uri($addr); - - $profile = self::getDetailsByURL($data['url'], $uid); - } else { - $profile = $r[0]; - } - - return $profile; } /** @@ -1250,9 +957,9 @@ class Contact $unfollow_link = ''; if (!$contact['self'] && in_array($contact['network'], Protocol::NATIVE_SUPPORT)) { if ($contact['uid'] && in_array($contact['rel'], [self::SHARING, self::FRIEND])) { - $unfollow_link = 'unfollow?url=' . urlencode($contact['url']); + $unfollow_link = 'unfollow?url=' . urlencode($contact['url']) . '&auto=1'; } elseif(!$contact['pending']) { - $follow_link = 'follow?url=' . urlencode($contact['url']); + $follow_link = 'follow?url=' . urlencode($contact['url']) . '&auto=1'; } } @@ -1309,117 +1016,6 @@ class Contact return $menucondensed; } - /** - * Returns ungrouped contact count or list for user - * - * Returns either the total number of ungrouped contacts for the given user - * id or a paginated list of ungrouped contacts. - * - * @param int $uid uid - * @return array - * @throws \Exception - */ - public static function getUngroupedList($uid) - { - return q("SELECT * - FROM `contact` - WHERE `uid` = %d - AND NOT `self` - AND NOT `deleted` - AND NOT `blocked` - AND NOT `pending` - AND `id` NOT IN ( - SELECT DISTINCT(`contact-id`) - FROM `group_member` - INNER JOIN `group` ON `group`.`id` = `group_member`.`gid` - WHERE `group`.`uid` = %d - )", intval($uid), intval($uid)); - } - - /** - * Have a look at all contact tables for a given profile url. - * This function works as a replacement for probing the contact. - * - * @param string $url Contact URL - * @param integer $cid Contact ID - * - * @return array Contact array in the "probe" structure - */ - private static function getProbeDataFromDatabase($url, $cid = null) - { - // The link could be provided as http although we stored it as https - $ssl_url = str_replace('http://', 'https://', $url); - - $fields = ['id', 'uid', 'url', 'addr', 'alias', 'notify', 'poll', 'name', 'nick', - 'photo', 'keywords', 'location', 'about', 'network', - 'priority', 'batch', 'request', 'confirm', 'poco']; - - if (!empty($cid)) { - $data = DBA::selectFirst('contact', $fields, ['id' => $cid]); - if (DBA::isResult($data)) { - return $data; - } - } - - $data = DBA::selectFirst('contact', $fields, ['nurl' => Strings::normaliseLink($url)]); - - if (!DBA::isResult($data)) { - $condition = ['alias' => [$url, Strings::normaliseLink($url), $ssl_url]]; - $data = DBA::selectFirst('contact', $fields, $condition); - } - - if (DBA::isResult($data)) { - // For security reasons we don't fetch key data from our users - $data["pubkey"] = ''; - return $data; - } - - $fields = ['url', 'addr', 'alias', 'notify', 'name', 'nick', - 'photo', 'keywords', 'location', 'about', 'network']; - $data = DBA::selectFirst('gcontact', $fields, ['nurl' => Strings::normaliseLink($url)]); - - if (!DBA::isResult($data)) { - $condition = ['alias' => [$url, Strings::normaliseLink($url), $ssl_url]]; - $data = DBA::selectFirst('contact', $fields, $condition); - } - - if (DBA::isResult($data)) { - $data["pubkey"] = ''; - $data["poll"] = ''; - $data["priority"] = 0; - $data["batch"] = ''; - $data["request"] = ''; - $data["confirm"] = ''; - $data["poco"] = ''; - return $data; - } - - $data = ActivityPub::probeProfile($url, false); - if (!empty($data)) { - return $data; - } - - $fields = ['url', 'addr', 'alias', 'notify', 'poll', 'name', 'nick', - 'photo', 'network', 'priority', 'batch', 'request', 'confirm']; - $data = DBA::selectFirst('fcontact', $fields, ['url' => $url]); - - if (!DBA::isResult($data)) { - $condition = ['alias' => [$url, Strings::normaliseLink($url), $ssl_url]]; - $data = DBA::selectFirst('contact', $fields, $condition); - } - - if (DBA::isResult($data)) { - $data["pubkey"] = ''; - $data["keywords"] = ''; - $data["location"] = ''; - $data["about"] = ''; - $data["poco"] = ''; - return $data; - } - - return []; - } - /** * Fetch the contact id for a given URL and user * @@ -1440,144 +1036,102 @@ class Contact * * @param string $url Contact URL * @param integer $uid The user id for the contact (0 = public contact) - * @param boolean $no_update Don't update the contact - * @param array $default Default value for creating the contact when every else fails - * @param boolean $in_loop Internally used variable to prevent an endless loop + * @param boolean $update true = always update, false = never update, null = update when not found + * @param array $default Default value for creating the contact when everything else fails * * @return integer Contact ID * @throws HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function getIdForURL($url, $uid = 0, $no_update = false, $default = [], $in_loop = false) + public static function getIdForURL($url, $uid = 0, $update = null, $default = []) { - Logger::log("Get contact data for url " . $url . " and user " . $uid . " - " . System::callstack(), Logger::DEBUG); - $contact_id = 0; if ($url == '') { + Logger::notice('Empty url, quitting', ['url' => $url, 'user' => $uid, 'default' => $default]); return 0; } - /// @todo Verify if we can't use Contact::getDetailsByUrl instead of the following - // We first try the nurl (http://server.tld/nick), most common case - $fields = ['id', 'avatar', 'updated', 'network']; - $options = ['order' => ['id']]; - $contact = DBA::selectFirst('contact', $fields, ['nurl' => Strings::normaliseLink($url), 'uid' => $uid, 'deleted' => false], $options); + $contact = self::getByURL($url, false, ['id', 'network'], $uid); - // Then the addr (nick@server.tld) - if (!DBA::isResult($contact)) { - $contact = DBA::selectFirst('contact', $fields, ['addr' => str_replace('acct:', '', $url), 'uid' => $uid, 'deleted' => false], $options); - } - - // Then the alias (which could be anything) - if (!DBA::isResult($contact)) { - // The link could be provided as http although we stored it as https - $ssl_url = str_replace('http://', 'https://', $url); - $condition = ['`alias` IN (?, ?, ?) AND `uid` = ? AND NOT `deleted`', $url, Strings::normaliseLink($url), $ssl_url, $uid]; - $contact = DBA::selectFirst('contact', $fields, $condition, $options); - } - - if (DBA::isResult($contact)) { + if (!empty($contact)) { $contact_id = $contact["id"]; - $update_contact = false; - // Update the contact every 7 days (Don't update mail or feed contacts) - if (in_array($contact['network'], Protocol::FEDERATED)) { - $update_contact = ($contact['updated'] < DateTimeFormat::utc('now -7 days')); - - // We force the update if the avatar is empty - if (empty($contact['avatar'])) { - $update_contact = true; - } - } elseif (empty($default) && in_array($contact['network'], [Protocol::MAIL, Protocol::PHANTOM]) && ($uid == 0)) { - // Update public mail accounts via their user's accounts - $fields = ['network', 'addr', 'name', 'nick', 'avatar', 'photo', 'thumb', 'micro']; - $mailcontact = DBA::selectFirst('contact', $fields, ["`addr` = ? AND `network` = ? AND `uid` != 0", $url, Protocol::MAIL]); - if (!DBA::isResult($mailcontact)) { - $mailcontact = DBA::selectFirst('contact', $fields, ["`nurl` = ? AND `network` = ? AND `uid` != 0", $url, Protocol::MAIL]); - } - - if (DBA::isResult($mailcontact)) { - DBA::update('contact', $mailcontact, ['id' => $contact_id]); - } - } - - // Update the contact in the background if needed but it is called by the frontend - if ($update_contact && $no_update && in_array($contact['network'], Protocol::NATIVE_SUPPORT)) { - Worker::add(PRIORITY_LOW, "UpdateContact", $contact_id, ($uid == 0 ? 'force' : '')); - } - - if (!$update_contact || $no_update) { + if (empty($update)) { + Logger::debug('Contact found', ['url' => $url, 'uid' => $uid, 'update' => $update, 'cid' => $contact_id]); return $contact_id; } } elseif ($uid != 0) { - // Non-existing user-specific contact, exiting + Logger::debug('Contact does not exist for the user', ['url' => $url, 'uid' => $uid, 'update' => $update]); + return 0; + } elseif (empty($default) && !is_null($update) && !$update) { + Logger::info('Contact not found, update not desired', ['url' => $url, 'uid' => $uid, 'update' => $update]); return 0; } - if ($no_update && empty($default)) { - // When we don't want to update, we look if we know this contact in any way - $data = self::getProbeDataFromDatabase($url, $contact_id); - $background_update = true; - } elseif ($no_update && !empty($default['network'])) { - // If there are default values, take these + $data = []; + + if (empty($default['network']) || $update) { + $data = Probe::uri($url, "", $uid); + + // Take the default values when probing failed + if (!empty($default) && !in_array($data["network"], array_merge(Protocol::NATIVE_SUPPORT, [Protocol::PUMPIO]))) { + $data = array_merge($data, $default); + } + } elseif (!empty($default['network'])) { $data = $default; - $background_update = false; - } else { - $data = []; - $background_update = false; } - if (empty($data)) { - $data = Probe::uri($url, "", $uid); - // Ensure that there is a gserver entry - if (!empty($data['baseurl']) && ($data['network'] != Protocol::PHANTOM)) { - GServer::check($data['baseurl']); + if (($uid == 0) && (empty($data['network']) || ($data['network'] == Protocol::PHANTOM))) { + // Fetch data for the public contact via the first found personal contact + /// @todo Check if this case can happen at all (possibly with mail accounts?) + $fields = ['name', 'nick', 'url', 'addr', 'alias', 'avatar', 'contact-type', + 'keywords', 'location', 'about', 'unsearchable', 'batch', 'notify', 'poll', + 'request', 'confirm', 'poco', 'subscribe', 'network', 'baseurl', 'gsid']; + + $personal_contact = DBA::selectFirst('contact', $fields, ["`addr` = ? AND `uid` != 0", $url]); + if (!DBA::isResult($personal_contact)) { + $personal_contact = DBA::selectFirst('contact', $fields, ["`nurl` = ? AND `uid` != 0", Strings::normaliseLink($url)]); + } + + if (DBA::isResult($personal_contact)) { + Logger::info('Take contact data from personal contact', ['url' => $url, 'update' => $update, 'contact' => $personal_contact, 'callstack' => System::callstack(20)]); + $data = $personal_contact; + $data['photo'] = $personal_contact['avatar']; + $data['account-type'] = $personal_contact['contact-type']; + $data['hide'] = $personal_contact['unsearchable']; + unset($data['avatar']); + unset($data['contact-type']); + unset($data['unsearchable']); } } - // Take the default values when probing failed - if (!empty($default) && !in_array($data["network"], array_merge(Protocol::NATIVE_SUPPORT, [Protocol::PUMPIO]))) { - $data = array_merge($data, $default); - } - - if (empty($data) || ($data['network'] == Protocol::PHANTOM)) { - Logger::info('No valid network found', ['url' => $url, 'data' => $data, 'callstack' => System::callstack(20)]); + if (empty($data['network']) || ($data['network'] == Protocol::PHANTOM)) { + Logger::notice('No valid network found', ['url' => $url, 'uid' => $uid, 'default' => $default, 'update' => $update, 'callstack' => System::callstack(20)]); return 0; } - if (!$contact_id && !empty($data['alias']) && ($data['alias'] != $url) && !$in_loop) { - $contact_id = self::getIdForURL($data["alias"], $uid, true, $default, true); + if (!$contact_id) { + $urls = [Strings::normaliseLink($url), Strings::normaliseLink($data['url'])]; + if (!empty($data['alias'])) { + $urls[] = Strings::normaliseLink($data['alias']); + } + $contact = self::selectFirst(['id'], ['nurl' => $urls, 'uid' => $uid]); + if (!empty($contact['id'])) { + $contact_id = $contact['id']; + Logger::info('Fetched id by url', ['cid' => $contact_id, 'uid' => $uid, 'url' => $url, 'data' => $data]); + } } if (!$contact_id) { + // We only insert the basic data. The rest will be done in "updateFromProbeArray" $fields = [ 'uid' => $uid, - 'created' => DateTimeFormat::utcNow(), 'url' => $data['url'], 'nurl' => Strings::normaliseLink($data['url']), - 'addr' => $data['addr'] ?? '', - 'alias' => $data['alias'] ?? '', - 'notify' => $data['notify'] ?? '', - 'poll' => $data['poll'] ?? '', - 'name' => $data['name'] ?? '', - 'nick' => $data['nick'] ?? '', - 'photo' => $data['photo'] ?? '', - 'keywords' => $data['keywords'] ?? '', - 'location' => $data['location'] ?? '', - 'about' => $data['about'] ?? '', 'network' => $data['network'], - 'pubkey' => $data['pubkey'] ?? '', + 'created' => DateTimeFormat::utcNow(), 'rel' => self::SHARING, - 'priority' => $data['priority'] ?? 0, - 'batch' => $data['batch'] ?? '', - 'request' => $data['request'] ?? '', - 'confirm' => $data['confirm'] ?? '', - 'poco' => $data['poco'] ?? '', - 'baseurl' => $data['baseurl'] ?? '', - 'name-date' => DateTimeFormat::utcNow(), - 'uri-date' => DateTimeFormat::utcNow(), - 'avatar-date' => DateTimeFormat::utcNow(), 'writable' => 1, 'blocked' => 0, 'readonly' => 0, @@ -1586,78 +1140,33 @@ class Contact $condition = ['nurl' => Strings::normaliseLink($data["url"]), 'uid' => $uid, 'deleted' => false]; // Before inserting we do check if the entry does exist now. - $contact = DBA::selectFirst('contact', ['id'], $condition, ['order' => ['id']]); - if (!DBA::isResult($contact)) { - Logger::info('Create new contact', $fields); - - self::insert($fields); - - // We intentionally aren't using lastInsertId here. There is a chance for duplicates. + if (DI::lock()->acquire(self::LOCK_INSERT, 0)) { $contact = DBA::selectFirst('contact', ['id'], $condition, ['order' => ['id']]); - if (!DBA::isResult($contact)) { - Logger::info('Contact creation failed', $fields); - // Shouldn't happen - return 0; - } - } else { - Logger::info('Contact had been created before', ['id' => $contact["id"], 'url' => $url, 'contact' => $fields]); - } - - $contact_id = $contact["id"]; - } - - if (!empty($data['photo']) && ($data['network'] != Protocol::FEED)) { - self::updateAvatar($data['photo'], $uid, $contact_id); - } - - if (in_array($data["network"], array_merge(Protocol::NATIVE_SUPPORT, [Protocol::PUMPIO]))) { - if ($background_update) { - // Update in the background when we fetched the data solely from the database - Worker::add(PRIORITY_MEDIUM, "UpdateContact", $contact_id, ($uid == 0 ? 'force' : '')); - } else { - // Else do a direct update - self::updateFromProbe($contact_id, '', false); - - // Update the gcontact entry - if ($uid == 0) { - GContact::updateFromPublicContactID($contact_id); - if (($data['network'] == Protocol::ACTIVITYPUB) && in_array(DI::config()->get('system', 'gcontact_discovery'), [GContact::DISCOVERY_DIRECT, GContact::DISCOVERY_RECURSIVE])) { - GContact::discoverFollowers($data['url']); + if (DBA::isResult($contact)) { + $contact_id = $contact['id']; + Logger::notice('Contact had been created (shortly) before', ['id' => $contact_id, 'url' => $url, 'uid' => $uid]); + } else { + DBA::insert('contact', $fields); + $contact_id = DBA::lastInsertId(); + if ($contact_id) { + Logger::info('Contact inserted', ['id' => $contact_id, 'url' => $url, 'uid' => $uid]); } } + DI::lock()->release(self::LOCK_INSERT); + } else { + Logger::warning('Contact lock had not been acquired'); + } + + if (!$contact_id) { + Logger::info('Contact was not inserted', ['url' => $url, 'uid' => $uid]); + return 0; } } else { - $fields = ['url', 'nurl', 'addr', 'alias', 'name', 'nick', 'keywords', 'location', 'about', 'avatar-date', 'baseurl']; - $contact = DBA::selectFirst('contact', $fields, ['id' => $contact_id]); - - // This condition should always be true - if (!DBA::isResult($contact)) { - return $contact_id; - } - - $updated = [ - 'url' => $data['url'], - 'nurl' => Strings::normaliseLink($data['url']), - 'updated' => DateTimeFormat::utcNow() - ]; - - $fields = ['addr', 'alias', 'name', 'nick', 'keywords', 'location', 'about', 'baseurl']; - - foreach ($fields as $field) { - $updated[$field] = ($data[$field] ?? '') ?: $contact[$field]; - } - - if (($updated['addr'] != $contact['addr']) || (!empty($data['alias']) && ($data['alias'] != $contact['alias']))) { - $updated['uri-date'] = DateTimeFormat::utcNow(); - } - - if (($data['name'] != $contact['name']) || ($data['nick'] != $contact['nick'])) { - $updated['name-date'] = DateTimeFormat::utcNow(); - } - - DBA::update('contact', $updated, ['id' => $contact_id], $contact); + Logger::info('Contact will be updated', ['url' => $url, 'uid' => $uid, 'update' => $update, 'cid' => $contact_id]); } + self::updateFromProbeArray($contact_id, $data); + return $contact_id; } @@ -1756,18 +1265,31 @@ class Contact * Returns posts from a given contact url * * @param string $contact_url Contact URL - * * @param bool $thread_mode - * @param int $update + * @param int $update Update mode + * @param int $parent Item parent ID for the update mode * @return string posts in HTML * @throws \Exception */ - public static function getPostsFromUrl($contact_url, $thread_mode = false, $update = 0) + public static function getPostsFromUrl($contact_url, $thread_mode = false, $update = 0, $parent = 0) + { + return self::getPostsFromId(self::getIdForURL($contact_url), $thread_mode, $update, $parent); + } + + /** + * Returns posts from a given contact id + * + * @param int $cid Contact ID + * @param bool $thread_mode + * @param int $update Update mode + * @param int $parent Item parent ID for the update mode + * @return string posts in HTML + * @throws \Exception + */ + public static function getPostsFromId($cid, $thread_mode = false, $update = 0, $parent = 0) { $a = DI::app(); - $cid = self::getIdForURL($contact_url); - $contact = DBA::selectFirst('contact', ['contact-type', 'network'], ['id' => $cid]); if (!DBA::isResult($contact)) { return ''; @@ -1782,13 +1304,22 @@ class Contact $contact_field = ((($contact["contact-type"] == self::TYPE_COMMUNITY) || ($contact['network'] == Protocol::MAIL)) ? 'owner-id' : 'author-id'); if ($thread_mode) { - $condition = ["`$contact_field` = ? AND `gravity` = ? AND " . $sql, - $cid, GRAVITY_PARENT, local_user()]; + $condition = ["((`$contact_field` = ? AND `gravity` = ?) OR (`author-id` = ? AND `gravity` = ? AND `vid` = ?)) AND " . $sql, + $cid, GRAVITY_PARENT, $cid, GRAVITY_ACTIVITY, Verb::getID(Activity::ANNOUNCE), local_user()]; } else { $condition = ["`$contact_field` = ? AND `gravity` IN (?, ?) AND " . $sql, $cid, GRAVITY_PARENT, GRAVITY_COMMENT, local_user()]; } + if (!empty($parent)) { + $condition = DBA::mergeConditions($condition, ['parent' => $parent]); + } else { + $last_received = isset($_GET['last_received']) ? DateTimeFormat::utc($_GET['last_received']) : ''; + if (!empty($last_received)) { + $condition = DBA::mergeConditions($condition, ["`received` < ?", $last_received]); + } + } + if (DI::mode()->isMobile()) { $itemsPerPage = DI::pConfig()->get(local_user(), 'system', 'itemspage_mobile_network', DI::config()->get('system', 'itemspage_network_mobile')); @@ -1799,25 +1330,38 @@ class Contact $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), $itemsPerPage); - $params = ['order' => ['received' => true], - 'limit' => [$pager->getStart(), $pager->getItemsPerPage()]]; + $params = ['order' => ['received' => true], 'limit' => [$pager->getStart(), $pager->getItemsPerPage()]]; - if ($thread_mode) { - $r = Item::selectThreadForUser(local_user(), ['uri'], $condition, $params); + if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll')) { + $tpl = Renderer::getMarkupTemplate('infinite_scroll_head.tpl'); + $o = Renderer::replaceMacros($tpl, ['$reload_uri' => DI::args()->getQueryString()]); + } else { + $o = ''; + } - $items = Item::inArray($r); + if ($thread_mode) { + $r = Item::selectForUser(local_user(), ['uri', 'gravity', 'parent-uri', 'thr-parent-id', 'author-id'], $condition, $params); + $items = []; + while ($item = DBA::fetch($r)) { + $items[] = $item; + } + DBA::close($r); - $o = conversation($a, $items, 'contacts', $update, false, 'commented', local_user()); + $o .= conversation($a, $items, 'contacts', $update, false, 'commented', local_user()); } else { $r = Item::selectForUser(local_user(), [], $condition, $params); $items = Item::inArray($r); - $o = conversation($a, $items, 'contact-posts', false); + $o .= conversation($a, $items, 'contact-posts', $update); } if (!$update) { - $o .= $pager->renderMinimal(count($items)); + if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll')) { + $o .= HTML::scrollLoader(); + } else { + $o .= $pager->renderMinimal(count($items)); + } } return $o; @@ -1836,7 +1380,6 @@ class Contact // There are several fields that indicate that the contact or user is a forum // "page-flags" is a field in the user table, // "forum" and "prv" are used in the contact table. They stand for User::PAGE_FLAGS_COMMUNITY and User::PAGE_FLAGS_PRVGROUP. - // "community" is used in the gcontact table and is true if the contact is User::PAGE_FLAGS_COMMUNITY or User::PAGE_FLAGS_PRVGROUP. if ((isset($contact['page-flags']) && (intval($contact['page-flags']) == User::PAGE_FLAGS_COMMUNITY)) || (isset($contact['page-flags']) && (intval($contact['page-flags']) == User::PAGE_FLAGS_PRVGROUP)) || (isset($contact['forum']) && intval($contact['forum'])) @@ -1906,55 +1449,327 @@ class Contact return $return; } + /** + * Ensure that cached avatar exist + * + * @param integer $cid + */ + public static function checkAvatarCache(int $cid) + { + $contact = DBA::selectFirst('contact', ['url', 'avatar', 'photo', 'thumb', 'micro'], ['id' => $cid, 'uid' => 0, 'self' => false]); + if (!DBA::isResult($contact)) { + return; + } + + if (empty($contact['avatar']) || (!empty($contact['photo']) && !empty($contact['thumb']) && !empty($contact['micro']))) { + return; + } + + Logger::info('Adding avatar cache', ['id' => $cid, 'contact' => $contact]); + + self::updateAvatar($cid, $contact['avatar'], true); + } + + /** + * Return the photo path for a given contact array in the given size + * + * @param array $contact contact array + * @param string $field Fieldname of the photo in the contact array + * @param string $size Size of the avatar picture + * @param string $avatar Avatar path that is displayed when no photo had been found + * @return string photo path + */ + private static function getAvatarPath(array $contact, string $field, string $size, string $avatar) + { + if (!empty($contact)) { + $contact = self::checkAvatarCacheByArray($contact); + if (!empty($contact[$field])) { + $avatar = $contact[$field]; + } + } + + if (empty($avatar)) { + $avatar = self::getDefaultAvatar([], $size); + } + + if (Proxy::isLocalImage($avatar)) { + return $avatar; + } else { + return Proxy::proxifyUrl($avatar, false, $size); + } + } + + /** + * Return the photo path for a given contact array + * + * @param array $contact Contact array + * @param string $avatar Avatar path that is displayed when no photo had been found + * @return string photo path + */ + public static function getPhoto(array $contact, string $avatar = '') + { + return self::getAvatarPath($contact, 'photo', Proxy::SIZE_SMALL, $avatar); + } + + /** + * Return the photo path (thumb size) for a given contact array + * + * @param array $contact Contact array + * @param string $avatar Avatar path that is displayed when no photo had been found + * @return string photo path + */ + public static function getThumb(array $contact, string $avatar = '') + { + return self::getAvatarPath($contact, 'thumb', Proxy::SIZE_THUMB, $avatar); + } + + /** + * Return the photo path (micro size) for a given contact array + * + * @param array $contact Contact array + * @param string $avatar Avatar path that is displayed when no photo had been found + * @return string photo path + */ + public static function getMicro(array $contact, string $avatar = '') + { + return self::getAvatarPath($contact, 'micro', Proxy::SIZE_MICRO, $avatar); + } + + /** + * Check the given contact array for avatar cache fields + * + * @param array $contact + * @return array contact array with avatar cache fields + */ + private static function checkAvatarCacheByArray(array $contact) + { + $update = false; + $contact_fields = []; + $fields = ['photo', 'thumb', 'micro']; + foreach ($fields as $field) { + if (isset($contact[$field])) { + $contact_fields[] = $field; + } + if (isset($contact[$field]) && empty($contact[$field])) { + $update = true; + } + } + + if (!$update) { + return $contact; + } + + if (!empty($contact['id']) && !empty($contact['avatar'])) { + self::updateAvatar($contact['id'], $contact['avatar'], true); + + $new_contact = self::getById($contact['id'], $contact_fields); + if (DBA::isResult($new_contact)) { + // We only update the cache fields + $contact = array_merge($contact, $new_contact); + } + } + + /// add the default avatars if the fields aren't filled + if (isset($contact['photo']) && empty($contact['photo'])) { + $contact['photo'] = self::getDefaultAvatar($contact, Proxy::SIZE_SMALL); + } + if (isset($contact['thumb']) && empty($contact['thumb'])) { + $contact['thumb'] = self::getDefaultAvatar($contact, Proxy::SIZE_THUMB); + } + if (isset($contact['micro']) && empty($contact['micro'])) { + $contact['micro'] = self::getDefaultAvatar($contact, Proxy::SIZE_MICRO); + } + + return $contact; + } + + /** + * Fetch the default avatar for the given contact and size + * + * @param array $contact contact array + * @param string $size Size of the avatar picture + * @return void + */ + public static function getDefaultAvatar(array $contact, string $size) + { + switch ($size) { + case Proxy::SIZE_MICRO: + $avatar['size'] = 48; + $default = self::DEFAULT_AVATAR_MICRO; + break; + + case Proxy::SIZE_THUMB: + $avatar['size'] = 80; + $default = self::DEFAULT_AVATAR_THUMB; + break; + + case Proxy::SIZE_SMALL: + default: + $avatar['size'] = 300; + $default = self::DEFAULT_AVATAR_PHOTO; + break; + } + + if (!DI::config()->get('system', 'remote_avatar_lookup')) { + return DI::baseUrl() . $default; + } + + if (!empty($contact['xmpp'])) { + $avatar['email'] = $contact['xmpp']; + } elseif (!empty($contact['addr'])) { + $avatar['email'] = $contact['addr']; + } elseif (!empty($contact['url'])) { + $avatar['email'] = $contact['url']; + } else { + return DI::baseUrl() . $default; + } + + $avatar['url'] = ''; + $avatar['success'] = false; + + Hook::callAll('avatar_lookup', $avatar); + + if ($avatar['success'] && !empty($avatar['url'])) { + return $avatar['url']; + } + + return DI::baseUrl() . $default; + } + /** * Updates the avatar links in a contact only if needed * - * @param string $avatar Link to avatar picture - * @param int $uid User id of contact owner - * @param int $cid Contact id - * @param bool $force force picture update + * @param int $cid Contact id + * @param string $avatar Link to avatar picture + * @param bool $force force picture update + * @param bool $create_cache Enforces the creation of cached avatar fields * * @return void * @throws HTTPException\InternalServerErrorException * @throws HTTPException\NotFoundException * @throws \ImagickException */ - public static function updateAvatar($avatar, $uid, $cid, $force = false) + public static function updateAvatar(int $cid, string $avatar, bool $force = false, bool $create_cache = false) { - $contact = DBA::selectFirst('contact', ['avatar', 'photo', 'thumb', 'micro', 'nurl'], ['id' => $cid, 'self' => false]); + $contact = DBA::selectFirst('contact', ['uid', 'avatar', 'photo', 'thumb', 'micro', 'xmpp', 'addr', 'nurl', 'url', 'network'], + ['id' => $cid, 'self' => false]); if (!DBA::isResult($contact)) { return; } - $data = [ - $contact['photo'] ?? '', - $contact['thumb'] ?? '', - $contact['micro'] ?? '', - ]; + $uid = $contact['uid']; - foreach ($data as $image_uri) { - $image_rid = Photo::ridFromURI($image_uri); - if ($image_rid && !Photo::exists(['resource-id' => $image_rid, 'uid' => $uid])) { - Logger::info('Regenerating avatar', ['contact uid' => $uid, 'cid' => $cid, 'missing photo' => $image_rid, 'avatar' => $contact['avatar']]); - $force = true; + // Only update the cached photo links of public contacts when they already are cached + if (($uid == 0) && !$force && empty($contact['thumb']) && empty($contact['micro']) && !$create_cache) { + if ($contact['avatar'] != $avatar) { + DBA::update('contact', ['avatar' => $avatar], ['id' => $cid]); + Logger::info('Only update the avatar', ['id' => $cid, 'avatar' => $avatar, 'contact' => $contact]); + } + return; + } + + // User contacts use are updated through the public contacts + if (($uid != 0) && !in_array($contact['network'], [Protocol::FEED, Protocol::MAIL])) { + $pcid = self::getIdForURL($contact['url'], false); + if (!empty($pcid)) { + Logger::debug('Update the private contact via the public contact', ['id' => $cid, 'uid' => $uid, 'public' => $pcid]); + self::updateAvatar($pcid, $avatar, $force, true); + return; } } - if (($contact["avatar"] != $avatar) || $force) { - $photos = Photo::importProfilePhoto($avatar, $uid, $cid, true); + $default_avatar = empty($avatar) || strpos($avatar, self::DEFAULT_AVATAR_PHOTO); - if ($photos) { - $fields = ['avatar' => $avatar, 'photo' => $photos[0], 'thumb' => $photos[1], 'micro' => $photos[2], 'avatar-date' => DateTimeFormat::utcNow()]; - DBA::update('contact', $fields, ['id' => $cid]); + if ($default_avatar) { + $avatar = self::getDefaultAvatar($contact, Proxy::SIZE_SMALL); + } - // Update the public contact (contact id = 0) - if ($uid != 0) { - $pcontact = DBA::selectFirst('contact', ['id'], ['nurl' => $contact['nurl'], 'uid' => 0]); - if (DBA::isResult($pcontact)) { - DBA::update('contact', $fields, ['id' => $pcontact['id']]); + if ($default_avatar && Proxy::isLocalImage($avatar)) { + $fields = ['avatar' => $avatar, 'avatar-date' => DateTimeFormat::utcNow(), + 'photo' => $avatar, + 'thumb' => self::getDefaultAvatar($contact, Proxy::SIZE_THUMB), + 'micro' => self::getDefaultAvatar($contact, Proxy::SIZE_MICRO)]; + Logger::debug('Use default avatar', ['id' => $cid, 'uid' => $uid]); + } + + // Use the data from the self account + if (empty($fields)) { + $local_uid = User::getIdForURL($contact['url']); + if (!empty($local_uid)) { + $fields = self::selectFirst(['avatar', 'avatar-date', 'photo', 'thumb', 'micro'], ['self' => true, 'uid' => $local_uid]); + Logger::debug('Use owner data', ['id' => $cid, 'uid' => $uid, 'owner-uid' => $local_uid]); + } + } + + if (empty($fields)) { + $update = ($contact['avatar'] != $avatar) || $force; + + if (!$update) { + $data = [ + $contact['photo'] ?? '', + $contact['thumb'] ?? '', + $contact['micro'] ?? '', + ]; + + foreach ($data as $image_uri) { + $image_rid = Photo::ridFromURI($image_uri); + if ($image_rid && !Photo::exists(['resource-id' => $image_rid, 'uid' => $uid])) { + Logger::debug('Regenerating avatar', ['contact uid' => $uid, 'cid' => $cid, 'missing photo' => $image_rid, 'avatar' => $contact['avatar']]); + $update = true; } } } + + if ($update) { + $photos = Photo::importProfilePhoto($avatar, $uid, $cid, true); + if ($photos) { + $fields = ['avatar' => $avatar, 'photo' => $photos[0], 'thumb' => $photos[1], 'micro' => $photos[2], 'avatar-date' => DateTimeFormat::utcNow()]; + $update = !empty($fields); + Logger::debug('Created new cached avatars', ['id' => $cid, 'uid' => $uid, 'owner-uid' => $local_uid]); + } else { + $update = false; + } + } + } else { + $update = ($fields['photo'] . $fields['thumb'] . $fields['micro'] != $contact['photo'] . $contact['thumb'] . $contact['micro']) || $force; + } + + if (!$update) { + return; + } + + $cids = []; + $uids = []; + if (($uid == 0) && !in_array($contact['network'], [Protocol::FEED, Protocol::MAIL])) { + // Collect all user contacts of the given public contact + $personal_contacts = DBA::select('contact', ['id', 'uid'], + ["`nurl` = ? AND `id` != ? AND NOT `self`", $contact['nurl'], $cid]); + while ($personal_contact = DBA::fetch($personal_contacts)) { + $cids[] = $personal_contact['id']; + $uids[] = $personal_contact['uid']; + } + DBA::close($personal_contacts); + + if (!empty($cids)) { + // Delete possibly existing cached user contact avatars + Photo::delete(['uid' => $uids, 'contact-id' => $cids, 'album' => Photo::CONTACT_PHOTOS]); + } + } + + $cids[] = $cid; + $uids[] = $uid; + Logger::info('Updating cached contact avatars', ['cid' => $cids, 'uid' => $uids, 'fields' => $fields]); + DBA::update('contact', $fields, ['id' => $cids]); + } + + public static function deleteContactByUrl(string $url) + { + // Update contact data for all users + $condition = ['self' => false, 'nurl' => Strings::normaliseLink($url)]; + $contacts = DBA::select('contact', ['id', 'uid'], $condition); + while ($contact = DBA::fetch($contacts)) { + Logger::info('Deleting contact', ['id' => $contact['id'], 'uid' => $contact['uid'], 'url' => $url]); + self::remove($contact['id']); } } @@ -1976,44 +1791,49 @@ class Contact } // Search for duplicated contacts and get rid of them - if (self::removeDuplicates(Strings::normaliseLink($url), $uid) || ($uid != 0)) { + if (self::removeDuplicates(Strings::normaliseLink($url), $uid)) { return; } - // Update the corresponding gcontact entry - GContact::updateFromPublicContactID($id); - - // Archive or unarchive the contact. We only need to do this for the public contact. - // The archive/unarchive function will update the personal contacts by themselves. + // Archive or unarchive the contact. $contact = DBA::selectFirst('contact', [], ['id' => $id]); if (!DBA::isResult($contact)) { Logger::info('Couldn\'t select contact for archival.', ['id' => $id]); return; } - if (!empty($fields['success_update'])) { - self::unmarkForArchival($contact); - } elseif (!empty($fields['failure_update'])) { - self::markForArchival($contact); + if (isset($fields['failed'])) { + if ($fields['failed']) { + self::markForArchival($contact); + } else { + self::unmarkForArchival($contact); + } } - $condition = ['self' => false, 'nurl' => Strings::normaliseLink($url), 'network' => Protocol::FEDERATED]; + if ($contact['uid'] != 0) { + return; + } - // These contacts are sharing with us, we don't poll them. - // This means that we don't set the update fields in "OnePoll.php". - $condition['rel'] = self::SHARING; + // Update contact data for all users + $condition = ['self' => false, 'nurl' => Strings::normaliseLink($url)]; + + $condition['network'] = [Protocol::DFRN, Protocol::DIASPORA, Protocol::ACTIVITYPUB]; DBA::update('contact', $fields, $condition); - unset($fields['last-update']); - unset($fields['success_update']); - unset($fields['failure_update']); + // We mustn't set the update fields for OStatus contacts since they are updated in OnePoll + $condition['network'] = Protocol::OSTATUS; + + // If the contact failed, propagate the update fields to all contacts + if (empty($fields['failed'])) { + unset($fields['last-update']); + unset($fields['success_update']); + unset($fields['failure_update']); + } if (empty($fields)) { return; } - // We are polling these contacts, so we mustn't set the update fields here. - $condition['rel'] = [self::FOLLOWER, self::FRIEND]; DBA::update('contact', $fields, $condition); } @@ -2065,12 +1885,29 @@ class Contact /** * @param integer $id contact id * @param string $network Optional network we are probing for - * @param boolean $force Optional forcing of network probing (otherwise we use the cached data) * @return boolean * @throws HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function updateFromProbe($id, $network = '', $force = false) + public static function updateFromProbe(int $id, string $network = '') + { + $contact = DBA::selectFirst('contact', ['uid', 'url'], ['id' => $id]); + if (!DBA::isResult($contact)) { + return false; + } + + $ret = Probe::uri($contact['url'], $network, $contact['uid']); + return self::updateFromProbeArray($id, $ret); + } + + /** + * @param integer $id contact id + * @param array $ret Probed data + * @return boolean + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + private static function updateFromProbeArray(int $id, array $ret) { /* Warning: Never ever fetch the public key via Probe::uri and write it into the contacts. @@ -2080,14 +1917,23 @@ class Contact // These fields aren't updated by this routine: // 'xmpp', 'sensitive' - $fields = ['uid', 'avatar', 'name', 'nick', 'location', 'keywords', 'about', + $fields = ['uid', 'avatar', 'name', 'nick', 'location', 'keywords', 'about', 'subscribe', 'manually-approve', 'unsearchable', 'url', 'addr', 'batch', 'notify', 'poll', 'request', 'confirm', 'poco', - 'network', 'alias', 'baseurl', 'forum', 'prv', 'contact-type', 'pubkey']; + 'network', 'alias', 'baseurl', 'gsid', 'forum', 'prv', 'contact-type', 'pubkey', 'last-item']; $contact = DBA::selectFirst('contact', $fields, ['id' => $id]); if (!DBA::isResult($contact)) { return false; } + if (!empty($ret['account-type']) && $ret['account-type'] == User::ACCOUNT_TYPE_DELETED) { + Logger::info('Deleted account', ['id' => $id, 'url' => $ret['url'], 'ret' => $ret]); + self::remove($id); + + // Delete all contacts with the same URL + self::deleteContactByUrl($ret['url']); + return true; + } + $uid = $contact['uid']; unset($contact['uid']); @@ -2097,24 +1943,20 @@ class Contact $contact['photo'] = $contact['avatar']; unset($contact['avatar']); - $ret = Probe::uri($contact['url'], $network, $uid, !$force); - $updated = DateTimeFormat::utcNow(); // We must not try to update relay contacts via probe. They are no real contacts. // We check after the probing to be able to correct falsely detected contact types. if (($contact['contact-type'] == self::TYPE_RELAY) && (!Strings::compareLink($ret['url'], $contact['url']) || in_array($ret['network'], [Protocol::FEED, Protocol::PHANTOM]))) { - self::updateContact($id, $uid, $contact['url'], ['last-update' => $updated, 'success_update' => $updated]); + self::updateContact($id, $uid, $contact['url'], ['failed' => false, 'last-update' => $updated, 'success_update' => $updated]); Logger::info('Not updating relais', ['id' => $id, 'url' => $contact['url']]); return true; } // If Probe::uri fails the network code will be different ("feed" or "unkn") if (in_array($ret['network'], [Protocol::FEED, Protocol::PHANTOM]) && ($ret['network'] != $contact['network'])) { - if ($force && ($uid == 0)) { - self::updateContact($id, $uid, $ret['url'], ['last-update' => $updated, 'failure_update' => $updated]); - } + self::updateContact($id, $uid, $ret['url'], ['failed' => true, 'last-update' => $updated, 'failure_update' => $updated]); return false; } @@ -2126,16 +1968,18 @@ class Contact $ret['forum'] = false; $ret['prv'] = false; $ret['contact-type'] = $ret['account-type']; - if ($ret['contact-type'] == User::ACCOUNT_TYPE_COMMUNITY) { - $apcontact = APContact::getByURL($ret['url'], false); - if (isset($apcontact['manually-approve'])) { - $ret['forum'] = (bool)!$apcontact['manually-approve']; - $ret['prv'] = (bool)!$ret['forum']; - } + if (($ret['contact-type'] == User::ACCOUNT_TYPE_COMMUNITY) && isset($ret['manually-approve'])) { + $ret['forum'] = (bool)!$ret['manually-approve']; + $ret['prv'] = (bool)!$ret['forum']; } } - $new_pubkey = $ret['pubkey']; + $new_pubkey = $ret['pubkey'] ?? ''; + + if ($uid == 0) { + $ret['last-item'] = Probe::getLastUpdate($ret); + Logger::info('Fetched last item', ['id' => $id, 'probed_url' => $ret['url'], 'last-item' => $ret['last-item'], 'callstack' => System::callstack(20)]); + } $update = false; @@ -2151,18 +1995,29 @@ class Contact } } + if (!empty($ret['last-item']) && ($contact['last-item'] < $ret['last-item'])) { + $update = true; + } else { + unset($ret['last-item']); + } + if (!empty($ret['photo']) && ($ret['network'] != Protocol::FEED)) { - self::updateAvatar($ret['photo'], $uid, $id, $update || $force); + self::updateAvatar($id, $ret['photo'], $update); } if (!$update) { - if ($force) { - self::updateContact($id, $uid, $ret['url'], ['last-update' => $updated, 'success_update' => $updated]); - } + self::updateContact($id, $uid, $ret['url'], ['failed' => false, 'last-update' => $updated, 'success_update' => $updated]); + if (Contact\Relation::isDiscoverable($ret['url'])) { + Worker::add(PRIORITY_LOW, 'ContactDiscovery', $ret['url']); + } + // Update the public contact if ($uid != 0) { - self::updateFromProbeByURL($ret['url']); + $contact = self::getByURL($ret['url'], false, ['id']); + if (!empty($contact['id'])) { + self::updateFromProbeArray($contact['id'], $ret); + } } return true; @@ -2170,13 +2025,14 @@ class Contact $ret['nurl'] = Strings::normaliseLink($ret['url']); $ret['updated'] = $updated; + $ret['failed'] = false; // Only fill the pubkey if it had been empty before. We have to prevent identity theft. if (empty($pubkey) && !empty($new_pubkey)) { $ret['pubkey'] = $new_pubkey; } - if (($ret['addr'] != $contact['addr']) || (!empty($ret['alias']) && ($ret['alias'] != $contact['alias']))) { + if ((!empty($ret['addr']) && ($ret['addr'] != $contact['addr'])) || (!empty($ret['alias']) && ($ret['alias'] != $contact['alias']))) { $ret['uri-date'] = DateTimeFormat::utcNow(); } @@ -2184,7 +2040,7 @@ class Contact $ret['name-date'] = $updated; } - if ($force && ($uid == 0)) { + if (($uid == 0) || in_array($ret['network'], [Protocol::DFRN, Protocol::DIASPORA, Protocol::ACTIVITYPUB])) { $ret['last-update'] = $updated; $ret['success_update'] = $updated; } @@ -2193,10 +2049,20 @@ class Contact self::updateContact($id, $uid, $ret['url'], $ret); + if (Contact\Relation::isDiscoverable($ret['url'])) { + Worker::add(PRIORITY_LOW, 'ContactDiscovery', $ret['url']); + } + return true; } - public static function updateFromProbeByURL($url, $force = false) + /** + * @param integer $url contact url + * @return integer Contact id + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public static function updateFromProbeByURL($url) { $id = self::getIdForURL($url); @@ -2204,7 +2070,7 @@ class Contact return $id; } - self::updateFromProbe($id, '', $force); + self::updateFromProbe($id); return $id; } @@ -2256,20 +2122,20 @@ class Contact * $return['message'] error text if success is false. * * Takes a $uid and a url/handle and adds a new contact - * @param int $uid - * @param string $url + * + * @param array $user The user the contact should be created for + * @param string $url The profile URL of the contact * @param bool $interactive * @param string $network * @return array * @throws HTTPException\InternalServerErrorException + * @throws HTTPException\NotFoundException * @throws \ImagickException */ - public static function createFromProbe($uid, $url, $interactive = false, $network = '') + public static function createFromProbe(array $user, $url, $interactive = false, $network = '') { $result = ['cid' => -1, 'success' => false, 'message' => '']; - $a = DI::app(); - // remove ajax junk, e.g. Twitter $url = str_replace('/#!/', '/', $url); @@ -2300,7 +2166,7 @@ class Contact if (!empty($arr['contact']['name'])) { $ret = $arr['contact']; } else { - $ret = Probe::uri($url, $network, $uid, false); + $ret = Probe::uri($url, $network, $user['uid']); } if (($network != '') && ($ret['network'] != $network)) { @@ -2312,21 +2178,21 @@ class Contact // the poll url is more reliable than the profile url, as we may have // indirect links or webfinger links - $condition = ['uid' => $uid, 'poll' => [$ret['poll'], Strings::normaliseLink($ret['poll'])], 'network' => $ret['network'], 'pending' => false]; + $condition = ['uid' => $user['uid'], 'poll' => [$ret['poll'], Strings::normaliseLink($ret['poll'])], 'network' => $ret['network'], 'pending' => false]; $contact = DBA::selectFirst('contact', ['id', 'rel'], $condition); if (!DBA::isResult($contact)) { - $condition = ['uid' => $uid, 'nurl' => Strings::normaliseLink($url), 'network' => $ret['network'], 'pending' => false]; + $condition = ['uid' => $user['uid'], 'nurl' => Strings::normaliseLink($ret['url']), 'network' => $ret['network'], 'pending' => false]; $contact = DBA::selectFirst('contact', ['id', 'rel'], $condition); } - $protocol = self::getProtocol($url, $ret['network']); + $protocol = self::getProtocol($ret['url'], $ret['network']); if (($protocol === Protocol::DFRN) && !DBA::isResult($contact)) { if ($interactive) { if (strlen(DI::baseUrl()->getUrlPath())) { - $myaddr = bin2hex(DI::baseUrl() . '/profile/' . $a->user['nickname']); + $myaddr = bin2hex(DI::baseUrl() . '/profile/' . $user['nickname']); } else { - $myaddr = bin2hex($a->user['nickname'] . '@' . DI::baseUrl()->getHostname()); + $myaddr = bin2hex($user['nickname'] . '@' . DI::baseUrl()->getHostname()); } DI::baseUrl()->redirect($ret['request'] . "&addr=$myaddr"); @@ -2345,7 +2211,7 @@ class Contact } // do we have enough information? - if (empty($ret['name']) || empty($ret['poll']) || (empty($ret['url']) && empty($ret['addr']))) { + if (empty($protocol) || ($protocol == Protocol::PHANTOM) || (empty($ret['url']) && empty($ret['addr']))) { $result['message'] .= DI::l10n()->t('The profile address specified does not provide adequate information.') . EOL; if (empty($ret['poll'])) { $result['message'] .= DI::l10n()->t('No compatible communication protocols or feeds were discovered.') . EOL; @@ -2356,7 +2222,7 @@ class Contact if (empty($ret['url'])) { $result['message'] .= DI::l10n()->t('No browser URL could be matched to this address.') . EOL; } - if (strpos($url, '@') !== false) { + if (strpos($ret['url'], '@') !== false) { $result['message'] .= DI::l10n()->t('Unable to match @-style Identity Address with a known protocol or email contact.') . EOL; $result['message'] .= DI::l10n()->t('Use mailto: in front of address to force email check.') . EOL; } @@ -2379,11 +2245,8 @@ class Contact $hidden = (($protocol === Protocol::MAIL) ? 1 : 0); $pending = false; - if ($protocol == Protocol::ACTIVITYPUB) { - $apcontact = APContact::getByURL($url, false); - if (isset($apcontact['manually-approve'])) { - $pending = (bool)$apcontact['manually-approve']; - } + if (($protocol == Protocol::ACTIVITYPUB) && isset($ret['manually-approve'])) { + $pending = (bool)$ret['manually-approve']; } if (in_array($protocol, [Protocol::MAIL, Protocol::DIASPORA, Protocol::ACTIVITYPUB])) { @@ -2401,7 +2264,7 @@ class Contact // create contact record self::insert([ - 'uid' => $uid, + 'uid' => $user['uid'], 'created' => DateTimeFormat::utcNow(), 'url' => $ret['url'], 'nurl' => Strings::normaliseLink($ret['url']), @@ -2415,6 +2278,7 @@ class Contact 'nick' => $ret['nick'], 'network' => $ret['network'], 'baseurl' => $ret['baseurl'], + 'gsid' => $ret['gsid'] ?? null, 'protocol' => $protocol, 'pubkey' => $ret['pubkey'], 'rel' => $new_relation, @@ -2428,7 +2292,7 @@ class Contact ]); } - $contact = DBA::selectFirst('contact', [], ['url' => $ret['url'], 'network' => $ret['network'], 'uid' => $uid]); + $contact = DBA::selectFirst('contact', [], ['url' => $ret['url'], 'network' => $ret['network'], 'uid' => $user['uid']]); if (!DBA::isResult($contact)) { $result['message'] .= DI::l10n()->t('Unable to retrieve contact information.') . EOL; return $result; @@ -2437,28 +2301,31 @@ class Contact $contact_id = $contact['id']; $result['cid'] = $contact_id; - Group::addMember(User::getDefaultGroup($uid, $contact["network"]), $contact_id); + Group::addMember(User::getDefaultGroup($user['uid'], $contact["network"]), $contact_id); // Update the avatar - self::updateAvatar($ret['photo'], $uid, $contact_id); + self::updateAvatar($contact_id, $ret['photo']); // pull feed and consume it, which should subscribe to the hub. + if ($contact['network'] == Protocol::OSTATUS) { + Worker::add(PRIORITY_HIGH, 'OnePoll', $contact_id, 'force'); + } else { + Worker::add(PRIORITY_HIGH, 'UpdateContact', $contact_id); + } - Worker::add(PRIORITY_HIGH, "OnePoll", $contact_id, "force"); - - $owner = User::getOwnerDataById($uid); + $owner = User::getOwnerDataById($user['uid']); if (DBA::isResult($owner)) { if (in_array($protocol, [Protocol::OSTATUS, Protocol::DFRN])) { // create a follow slap $item = []; $item['verb'] = Activity::FOLLOW; + $item['gravity'] = GRAVITY_ACTIVITY; $item['follow'] = $contact["url"]; $item['body'] = ''; $item['title'] = ''; $item['guid'] = ''; $item['uri-id'] = 0; - $item['attach'] = ''; $slap = OStatus::salmon($item, $owner); @@ -2466,7 +2333,7 @@ class Contact Salmon::slapper($owner, $contact['notify'], $slap); } } elseif ($protocol == Protocol::DIASPORA) { - $ret = Diaspora::sendShare($a->user, $contact); + $ret = Diaspora::sendShare($owner, $contact); Logger::log('share returns: ' . $ret); } elseif ($protocol == Protocol::ACTIVITYPUB) { $activity_id = ActivityPub\Transmitter::activityIDFromContact($contact_id); @@ -2475,7 +2342,7 @@ class Contact return false; } - $ret = ActivityPub\Transmitter::sendActivity('Follow', $contact['url'], $uid, $activity_id); + $ret = ActivityPub\Transmitter::sendActivity('Follow', $contact['url'], $user['uid'], $activity_id); Logger::log('Follow returns: ' . $ret); } } @@ -2575,7 +2442,7 @@ class Contact // Contact is blocked at user-level if (!empty($contact['id']) && !empty($importer['id']) && - self::isBlockedByUser($contact['id'], $importer['id'])) { + Contact\User::isBlocked($contact['id'], $importer['id'])) { return false; } @@ -2589,7 +2456,7 @@ class Contact } // Ensure to always have the correct network type, independent from the connection request method - self::updateFromProbe($contact['id'], '', true); + self::updateFromProbe($contact['id']); return true; } else { @@ -2607,7 +2474,6 @@ class Contact 'nurl' => Strings::normaliseLink($url), 'name' => $name, 'nick' => $nick, - 'photo' => $photo, 'network' => $network, 'rel' => self::FOLLOWER, 'blocked' => 0, @@ -2619,9 +2485,9 @@ class Contact $contact_id = DBA::lastInsertId(); // Ensure to always have the correct network type, independent from the connection request method - self::updateFromProbe($contact_id, '', true); + self::updateFromProbe($contact_id); - Contact::updateAvatar($photo, $importer["uid"], $contact_id, true); + self::updateAvatar($contact_id, $photo, true); $contact_record = DBA::selectFirst('contact', ['id', 'network', 'name', 'url', 'photo'], ['id' => $contact_id]); @@ -2644,29 +2510,23 @@ class Contact in_array($user['page-flags'], [User::PAGE_FLAGS_NORMAL])) { notification([ - 'type' => Type::INTRO, - 'notify_flags' => $user['notify-flags'], - 'language' => $user['language'], - 'to_name' => $user['username'], - 'to_email' => $user['email'], - 'uid' => $user['uid'], - 'link' => DI::baseUrl() . '/notifications/intros', - 'source_name' => ((strlen(stripslashes($contact_record['name']))) ? stripslashes($contact_record['name']) : DI::l10n()->t('[Name Withheld]')), - 'source_link' => $contact_record['url'], - 'source_photo' => $contact_record['photo'], - 'verb' => ($sharing ? Activity::FRIEND : Activity::FOLLOW), - 'otype' => 'intro' + 'type' => Type::INTRO, + 'otype' => Notify\ObjectType::INTRO, + 'verb' => ($sharing ? Activity::FRIEND : Activity::FOLLOW), + 'uid' => $user['uid'], + 'cid' => $contact_record['id'], + 'link' => DI::baseUrl() . '/notifications/intros', ]); } } elseif (DBA::isResult($user) && in_array($user['page-flags'], [User::PAGE_FLAGS_SOAPBOX, User::PAGE_FLAGS_FREELOVE, User::PAGE_FLAGS_COMMUNITY])) { if (($user['page-flags'] == User::PAGE_FLAGS_FREELOVE) && ($network != Protocol::DIASPORA)) { - self::createFromProbe($importer['uid'], $url, false, $network); + self::createFromProbe($importer, $url, false, $network); } $condition = ['uid' => $importer['uid'], 'url' => $url, 'pending' => true]; $fields = ['pending' => false]; if ($user['page-flags'] == User::PAGE_FLAGS_FREELOVE) { - $fields['rel'] = Contact::FRIEND; + $fields['rel'] = self::FRIEND; } DBA::update('contact', $fields, $condition); @@ -2683,7 +2543,7 @@ class Contact if (($contact['rel'] == self::FRIEND) || ($contact['rel'] == self::SHARING)) { DBA::update('contact', ['rel' => self::SHARING], ['id' => $contact['id']]); } else { - Contact::remove($contact['id']); + self::remove($contact['id']); } } @@ -2692,7 +2552,7 @@ class Contact if (($contact['rel'] == self::FRIEND) || ($contact['rel'] == self::FOLLOWER)) { DBA::update('contact', ['rel' => self::FOLLOWER], ['id' => $contact['id']]); } else { - Contact::remove($contact['id']); + self::remove($contact['id']); } } @@ -2713,8 +2573,8 @@ class Contact AND NOT `contact`.`blocked` AND NOT `contact`.`archive` AND NOT `contact`.`deleted`', - Contact::SHARING, - Contact::FRIEND + self::SHARING, + self::FRIEND ]; $contacts = DBA::select('contact', ['id', 'uid', 'name', 'url', 'bd'], $condition); @@ -2749,7 +2609,7 @@ class Contact return []; } - $contacts = Contact::selectToArray(['id'], [ + $contacts = self::selectToArray(['id'], [ 'id' => $contact_ids, 'blocked' => false, 'pending' => false, @@ -2777,15 +2637,15 @@ class Contact return $url ?: $contact_url; // Equivalent to: ($url != '') ? $url : $contact_url; } - $data = self::getProbeDataFromDatabase($contact_url); - if (empty($data)) { + $contact = self::getByURL($contact_url, false); + if (empty($contact)) { return $url ?: $contact_url; // Equivalent to: ($url != '') ? $url : $contact_url; } // Prevents endless loop in case only a non-public contact exists for the contact URL - unset($data['uid']); + unset($contact['uid']); - return self::magicLinkByContact($data, $url ?: $contact_url); + return self::magicLinkByContact($contact, $url ?: $contact_url); } /** @@ -2798,7 +2658,7 @@ class Contact * @throws HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function magicLinkbyId($cid, $url = '') + public static function magicLinkById($cid, $url = '') { $contact = DBA::selectFirst('contact', ['id', 'network', 'url', 'uid'], ['id' => $cid]); @@ -2819,7 +2679,7 @@ class Contact { $destination = $url ?: $contact['url']; // Equivalent to ($url != '') ? $url : $contact['url']; - if (!Session::isAuthenticated() || ($contact['network'] != Protocol::DFRN)) { + if (!Session::isAuthenticated()) { return $destination; } @@ -2828,6 +2688,14 @@ class Contact return $url; } + if (DI::pConfig()->get(local_user(), 'system', 'stay_local') && ($url == '')) { + return 'contact/' . $contact['id'] . '/conversations'; + } + + if ($contact['network'] != Protocol::DFRN) { + return $destination; + } + if (!empty($contact['uid'])) { return self::magicLink($contact['url'], $url); } @@ -2845,18 +2713,6 @@ class Contact return $redirect; } - /** - * Remove a contact from all groups - * - * @param integer $contact_id - * - * @return boolean Success - */ - public static function removeFromGroups($contact_id) - { - return DBA::delete('group_member', ['contact-id' => $contact_id]); - } - /** * Is the contact a forum? * @@ -2890,4 +2746,103 @@ class Contact return in_array($protocol, [Protocol::DFRN, Protocol::DIASPORA, Protocol::ACTIVITYPUB]) && !$self; } + + /** + * Search contact table by nick or name + * + * @param string $search Name or nick + * @param string $mode Search mode (e.g. "community") + * + * @return array with search results + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function searchByName($search, $mode = '') + { + if (empty($search)) { + return []; + } + + // check supported networks + if (DI::config()->get('system', 'diaspora_enabled')) { + $diaspora = Protocol::DIASPORA; + } else { + $diaspora = Protocol::DFRN; + } + + if (!DI::config()->get('system', 'ostatus_disabled')) { + $ostatus = Protocol::OSTATUS; + } else { + $ostatus = Protocol::DFRN; + } + + // check if we search only communities or every contact + if ($mode === 'community') { + $extra_sql = sprintf(' AND `contact-type` = %d', self::TYPE_COMMUNITY); + } else { + $extra_sql = ''; + } + + $search .= '%'; + + $results = DBA::p("SELECT * FROM `contact` + WHERE NOT `unsearchable` AND `network` IN (?, ?, ?, ?) AND + NOT `failed` AND `uid` = ? AND + (`addr` LIKE ? OR `name` LIKE ? OR `nick` LIKE ?) $extra_sql + ORDER BY `nurl` DESC LIMIT 1000", + Protocol::DFRN, Protocol::ACTIVITYPUB, $ostatus, $diaspora, 0, $search, $search, $search + ); + + $contacts = DBA::toArray($results); + return $contacts; + } + + /** + * Add public contacts from an array + * + * @param array $urls + * @return array result "count", "added" and "updated" + */ + public static function addByUrls(array $urls) + { + $added = 0; + $updated = 0; + $unchanged = 0; + $count = 0; + + foreach ($urls as $url) { + $contact = self::getByURL($url, false, ['id', 'updated']); + if (empty($contact['id'])) { + Worker::add(PRIORITY_LOW, 'AddContact', 0, $url); + ++$added; + } elseif ($contact['updated'] < DateTimeFormat::utc('now -7 days')) { + Worker::add(PRIORITY_LOW, 'UpdateContact', $contact['id']); + ++$updated; + } else { + ++$unchanged; + } + ++$count; + } + + return ['count' => $count, 'added' => $added, 'updated' => $updated, 'unchanged' => $unchanged]; + } + + /** + * Returns a random, global contact of the current node + * + * @return string The profile URL + * @throws Exception + */ + public static function getRandomUrl() + { + $r = DBA::selectFirst('contact', ['url'], [ + "`uid` = ? AND `network` = ? AND NOT `failed` AND `last-item` > ?", + 0, Protocol::DFRN, DateTimeFormat::utc('now - 1 month'), + ], ['order' => ['RAND()']]); + + if (DBA::isResult($r)) { + return $r['url']; + } + + return ''; + } } diff --git a/src/Model/Contact/Group.php b/src/Model/Contact/Group.php new file mode 100644 index 0000000000..5bf1dce507 --- /dev/null +++ b/src/Model/Contact/Group.php @@ -0,0 +1,105 @@ +. + * + */ + +namespace Friendica\Model\Contact; + +use Friendica\Database\DBA; + +/** + * This class provides information about contact groups based on the "group_member" table. + */ +class Group +{ + /** + * Returns a list of contacts belonging in a group + * + * @param int $gid + * @return array + * @throws \Exception + */ + public static function getById(int $gid) + { + $return = []; + + if (intval($gid)) { + $stmt = DBA::p('SELECT `group_member`.`contact-id`, `contact`.* + FROM `contact` + INNER JOIN `group_member` + ON `contact`.`id` = `group_member`.`contact-id` + WHERE `gid` = ? + AND `contact`.`uid` = ? + AND NOT `contact`.`self` + AND NOT `contact`.`deleted` + AND NOT `contact`.`blocked` + AND NOT `contact`.`pending` + ORDER BY `contact`.`name` ASC', + $gid, + local_user() + ); + + if (DBA::isResult($stmt)) { + $return = DBA::toArray($stmt); + } + } + + return $return; + } + + /** + * Returns ungrouped contact count or list for user + * + * Returns either the total number of ungrouped contacts for the given user + * id or a paginated list of ungrouped contacts. + * + * @param int $uid uid + * @return array + * @throws \Exception + */ + public static function listUngrouped(int $uid) + { + return q("SELECT * + FROM `contact` + WHERE `uid` = %d + AND NOT `self` + AND NOT `deleted` + AND NOT `blocked` + AND NOT `pending` + AND NOT `failed` + AND `id` NOT IN ( + SELECT DISTINCT(`contact-id`) + FROM `group_member` + INNER JOIN `group` ON `group`.`id` = `group_member`.`gid` + WHERE `group`.`uid` = %d + )", intval($uid), intval($uid)); + } + + /** + * Remove a contact from all groups + * + * @param integer $contact_id + * + * @return boolean Success + */ + public static function removeContact(int $contact_id) + { + return DBA::delete('group_member', ['contact-id' => $contact_id]); + } +} diff --git a/src/Model/Contact/Relation.php b/src/Model/Contact/Relation.php new file mode 100644 index 0000000000..3c95973238 --- /dev/null +++ b/src/Model/Contact/Relation.php @@ -0,0 +1,656 @@ +. + * + */ + +namespace Friendica\Model\Contact; + +use Exception; +use Friendica\Core\Logger; +use Friendica\Core\Protocol; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\APContact; +use Friendica\Model\Contact; +use Friendica\Model\Profile; +use Friendica\Model\User; +use Friendica\Protocol\ActivityPub; +use Friendica\Util\DateTimeFormat; +use Friendica\Util\Strings; + +/** + * This class provides relationship information based on the `contact-relation` table. + * This table is directional (cid = source, relation-cid = target), references public contacts (with uid=0) and records both + * follows and the last interaction (likes/comments) on public posts. + */ +class Relation +{ + /** + * No discovery of followers/followings + */ + const DISCOVERY_NONE = 0; + /** + * Discover followers/followings of local contacts + */ + const DISCOVERY_LOCAL = 1; + /** + * Discover followers/followings of local contacts and contacts that visibly interacted on the system + */ + const DISCOVERY_INTERACTOR = 2; + /** + * Discover followers/followings of all contacts + */ + const DISCOVERY_ALL = 3; + + public static function store(int $target, int $actor, string $interaction_date) + { + if ($actor == $target) { + return; + } + + DBA::update('contact-relation', ['last-interaction' => $interaction_date], ['cid' => $target, 'relation-cid' => $actor], true); + } + + /** + * Fetches the followers of a given profile and adds them + * + * @param string $url URL of a profile + * @return void + */ + public static function discoverByUrl(string $url) + { + $contact = Contact::getByURL($url); + if (empty($contact)) { + return; + } + + if (!self::isDiscoverable($url, $contact)) { + return; + } + + $uid = User::getIdForURL($url); + if (!empty($uid)) { + // Fetch the followers/followings locally + $followers = self::getContacts($uid, [Contact::FOLLOWER, Contact::FRIEND]); + $followings = self::getContacts($uid, [Contact::SHARING, Contact::FRIEND]); + } else { + $apcontact = APContact::getByURL($url, false); + + if (!empty($apcontact['followers']) && is_string($apcontact['followers'])) { + $followers = ActivityPub::fetchItems($apcontact['followers']); + } else { + $followers = []; + } + + if (!empty($apcontact['following']) && is_string($apcontact['following'])) { + $followings = ActivityPub::fetchItems($apcontact['following']); + } else { + $followings = []; + } + } + + if (empty($followers) && empty($followings)) { + DBA::update('contact', ['last-discovery' => DateTimeFormat::utcNow()], ['id' => $contact['id']]); + Logger::info('The contact does not offer discoverable data', ['id' => $contact['id'], 'url' => $url, 'network' => $contact['network']]); + return; + } + + $target = $contact['id']; + + if (!empty($followers)) { + // Clear the follower list, since it will be recreated in the next step + DBA::update('contact-relation', ['follows' => false], ['cid' => $target]); + } + + $contacts = []; + foreach (array_merge($followers, $followings) as $contact) { + if (is_string($contact)) { + $contacts[] = $contact; + } elseif (!empty($contact['url']) && is_string($contact['url'])) { + $contacts[] = $contact['url']; + } + } + $contacts = array_unique($contacts); + + $follower_counter = 0; + $following_counter = 0; + + Logger::info('Discover contacts', ['id' => $target, 'url' => $url, 'contacts' => count($contacts)]); + foreach ($contacts as $contact) { + $actor = Contact::getIdForURL($contact); + if (!empty($actor)) { + if (in_array($contact, $followers)) { + $fields = ['cid' => $target, 'relation-cid' => $actor]; + DBA::update('contact-relation', ['follows' => true, 'follow-updated' => DateTimeFormat::utcNow()], $fields, true); + $follower_counter++; + } + + if (in_array($contact, $followings)) { + $fields = ['cid' => $actor, 'relation-cid' => $target]; + DBA::update('contact-relation', ['follows' => true, 'follow-updated' => DateTimeFormat::utcNow()], $fields, true); + $following_counter++; + } + } + } + + if (!empty($followers)) { + // Delete all followers that aren't followers anymore (and aren't interacting) + DBA::delete('contact-relation', ['cid' => $target, 'follows' => false, 'last-interaction' => DBA::NULL_DATETIME]); + } + + DBA::update('contact', ['last-discovery' => DateTimeFormat::utcNow()], ['id' => $target]); + Logger::info('Contacts discovery finished', ['id' => $target, 'url' => $url, 'follower' => $follower_counter, 'following' => $following_counter]); + return; + } + + /** + * Fetch contact url list from the given local user + * + * @param integer $uid + * @param array $rel + * @return array contact list + */ + private static function getContacts(int $uid, array $rel) + { + $list = []; + $profile = Profile::getByUID($uid); + if (!empty($profile['hide-friends'])) { + return $list; + } + + $condition = ['rel' => $rel, 'uid' => $uid, 'self' => false, 'deleted' => false, + 'hidden' => false, 'archive' => false, 'pending' => false]; + $condition = DBA::mergeConditions($condition, ["`url` IN (SELECT `url` FROM `apcontact`)"]); + $contacts = DBA::select('contact', ['url'], $condition); + while ($contact = DBA::fetch($contacts)) { + $list[] = $contact['url']; + } + DBA::close($contacts); + + return $list; + } + + /** + * Tests if a given contact url is discoverable + * + * @param string $url Contact url + * @param array $contact Contact array + * @return boolean True if contact is discoverable + */ + public static function isDiscoverable(string $url, array $contact = []) + { + $contact_discovery = DI::config()->get('system', 'contact_discovery'); + + if ($contact_discovery == self::DISCOVERY_NONE) { + return false; + } + + if (empty($contact)) { + $contact = Contact::getByURL($url, false); + } + + if (empty($contact)) { + return false; + } + + if ($contact['last-discovery'] > DateTimeFormat::utc('now - 1 month')) { + Logger::info('No discovery - Last was less than a month ago.', ['id' => $contact['id'], 'url' => $url, 'discovery' => $contact['last-discovery']]); + return false; + } + + if ($contact_discovery != self::DISCOVERY_ALL) { + $local = DBA::exists('contact', ["`nurl` = ? AND `uid` != ?", Strings::normaliseLink($url), 0]); + if (($contact_discovery == self::DISCOVERY_LOCAL) && !$local) { + Logger::info('No discovery - This contact is not followed/following locally.', ['id' => $contact['id'], 'url' => $url]); + return false; + } + + if ($contact_discovery == self::DISCOVERY_INTERACTOR) { + $interactor = DBA::exists('contact-relation', ["`relation-cid` = ? AND `last-interaction` > ?", $contact['id'], DBA::NULL_DATETIME]); + if (!$local && !$interactor) { + Logger::info('No discovery - This contact is not interacting locally.', ['id' => $contact['id'], 'url' => $url]); + return false; + } + } + } elseif ($contact['created'] > DateTimeFormat::utc('now - 1 day')) { + // Newly created contacts are not discovered to avoid DDoS attacks + Logger::info('No discovery - Contact record is less than a day old.', ['id' => $contact['id'], 'url' => $url, 'discovery' => $contact['created']]); + return false; + } + + if (!in_array($contact['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::OSTATUS])) { + $apcontact = APContact::getByURL($url, false); + if (empty($apcontact)) { + Logger::info('No discovery - The contact does not seem to speak ActivityPub.', ['id' => $contact['id'], 'url' => $url, 'network' => $contact['network']]); + return false; + } + } + + return true; + } + + /** + * @param int $uid user + * @param int $start optional, default 0 + * @param int $limit optional, default 80 + * @return array + */ + static public function getSuggestions(int $uid, int $start = 0, int $limit = 80) + { + $cid = Contact::getPublicIdByUserId($uid); + $totallimit = $start + $limit; + $contacts = []; + + Logger::info('Collecting suggestions', ['uid' => $uid, 'cid' => $cid, 'start' => $start, 'limit' => $limit]); + + $diaspora = DI::config()->get('system', 'diaspora_enabled') ? Protocol::DIASPORA : Protocol::ACTIVITYPUB; + $ostatus = !DI::config()->get('system', 'ostatus_disabled') ? Protocol::OSTATUS : Protocol::ACTIVITYPUB; + + // The query returns contacts where contacts interacted with whom the given user follows. + // Contacts who already are in the user's contact table are ignored. + $results = DBA::select('contact', [], + ["`id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` IN + (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ?) + AND NOT `cid` IN (SELECT `id` FROM `contact` WHERE `uid` = ? AND `nurl` IN + (SELECT `nurl` FROM `contact` WHERE `uid` = ? AND `rel` IN (?, ?)))) + AND NOT `hidden` AND `network` IN (?, ?, ?, ?)", + $cid, 0, $uid, Contact::FRIEND, Contact::SHARING, + Protocol::ACTIVITYPUB, Protocol::DFRN, $diaspora, $ostatus], + ['order' => ['last-item' => true], 'limit' => $totallimit] + ); + + while ($contact = DBA::fetch($results)) { + $contacts[$contact['id']] = $contact; + } + DBA::close($results); + + Logger::info('Contacts of contacts who are followed by the given user', ['uid' => $uid, 'cid' => $cid, 'count' => count($contacts)]); + + if (count($contacts) >= $totallimit) { + return array_slice($contacts, $start, $limit); + } + + // The query returns contacts where contacts interacted with whom also interacted with the given user. + // Contacts who already are in the user's contact table are ignored. + $results = DBA::select('contact', [], + ["`id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` IN + (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ?) + AND NOT `cid` IN (SELECT `id` FROM `contact` WHERE `uid` = ? AND `nurl` IN + (SELECT `nurl` FROM `contact` WHERE `uid` = ? AND `rel` IN (?, ?)))) + AND NOT `hidden` AND `network` IN (?, ?, ?, ?)", + $cid, 0, $uid, Contact::FRIEND, Contact::SHARING, + Protocol::ACTIVITYPUB, Protocol::DFRN, $diaspora, $ostatus], + ['order' => ['last-item' => true], 'limit' => $totallimit] + ); + + while ($contact = DBA::fetch($results)) { + $contacts[$contact['id']] = $contact; + } + DBA::close($results); + + Logger::info('Contacts of contacts who are following the given user', ['uid' => $uid, 'cid' => $cid, 'count' => count($contacts)]); + + if (count($contacts) >= $totallimit) { + return array_slice($contacts, $start, $limit); + } + + // The query returns contacts that follow the given user but aren't followed by that user. + $results = DBA::select('contact', [], + ["`nurl` IN (SELECT `nurl` FROM `contact` WHERE `uid` = ? AND `rel` = ?) + AND NOT `hidden` AND `uid` = ? AND `network` IN (?, ?, ?, ?)", + $uid, Contact::FOLLOWER, 0, + Protocol::ACTIVITYPUB, Protocol::DFRN, $diaspora, $ostatus], + ['order' => ['last-item' => true], 'limit' => $totallimit] + ); + + while ($contact = DBA::fetch($results)) { + $contacts[$contact['id']] = $contact; + } + DBA::close($results); + + Logger::info('Followers that are not followed by the given user', ['uid' => $uid, 'cid' => $cid, 'count' => count($contacts)]); + + if (count($contacts) >= $totallimit) { + return array_slice($contacts, $start, $limit); + } + + // The query returns any contact that isn't followed by that user. + $results = DBA::select('contact', [], + ["NOT `nurl` IN (SELECT `nurl` FROM `contact` WHERE `uid` = ? AND `rel` IN (?, ?)) + AND NOT `hidden` AND `uid` = ? AND `network` IN (?, ?, ?, ?)", + $uid, Contact::FRIEND, Contact::SHARING, 0, + Protocol::ACTIVITYPUB, Protocol::DFRN, $diaspora, $ostatus], + ['order' => ['last-item' => true], 'limit' => $totallimit] + ); + + while ($contact = DBA::fetch($results)) { + $contacts[$contact['id']] = $contact; + } + DBA::close($results); + + Logger::info('Any contact', ['uid' => $uid, 'cid' => $cid, 'count' => count($contacts)]); + + return array_slice($contacts, $start, $limit); + } + + /** + * Counts all the known follows of the provided public contact + * + * @param int $cid Public contact id + * @param array $condition Additional condition on the contact table + * @return int + * @throws Exception + */ + public static function countFollows(int $cid, array $condition = []) + { + $condition = DBA::mergeConditions($condition, + ['`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`)', + $cid] + ); + + return DI::dba()->count('contact', $condition); + } + + /** + * Returns a paginated list of contacts that are followed the provided public contact. + * + * @param int $cid Public contact id + * @param array $condition Additional condition on the contact table + * @param int $count + * @param int $offset + * @param bool $shuffle + * @return array + * @throws Exception + */ + public static function listFollows(int $cid, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false) + { + $condition = DBA::mergeConditions($condition, + ['`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`)', + $cid] + ); + + return DI::dba()->selectToArray('contact', [], $condition, + ['limit' => [$offset, $count], 'order' => [$shuffle ? 'RAND()' : 'name']] + ); + } + + /** + * Counts all the known followers of the provided public contact + * + * @param int $cid Public contact id + * @param array $condition Additional condition on the contact table + * @return int + * @throws Exception + */ + public static function countFollowers(int $cid, array $condition = []) + { + $condition = DBA::mergeConditions($condition, + ['`id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`)', + $cid] + ); + + return DI::dba()->count('contact', $condition); + } + + /** + * Returns a paginated list of contacts that follow the provided public contact. + * + * @param int $cid Public contact id + * @param array $condition Additional condition on the contact table + * @param int $count + * @param int $offset + * @param bool $shuffle + * @return array + * @throws Exception + */ + public static function listFollowers(int $cid, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false) + { + $condition = DBA::mergeConditions($condition, + ['`id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`)', $cid] + ); + + return DI::dba()->selectToArray('contact', [], $condition, + ['limit' => [$offset, $count], 'order' => [$shuffle ? 'RAND()' : 'name']] + ); + } + + /** + * Counts the number of contacts that are known mutuals with the provided public contact. + * + * @param int $cid Public contact id + * @param array $condition Additional condition array on the contact table + * @return int + * @throws Exception + */ + public static function countMutuals(int $cid, array $condition = []) + { + $condition = DBA::mergeConditions($condition, + ['`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`) + AND `id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`)', + $cid, $cid] + ); + + return DI::dba()->count('contact', $condition); + } + + /** + * Returns a paginated list of contacts that are known mutuals with the provided public contact. + * + * @param int $cid Public contact id + * @param array $condition Additional condition on the contact table + * @param int $count + * @param int $offset + * @param bool $shuffle + * @return array + * @throws Exception + */ + public static function listMutuals(int $cid, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false) + { + $condition = DBA::mergeConditions($condition, + ['`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`) + AND `id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`)', + $cid, $cid] + ); + + return DI::dba()->selectToArray('contact', [], $condition, + ['limit' => [$offset, $count], 'order' => [$shuffle ? 'RAND()' : 'name']] + ); + } + + + /** + * Counts the number of contacts with any relationship with the provided public contact. + * + * @param int $cid Public contact id + * @param array $condition Additional condition array on the contact table + * @return int + * @throws Exception + */ + public static function countAll(int $cid, array $condition = []) + { + $condition = DBA::mergeConditions($condition, + ['(`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`) + OR `id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`))', + $cid, $cid] + ); + + return DI::dba()->count('contact', $condition); + } + + /** + * Returns a paginated list of contacts with any relationship with the provided public contact. + * + * @param int $cid Public contact id + * @param array $condition Additional condition on the contact table + * @param int $count + * @param int $offset + * @param bool $shuffle + * @return array + * @throws Exception + */ + public static function listAll(int $cid, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false) + { + $condition = DBA::mergeConditions($condition, + ['(`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`) + OR `id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`))', + $cid, $cid] + ); + + return DI::dba()->selectToArray('contact', [], $condition, + ['limit' => [$offset, $count], 'order' => [$shuffle ? 'RAND()' : 'name']] + ); + } + + /** + * Counts the number of contacts that both provided public contacts have interacted with at least once. + * Interactions include follows and likes and comments on public posts. + * + * @param int $sourceId Public contact id + * @param int $targetId Public contact id + * @param array $condition Additional condition array on the contact table + * @return int + * @throws Exception + */ + public static function countCommon(int $sourceId, int $targetId, array $condition = []) + { + $condition = DBA::mergeConditions($condition, + ['`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ?) + AND `id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ?)', + $sourceId, $targetId] + ); + + return DI::dba()->count('contact', $condition); + } + + /** + * Returns a paginated list of contacts that both provided public contacts have interacted with at least once. + * Interactions include follows and likes and comments on public posts. + * + * @param int $sourceId Public contact id + * @param int $targetId Public contact id + * @param array $condition Additional condition on the contact table + * @param int $count + * @param int $offset + * @param bool $shuffle + * @return array + * @throws Exception + */ + public static function listCommon(int $sourceId, int $targetId, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false) + { + $condition = DBA::mergeConditions($condition, + ["`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ?) + AND `id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ?)", + $sourceId, $targetId] + ); + + return DI::dba()->selectToArray('contact', [], $condition, + ['limit' => [$offset, $count], 'order' => [$shuffle ? 'RAND()' : 'name']] + ); + } + + /** + * Counts the number of contacts that are followed by both provided public contacts. + * + * @param int $sourceId Public contact id + * @param int $targetId Public contact id + * @param array $condition Additional condition array on the contact table + * @return int + * @throws Exception + */ + public static function countCommonFollows(int $sourceId, int $targetId, array $condition = []) + { + $condition = DBA::mergeConditions($condition, + ['`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`) + AND `id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`)', + $sourceId, $targetId] + ); + + return DI::dba()->count('contact', $condition); + } + + /** + * Returns a paginated list of contacts that are followed by both provided public contacts. + * + * @param int $sourceId Public contact id + * @param int $targetId Public contact id + * @param array $condition Additional condition array on the contact table + * @param int $count + * @param int $offset + * @param bool $shuffle + * @return array + * @throws Exception + */ + public static function listCommonFollows(int $sourceId, int $targetId, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false) + { + $condition = DBA::mergeConditions($condition, + ["`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`) + AND `id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`)", + $sourceId, $targetId] + ); + + return DI::dba()->selectToArray('contact', [], $condition, + ['limit' => [$offset, $count], 'order' => [$shuffle ? 'RAND()' : 'name']] + ); + } + + /** + * Counts the number of contacts that follow both provided public contacts. + * + * @param int $sourceId Public contact id + * @param int $targetId Public contact id + * @param array $condition Additional condition on the contact table + * @return int + * @throws Exception + */ + public static function countCommonFollowers(int $sourceId, int $targetId, array $condition = []) + { + $condition = DBA::mergeConditions($condition, + ["`id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`) + AND `id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`)", + $sourceId, $targetId] + ); + + return DI::dba()->count('contact', $condition); + } + + /** + * Returns a paginated list of contacts that follow both provided public contacts. + * + * @param int $sourceId Public contact id + * @param int $targetId Public contact id + * @param array $condition Additional condition on the contact table + * @param int $count + * @param int $offset + * @param bool $shuffle + * @return array + * @throws Exception + */ + public static function listCommonFollowers(int $sourceId, int $targetId, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false) + { + $condition = DBA::mergeConditions($condition, + ["`id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`) + AND `id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`)", + $sourceId, $targetId] + ); + + return DI::dba()->selectToArray('contact', [], $condition, + ['limit' => [$offset, $count], 'order' => [$shuffle ? 'RAND()' : 'name']] + ); + } +} diff --git a/src/Model/Contact/User.php b/src/Model/Contact/User.php new file mode 100644 index 0000000000..be60c119bf --- /dev/null +++ b/src/Model/Contact/User.php @@ -0,0 +1,204 @@ +. + * + */ + +namespace Friendica\Model\Contact; + +use Friendica\Database\DBA; +use Friendica\Model\Contact; + +/** + * This class provides information about user related contacts based on the "user-contact" table. + */ +class User +{ + /** + * Block contact id for user id + * + * @param int $cid Either public contact id or user's contact id + * @param int $uid User ID + * @param boolean $blocked Is the contact blocked or unblocked? + * @throws \Exception + */ + public static function setBlocked($cid, $uid, $blocked) + { + $cdata = Contact::getPublicAndUserContacID($cid, $uid); + if (empty($cdata)) { + return; + } + + if ($cdata['user'] != 0) { + DBA::update('contact', ['blocked' => $blocked], ['id' => $cdata['user'], 'pending' => false]); + } + + DBA::update('user-contact', ['blocked' => $blocked], ['cid' => $cdata['public'], 'uid' => $uid], true); + } + + /** + * Returns "block" state for contact id and user id + * + * @param int $cid Either public contact id or user's contact id + * @param int $uid User ID + * + * @return boolean is the contact id blocked for the given user? + * @throws \Exception + */ + public static function isBlocked($cid, $uid) + { + $cdata = Contact::getPublicAndUserContacID($cid, $uid); + if (empty($cdata)) { + return false; + } + + $public_blocked = false; + + if (!empty($cdata['public'])) { + $public_contact = DBA::selectFirst('user-contact', ['blocked'], ['cid' => $cdata['public'], 'uid' => $uid]); + if (DBA::isResult($public_contact)) { + $public_blocked = $public_contact['blocked']; + } + } + + $user_blocked = $public_blocked; + + if (!empty($cdata['user'])) { + $user_contact = DBA::selectFirst('contact', ['blocked'], ['id' => $cdata['user'], 'pending' => false]); + if (DBA::isResult($user_contact)) { + $user_blocked = $user_contact['blocked']; + } + } + + if ($user_blocked != $public_blocked) { + DBA::update('user-contact', ['blocked' => $user_blocked], ['cid' => $cdata['public'], 'uid' => $uid], true); + } + + return $user_blocked; + } + + /** + * Ignore contact id for user id + * + * @param int $cid Either public contact id or user's contact id + * @param int $uid User ID + * @param boolean $ignored Is the contact ignored or unignored? + * @throws \Exception + */ + public static function setIgnored($cid, $uid, $ignored) + { + $cdata = Contact::getPublicAndUserContacID($cid, $uid); + if (empty($cdata)) { + return; + } + + if ($cdata['user'] != 0) { + DBA::update('contact', ['readonly' => $ignored], ['id' => $cdata['user'], 'pending' => false]); + } + + DBA::update('user-contact', ['ignored' => $ignored], ['cid' => $cdata['public'], 'uid' => $uid], true); + } + + /** + * Returns "ignore" state for contact id and user id + * + * @param int $cid Either public contact id or user's contact id + * @param int $uid User ID + * + * @return boolean is the contact id ignored for the given user? + * @throws \Exception + */ + public static function isIgnored($cid, $uid) + { + $cdata = Contact::getPublicAndUserContacID($cid, $uid); + if (empty($cdata)) { + return false; + } + + $public_ignored = false; + + if (!empty($cdata['public'])) { + $public_contact = DBA::selectFirst('user-contact', ['ignored'], ['cid' => $cdata['public'], 'uid' => $uid]); + if (DBA::isResult($public_contact)) { + $public_ignored = $public_contact['ignored']; + } + } + + $user_ignored = $public_ignored; + + if (!empty($cdata['user'])) { + $user_contact = DBA::selectFirst('contact', ['readonly'], ['id' => $cdata['user'], 'pending' => false]); + if (DBA::isResult($user_contact)) { + $user_ignored = $user_contact['readonly']; + } + } + + if ($user_ignored != $public_ignored) { + DBA::update('user-contact', ['ignored' => $user_ignored], ['cid' => $cdata['public'], 'uid' => $uid], true); + } + + return $user_ignored; + } + + /** + * Set "collapsed" for contact id and user id + * + * @param int $cid Either public contact id or user's contact id + * @param int $uid User ID + * @param boolean $collapsed are the contact's posts collapsed or uncollapsed? + * @throws \Exception + */ + public static function setCollapsed($cid, $uid, $collapsed) + { + $cdata = Contact::getPublicAndUserContacID($cid, $uid); + if (empty($cdata)) { + return; + } + + DBA::update('user-contact', ['collapsed' => $collapsed], ['cid' => $cdata['public'], 'uid' => $uid], true); + } + + /** + * Returns "collapsed" state for contact id and user id + * + * @param int $cid Either public contact id or user's contact id + * @param int $uid User ID + * + * @return boolean is the contact id blocked for the given user? + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public static function isCollapsed($cid, $uid) + { + $cdata = Contact::getPublicAndUserContacID($cid, $uid); + if (empty($cdata)) { + return; + } + + $collapsed = false; + + if (!empty($cdata['public'])) { + $public_contact = DBA::selectFirst('user-contact', ['collapsed'], ['cid' => $cdata['public'], 'uid' => $uid]); + if (DBA::isResult($public_contact)) { + $collapsed = $public_contact['collapsed']; + } + } + + return $collapsed; + } +} diff --git a/src/Model/Conversation.php b/src/Model/Conversation.php index 1dcb6b0c2d..c51f19f171 100644 --- a/src/Model/Conversation.php +++ b/src/Model/Conversation.php @@ -21,8 +21,8 @@ namespace Friendica\Model; -use Friendica\Core\Logger; use Friendica\Core\Protocol; +use Friendica\Database\Database; use Friendica\Database\DBA; use Friendica\Util\DateTimeFormat; @@ -33,11 +33,15 @@ class Conversation * It currently is stored in the "protocol" field for legacy reasons. */ const PARCEL_ACTIVITYPUB = 0; - const PARCEL_DFRN = 1; + const PARCEL_DFRN = 1; // Deprecated const PARCEL_DIASPORA = 2; const PARCEL_SALMON = 3; const PARCEL_FEED = 4; // Deprecated const PARCEL_SPLIT_CONVERSATION = 6; + const PARCEL_LEGACY_DFRN = 7; + const PARCEL_DIASPORA_DFRN = 8; + const PARCEL_LOCAL_DFRN = 9; + const PARCEL_DIRECT = 10; const PARCEL_TWITTER = 67; const PARCEL_UNKNOWN = 255; @@ -53,6 +57,10 @@ class Conversation * The message had been fetched by our system */ const PULL = 2; + /** + * The message had been pushed to this system via a relay server + */ + const RELAY = 3; public static function getByItemUri($item_uri) { @@ -100,42 +108,14 @@ class Conversation $conversation['source'] = $arr['source']; } - $fields = ['item-uri', 'reply-to-uri', 'conversation-uri', 'conversation-href', 'protocol', 'source']; - $old_conv = DBA::selectFirst('conversation', $fields, ['item-uri' => $conversation['item-uri']]); - if (DBA::isResult($old_conv)) { - // Don't update when only the source has changed. - // Only do this when there had been no source before. - if ($old_conv['source'] != '') { - unset($old_conv['source']); - } - // Update structure data all the time but the source only when its from a better protocol. - if ( - empty($conversation['source']) - || ( - !empty($old_conv['source']) - && ($old_conv['protocol'] < (($conversation['protocol'] ?? '') ?: self::PARCEL_UNKNOWN)) - ) - ) { - unset($conversation['protocol']); - unset($conversation['source']); - } - if (!DBA::update('conversation', $conversation, ['item-uri' => $conversation['item-uri']], $old_conv)) { - Logger::log('Conversation: update for ' . $conversation['item-uri'] . ' from ' . $old_conv['protocol'] . ' to ' . $conversation['protocol'] . ' failed', - Logger::DEBUG); - } - } else { - if (!DBA::insert('conversation', $conversation, true)) { - Logger::log('Conversation: insert for ' . $conversation['item-uri'] . ' (protocol ' . $conversation['protocol'] . ') failed', - Logger::DEBUG); - } + if (!DBA::exists('conversation', ['item-uri' => $conversation['item-uri']])) { + DBA::insert('conversation', $conversation, Database::INSERT_IGNORE); } } unset($arr['conversation-uri']); unset($arr['conversation-href']); - unset($arr['protocol']); unset($arr['source']); - unset($arr['direction']); return $arr; } diff --git a/src/Model/Event.php b/src/Model/Event.php index b8d578bbad..f66d765624 100644 --- a/src/Model/Event.php +++ b/src/Model/Event.php @@ -24,6 +24,7 @@ namespace Friendica\Model; use Friendica\Content\Text\BBCode; use Friendica\Core\Hook; use Friendica\Core\Logger; +use Friendica\Core\Protocol; use Friendica\Core\Renderer; use Friendica\Core\System; use Friendica\Database\DBA; @@ -256,6 +257,16 @@ class Event */ public static function store($arr) { + $network = $arr['network'] ?? Protocol::DFRN; + $protocol = $arr['protocol'] ?? Conversation::PARCEL_UNKNOWN; + $direction = $arr['direction'] ?? Conversation::UNKNOWN; + $source = $arr['source'] ?? ''; + + unset($arr['network']); + unset($arr['protocol']); + unset($arr['direction']); + unset($arr['source']); + $event = []; $event['id'] = intval($arr['id'] ?? 0); $event['uid'] = intval($arr['uid'] ?? 0); @@ -290,6 +301,9 @@ class Event } $contact = DBA::selectFirst('contact', [], $conditions); + if (!DBA::isResult($contact)) { + Logger::warning('Contact not found', ['condition' => $conditions, 'callstack' => System::callstack(20)]); + } // Existing event being modified. if ($event['id']) { @@ -346,7 +360,6 @@ class Event $item_arr['uid'] = $event['uid']; $item_arr['contact-id'] = $event['cid']; $item_arr['uri'] = $event['uri']; - $item_arr['parent-uri'] = $event['uri']; $item_arr['guid'] = $event['guid']; $item_arr['plink'] = $arr['plink'] ?? ''; $item_arr['post-type'] = Item::PT_EVENT; @@ -370,6 +383,10 @@ class Event $item_arr['origin'] = $event['cid'] === 0 ? 1 : 0; $item_arr['body'] = self::getBBCode($event); $item_arr['event-id'] = $event['id']; + $item_arr['network'] = $network; + $item_arr['protocol'] = $protocol; + $item_arr['direction'] = $direction; + $item_arr['source'] = $source; $item_arr['object'] = '' . XML::escape(Activity\ObjectType::EVENT) . '' . XML::escape($event['uri']) . ''; $item_arr['object'] .= '' . XML::escape(self::getBBCode($event)) . ''; @@ -611,14 +628,12 @@ class Event $title = BBCode::convert(Strings::escapeHtml($event['summary'])); if (!$title) { - list($title, $_trash) = explode(" $is_first, 'item' => $event, 'html' => $html, - 'plink' => [$event['plink'], DI::l10n()->t('link to source'), '', ''], + 'plink' => Item::getPlink($event), ]; } diff --git a/src/Model/FContact.php b/src/Model/FContact.php new file mode 100644 index 0000000000..3c56e5a0db --- /dev/null +++ b/src/Model/FContact.php @@ -0,0 +1,195 @@ +. + * + */ + +namespace Friendica\Model; + +use Friendica\Core\Logger; +use Friendica\Core\Protocol; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Notify\Type; +use Friendica\Network\Probe; +use Friendica\Protocol\Activity; +use Friendica\Util\DateTimeFormat; +use Friendica\Util\Strings; + +class FContact +{ + /** + * Fetches data for a given handle + * + * @param string $handle The handle + * @param boolean $update true = always update, false = never update, null = update when not found or outdated + * + * @return array the queried data + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public static function getByURL($handle, $update = null, $network = Protocol::DIASPORA) + { + $person = DBA::selectFirst('fcontact', [], ['network' => $network, 'addr' => $handle]); + if (!DBA::isResult($person)) { + $urls = [$handle, str_replace('http://', 'https://', $handle), Strings::normaliseLink($handle)]; + $person = DBA::selectFirst('fcontact', [], ['network' => $network, 'url' => $urls]); + } + + if (DBA::isResult($person)) { + Logger::debug('In cache', ['person' => $person]); + + if (is_null($update)) { + // update record occasionally so it doesn't get stale + $d = strtotime($person["updated"]." +00:00"); + if ($d < strtotime("now - 14 days")) { + $update = true; + } + + if ($person["guid"] == "") { + $update = true; + } + } + } elseif (is_null($update)) { + $update = !DBA::isResult($person); + } else { + $person = []; + } + + if ($update) { + Logger::info('create or refresh', ['handle' => $handle]); + $r = Probe::uri($handle, $network); + + // Note that Friendica contacts will return a "Diaspora person" + // if Diaspora connectivity is enabled on their server + if ($r && ($r["network"] === $network)) { + self::updateFContact($r); + + $person = self::getByURL($handle, false, $network); + } + } + + return $person; + } + + /** + * Updates the fcontact table + * + * @param array $arr The fcontact data + * @throws \Exception + */ + private static function updateFContact($arr) + { + $fields = ['name' => $arr["name"], 'photo' => $arr["photo"], + 'request' => $arr["request"], 'nick' => $arr["nick"], + 'addr' => strtolower($arr["addr"]), 'guid' => $arr["guid"], + 'batch' => $arr["batch"], 'notify' => $arr["notify"], + 'poll' => $arr["poll"], 'confirm' => $arr["confirm"], + 'alias' => $arr["alias"], 'pubkey' => $arr["pubkey"], + 'updated' => DateTimeFormat::utcNow()]; + + $condition = ['url' => $arr["url"], 'network' => $arr["network"]]; + + DBA::update('fcontact', $fields, $condition, true); + } + + /** + * get a url (scheme://domain.tld/u/user) from a given Diaspora* + * fcontact guid + * + * @param mixed $fcontact_guid Hexadecimal string guid + * + * @return string the contact url or null + * @throws \Exception + */ + public static function getUrlByGuid($fcontact_guid) + { + Logger::info('fcontact', ['guid' => $fcontact_guid]); + + $r = q( + "SELECT `url` FROM `fcontact` WHERE `url` != '' AND `network` = '%s' AND `guid` = '%s'", + DBA::escape(Protocol::DIASPORA), + DBA::escape($fcontact_guid) + ); + + if (DBA::isResult($r)) { + return $r[0]['url']; + } + + return null; + } + + /** + * Suggest a given contact to a given user from a given contact + * + * @param integer $uid + * @param integer $cid + * @param integer $from_cid + * @return bool Was the adding successful? + */ + public static function addSuggestion(int $uid, int $cid, int $from_cid, string $note = '') + { + $owner = User::getOwnerDataById($uid); + $contact = Contact::getById($cid); + $from_contact = Contact::getById($from_cid); + + if (DBA::exists('contact', ['nurl' => Strings::normaliseLink($contact['url']), 'uid' => $uid])) { + return false; + } + + $fcontact = self::getByURL($contact['url'], null, $contact['network']); + if (empty($fcontact)) { + Logger::warning('FContact had not been found', ['fcontact' => $contact['url']]); + return false; + } + + $fid = $fcontact['id']; + + // Quit if we already have an introduction for this person + if (DBA::exists('intro', ['uid' => $uid, 'fid' => $fid])) { + return false; + } + + $suggest = []; + $suggest['uid'] = $uid; + $suggest['cid'] = $from_cid; + $suggest['url'] = $contact['url']; + $suggest['name'] = $contact['name']; + $suggest['photo'] = $contact['photo']; + $suggest['request'] = $contact['request']; + $suggest['title'] = ''; + $suggest['body'] = $note; + + $hash = Strings::getRandomHex(); + $fields = ['uid' => $suggest['uid'], 'fid' => $fid, 'contact-id' => $suggest['cid'], + 'note' => $suggest['body'], 'hash' => $hash, 'datetime' => DateTimeFormat::utcNow(), 'blocked' => false]; + DBA::insert('intro', $fields); + + notification([ + 'type' => Type::SUGGEST, + 'otype' => Notify\ObjectType::INTRO, + 'verb' => Activity::REQ_FRIEND, + 'uid' => $owner['uid'], + 'cid' => $from_contact['uid'], + 'item' => $suggest, + 'link' => DI::baseUrl().'/notifications/intros', + ]); + + return true; + } +} diff --git a/src/Model/FileTag.php b/src/Model/FileTag.php index 0b728e33d7..a2c8bb4397 100644 --- a/src/Model/FileTag.php +++ b/src/Model/FileTag.php @@ -271,8 +271,6 @@ class FileTag if (!strlen($saved) || !stristr($saved, '[' . self::encode($file) . ']')) { DI::pConfig()->set($uid, 'system', 'filetags', $saved . '[' . self::encode($file) . ']'); } - - info(DI::l10n()->t('Item filed')); } return true; diff --git a/src/Model/GContact.php b/src/Model/GContact.php deleted file mode 100644 index becfd61b08..0000000000 --- a/src/Model/GContact.php +++ /dev/null @@ -1,1439 +0,0 @@ -. - * - */ - -namespace Friendica\Model; - -use DOMDocument; -use DOMXPath; -use Exception; -use Friendica\Core\Logger; -use Friendica\Core\Protocol; -use Friendica\Core\System; -use Friendica\Core\Search; -use Friendica\Core\Worker; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Network\Probe; -use Friendica\Protocol\ActivityPub; -use Friendica\Protocol\PortableContact; -use Friendica\Util\DateTimeFormat; -use Friendica\Util\Network; -use Friendica\Util\Strings; - -/** - * This class handles GlobalContact related functions - */ -class GContact -{ - /** - * No discovery of followers/followings - */ - const DISCOVERY_NONE = 0; - /** - * Only discover followers/followings from direct contacts - */ - const DISCOVERY_DIRECT = 1; - /** - * Recursive discovery of followers/followings - */ - const DISCOVERY_RECURSIVE = 2; - - /** - * Search global contact table by nick or name - * - * @param string $search Name or nick - * @param string $mode Search mode (e.g. "community") - * - * @return array with search results - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function searchByName($search, $mode = '') - { - if (empty($search)) { - return []; - } - - // check supported networks - if (DI::config()->get('system', 'diaspora_enabled')) { - $diaspora = Protocol::DIASPORA; - } else { - $diaspora = Protocol::DFRN; - } - - if (!DI::config()->get('system', 'ostatus_disabled')) { - $ostatus = Protocol::OSTATUS; - } else { - $ostatus = Protocol::DFRN; - } - - // check if we search only communities or every contact - if ($mode === 'community') { - $extra_sql = ' AND `community`'; - } else { - $extra_sql = ''; - } - - $search .= '%'; - - $results = DBA::p("SELECT `nurl` FROM `gcontact` - WHERE NOT `hide` AND `network` IN (?, ?, ?, ?) AND - ((`last_contact` >= `last_failure`) OR (`updated` >= `last_failure`)) AND - (`addr` LIKE ? OR `name` LIKE ? OR `nick` LIKE ?) $extra_sql - GROUP BY `nurl` ORDER BY `nurl` DESC LIMIT 1000", - Protocol::DFRN, Protocol::ACTIVITYPUB, $ostatus, $diaspora, $search, $search, $search - ); - - $gcontacts = []; - while ($result = DBA::fetch($results)) { - $urlparts = parse_url($result['nurl']); - - // Ignore results that look strange. - // For historic reasons the gcontact table does contain some garbage. - if (empty($result['nurl']) || !empty($urlparts['query']) || !empty($urlparts['fragment'])) { - continue; - } - - $gcontacts[] = Contact::getDetailsByURL($result['nurl'], local_user()); - } - DBA::close($results); - return $gcontacts; - } - - /** - * Link the gcontact entry with user, contact and global contact - * - * @param integer $gcid Global contact ID - * @param integer $uid User ID - * @param integer $cid Contact ID - * @param integer $zcid Global Contact ID - * @return void - * @throws Exception - */ - public static function link($gcid, $uid = 0, $cid = 0, $zcid = 0) - { - if ($gcid <= 0) { - return; - } - - $condition = ['cid' => $cid, 'uid' => $uid, 'gcid' => $gcid, 'zcid' => $zcid]; - DBA::update('glink', ['updated' => DateTimeFormat::utcNow()], $condition, true); - } - - /** - * Sanitize the given gcontact data - * - * Generation: - * 0: No definition - * 1: Profiles on this server - * 2: Contacts of profiles on this server - * 3: Contacts of contacts of profiles on this server - * 4: ... - * - * @param array $gcontact array with gcontact data - * @return array $gcontact - * @throws Exception - */ - public static function sanitize($gcontact) - { - if (empty($gcontact['url'])) { - throw new Exception('URL is empty'); - } - - $gcontact['server_url'] = $gcontact['server_url'] ?? ''; - - $urlparts = parse_url($gcontact['url']); - if (empty($urlparts['scheme'])) { - throw new Exception('This (' . $gcontact['url'] . ") doesn't seem to be an url."); - } - - if (in_array($urlparts['host'], ['twitter.com', 'identi.ca'])) { - throw new Exception('Contact from a non federated network ignored. (' . $gcontact['url'] . ')'); - } - - // Don't store the statusnet connector as network - // We can't simply set this to Protocol::OSTATUS since the connector could have fetched posts from friendica as well - if ($gcontact['network'] == Protocol::STATUSNET) { - $gcontact['network'] = ''; - } - - // Assure that there are no parameter fragments in the profile url - if (empty($gcontact['*network']) || in_array($gcontact['network'], Protocol::FEDERATED)) { - $gcontact['url'] = self::cleanContactUrl($gcontact['url']); - } - - // The global contacts should contain the original picture, not the cached one - if (($gcontact['generation'] != 1) && stristr(Strings::normaliseLink($gcontact['photo']), Strings::normaliseLink(DI::baseUrl() . '/photo/'))) { - $gcontact['photo'] = ''; - } - - if (empty($gcontact['network'])) { - $gcontact['network'] = ''; - - $condition = ["`uid` = 0 AND `nurl` = ? AND `network` != '' AND `network` != ?", - Strings::normaliseLink($gcontact['url']), Protocol::STATUSNET]; - $contact = DBA::selectFirst('contact', ['network'], $condition); - if (DBA::isResult($contact)) { - $gcontact['network'] = $contact['network']; - } - - if (($gcontact['network'] == '') || ($gcontact['network'] == Protocol::OSTATUS)) { - $condition = ["`uid` = 0 AND `alias` IN (?, ?) AND `network` != '' AND `network` != ?", - $gcontact['url'], Strings::normaliseLink($gcontact['url']), Protocol::STATUSNET]; - $contact = DBA::selectFirst('contact', ['network'], $condition); - if (DBA::isResult($contact)) { - $gcontact['network'] = $contact['network']; - } - } - } - - $fields = ['network', 'updated', 'server_url', 'url', 'addr']; - $gcnt = DBA::selectFirst('gcontact', $fields, ['nurl' => Strings::normaliseLink($gcontact['url'])]); - if (DBA::isResult($gcnt)) { - if (!isset($gcontact['network']) && ($gcnt['network'] != Protocol::STATUSNET)) { - $gcontact['network'] = $gcnt['network']; - } - if ($gcontact['updated'] <= DBA::NULL_DATETIME) { - $gcontact['updated'] = $gcnt['updated']; - } - if (!isset($gcontact['server_url']) && (Strings::normaliseLink($gcnt['server_url']) != Strings::normaliseLink($gcnt['url']))) { - $gcontact['server_url'] = $gcnt['server_url']; - } - if (!isset($gcontact['addr'])) { - $gcontact['addr'] = $gcnt['addr']; - } - } - - if ((!isset($gcontact['network']) || !isset($gcontact['name']) || !isset($gcontact['addr']) || !isset($gcontact['photo']) || !isset($gcontact['server_url'])) - && GServer::reachable($gcontact['url'], $gcontact['server_url'], $gcontact['network'], false) - ) { - $data = Probe::uri($gcontact['url']); - - if ($data['network'] == Protocol::PHANTOM) { - throw new Exception('Probing for URL ' . $gcontact['url'] . ' failed'); - } - - $orig_profile = $gcontact['url']; - - $gcontact['server_url'] = $data['baseurl']; - - $gcontact = array_merge($gcontact, $data); - } - - if (!isset($gcontact['name']) || !isset($gcontact['photo'])) { - throw new Exception('No name and photo for URL '.$gcontact['url']); - } - - if (!in_array($gcontact['network'], Protocol::FEDERATED)) { - throw new Exception('No federated network (' . $gcontact['network'] . ') detected for URL ' . $gcontact['url']); - } - - if (empty($gcontact['server_url'])) { - // We check the server url to be sure that it is a real one - $server_url = self::getBasepath($gcontact['url']); - - // We are now sure that it is a correct URL. So we use it in the future - if ($server_url != '') { - $gcontact['server_url'] = $server_url; - } - } - - // The server URL doesn't seem to be valid, so we don't store it. - if (!GServer::check($gcontact['server_url'], $gcontact['network'])) { - $gcontact['server_url'] = ''; - } - - return $gcontact; - } - - /** - * @param integer $uid id - * @param integer $cid id - * @return integer - * @throws Exception - */ - public static function countCommonFriends($uid, $cid) - { - $r = q( - "SELECT count(*) as `total` - FROM `glink` INNER JOIN `gcontact` on `glink`.`gcid` = `gcontact`.`id` - WHERE `glink`.`cid` = %d AND `glink`.`uid` = %d AND - ((`gcontact`.`last_contact` >= `gcontact`.`last_failure`) OR - (`gcontact`.`updated` >= `gcontact`.`last_failure`)) - AND `gcontact`.`nurl` IN (select nurl from contact where uid = %d and self = 0 and blocked = 0 and hidden = 0 and id != %d) ", - intval($cid), - intval($uid), - intval($uid), - intval($cid) - ); - - if (DBA::isResult($r)) { - return $r[0]['total']; - } - return 0; - } - - /** - * @param integer $uid id - * @param integer $zcid zcid - * @return integer - * @throws Exception - */ - public static function countCommonFriendsZcid($uid, $zcid) - { - $r = q( - "SELECT count(*) as `total` - FROM `glink` INNER JOIN `gcontact` on `glink`.`gcid` = `gcontact`.`id` - where `glink`.`zcid` = %d - and `gcontact`.`nurl` in (select nurl from contact where uid = %d and self = 0 and blocked = 0 and hidden = 0) ", - intval($zcid), - intval($uid) - ); - - if (DBA::isResult($r)) { - return $r[0]['total']; - } - - return 0; - } - - /** - * @param integer $uid user - * @param integer $cid cid - * @param integer $start optional, default 0 - * @param integer $limit optional, default 9999 - * @param boolean $shuffle optional, default false - * @return object - * @throws Exception - */ - public static function commonFriends($uid, $cid, $start = 0, $limit = 9999, $shuffle = false) - { - if ($shuffle) { - $sql_extra = " order by rand() "; - } else { - $sql_extra = " order by `gcontact`.`name` asc "; - } - - $r = q( - "SELECT `gcontact`.*, `contact`.`id` AS `cid` - FROM `glink` - INNER JOIN `gcontact` ON `glink`.`gcid` = `gcontact`.`id` - INNER JOIN `contact` ON `gcontact`.`nurl` = `contact`.`nurl` - WHERE `glink`.`cid` = %d and `glink`.`uid` = %d - AND `contact`.`uid` = %d AND `contact`.`self` = 0 AND `contact`.`blocked` = 0 - AND `contact`.`hidden` = 0 AND `contact`.`id` != %d - AND ((`gcontact`.`last_contact` >= `gcontact`.`last_failure`) OR (`gcontact`.`updated` >= `gcontact`.`last_failure`)) - $sql_extra LIMIT %d, %d", - intval($cid), - intval($uid), - intval($uid), - intval($cid), - intval($start), - intval($limit) - ); - - /// @TODO Check all calling-findings of this function if they properly use DBA::isResult() - return $r; - } - - /** - * @param integer $uid user - * @param integer $zcid zcid - * @param integer $start optional, default 0 - * @param integer $limit optional, default 9999 - * @param boolean $shuffle optional, default false - * @return object - * @throws Exception - */ - public static function commonFriendsZcid($uid, $zcid, $start = 0, $limit = 9999, $shuffle = false) - { - if ($shuffle) { - $sql_extra = " order by rand() "; - } else { - $sql_extra = " order by `gcontact`.`name` asc "; - } - - $r = q( - "SELECT `gcontact`.* - FROM `glink` INNER JOIN `gcontact` on `glink`.`gcid` = `gcontact`.`id` - where `glink`.`zcid` = %d - and `gcontact`.`nurl` in (select nurl from contact where uid = %d and self = 0 and blocked = 0 and hidden = 0) - $sql_extra limit %d, %d", - intval($zcid), - intval($uid), - intval($start), - intval($limit) - ); - - /// @TODO Check all calling-findings of this function if they properly use DBA::isResult() - return $r; - } - - /** - * @param integer $uid user - * @param integer $cid cid - * @return integer - * @throws Exception - */ - public static function countAllFriends($uid, $cid) - { - $r = q( - "SELECT count(*) as `total` - FROM `glink` INNER JOIN `gcontact` on `glink`.`gcid` = `gcontact`.`id` - where `glink`.`cid` = %d and `glink`.`uid` = %d AND - ((`gcontact`.`last_contact` >= `gcontact`.`last_failure`) OR (`gcontact`.`updated` >= `gcontact`.`last_failure`))", - intval($cid), - intval($uid) - ); - - if (DBA::isResult($r)) { - return $r[0]['total']; - } - - return 0; - } - - /** - * @param integer $uid user - * @param integer $cid cid - * @param integer $start optional, default 0 - * @param integer $limit optional, default 80 - * @return array - * @throws Exception - */ - public static function allFriends($uid, $cid, $start = 0, $limit = 80) - { - $r = q( - "SELECT `gcontact`.*, `contact`.`id` AS `cid` - FROM `glink` - INNER JOIN `gcontact` on `glink`.`gcid` = `gcontact`.`id` - LEFT JOIN `contact` ON `contact`.`nurl` = `gcontact`.`nurl` AND `contact`.`uid` = %d - WHERE `glink`.`cid` = %d AND `glink`.`uid` = %d AND - ((`gcontact`.`last_contact` >= `gcontact`.`last_failure`) OR (`gcontact`.`updated` >= `gcontact`.`last_failure`)) - ORDER BY `gcontact`.`name` ASC LIMIT %d, %d ", - intval($uid), - intval($cid), - intval($uid), - intval($start), - intval($limit) - ); - - /// @TODO Check all calling-findings of this function if they properly use DBA::isResult() - return $r; - } - - /** - * @param int $uid user - * @param integer $start optional, default 0 - * @param integer $limit optional, default 80 - * @return array - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function suggestionQuery($uid, $start = 0, $limit = 80) - { - if (!$uid) { - return []; - } - - $network = [Protocol::DFRN, Protocol::ACTIVITYPUB]; - - if (DI::config()->get('system', 'diaspora_enabled')) { - $network[] = Protocol::DIASPORA; - } - - if (!DI::config()->get('system', 'ostatus_disabled')) { - $network[] = Protocol::OSTATUS; - } - - $sql_network = "'" . implode("', '", $network) . "'"; - - /// @todo This query is really slow - // By now we cache the data for five minutes - $r = q( - "SELECT count(glink.gcid) as `total`, gcontact.* from gcontact - INNER JOIN `glink` ON `glink`.`gcid` = `gcontact`.`id` - where uid = %d and not gcontact.nurl in ( select nurl from contact where uid = %d ) - AND NOT `gcontact`.`name` IN (SELECT `name` FROM `contact` WHERE `uid` = %d) - AND NOT `gcontact`.`id` IN (SELECT `gcid` FROM `gcign` WHERE `uid` = %d) - AND `gcontact`.`updated` >= '%s' AND NOT `gcontact`.`hide` - AND `gcontact`.`last_contact` >= `gcontact`.`last_failure` - AND `gcontact`.`network` IN (%s) - GROUP BY `glink`.`gcid` ORDER BY `gcontact`.`updated` DESC,`total` DESC LIMIT %d, %d", - intval($uid), - intval($uid), - intval($uid), - intval($uid), - DBA::NULL_DATETIME, - $sql_network, - intval($start), - intval($limit) - ); - - if (DBA::isResult($r) && count($r) >= ($limit -1)) { - return $r; - } - - $r2 = q( - "SELECT gcontact.* FROM gcontact - INNER JOIN `glink` ON `glink`.`gcid` = `gcontact`.`id` - WHERE `glink`.`uid` = 0 AND `glink`.`cid` = 0 AND `glink`.`zcid` = 0 AND NOT `gcontact`.`nurl` IN (SELECT `nurl` FROM `contact` WHERE `uid` = %d) - AND NOT `gcontact`.`name` IN (SELECT `name` FROM `contact` WHERE `uid` = %d) - AND NOT `gcontact`.`id` IN (SELECT `gcid` FROM `gcign` WHERE `uid` = %d) - AND `gcontact`.`updated` >= '%s' - AND `gcontact`.`last_contact` >= `gcontact`.`last_failure` - AND `gcontact`.`network` IN (%s) - ORDER BY rand() LIMIT %d, %d", - intval($uid), - intval($uid), - intval($uid), - DBA::NULL_DATETIME, - $sql_network, - intval($start), - intval($limit) - ); - - $list = []; - foreach ($r2 as $suggestion) { - $list[$suggestion['nurl']] = $suggestion; - } - - foreach ($r as $suggestion) { - $list[$suggestion['nurl']] = $suggestion; - } - - while (sizeof($list) > ($limit)) { - array_pop($list); - } - - return $list; - } - - /** - * @return void - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function updateSuggestions() - { - $done = []; - - /// @TODO Check if it is really neccessary to poll the own server - PortableContact::loadWorker(0, 0, 0, DI::baseUrl() . '/poco'); - - $done[] = DI::baseUrl() . '/poco'; - - if (strlen(DI::config()->get('system', 'directory'))) { - $x = Network::fetchUrl(Search::getGlobalDirectory() . '/pubsites'); - if (!empty($x)) { - $j = json_decode($x); - if (!empty($j->entries)) { - foreach ($j->entries as $entry) { - GServer::check($entry->url); - - $url = $entry->url . '/poco'; - if (!in_array($url, $done)) { - PortableContact::loadWorker(0, 0, 0, $url); - $done[] = $url; - } - } - } - } - } - - // Query your contacts from Friendica and Redmatrix/Hubzilla for their contacts - $contacts = DBA::p("SELECT DISTINCT(`poco`) AS `poco` FROM `contact` WHERE `network` IN (?, ?)", Protocol::DFRN, Protocol::DIASPORA); - while ($contact = DBA::fetch($contacts)) { - $base = substr($contact['poco'], 0, strrpos($contact['poco'], '/')); - if (!in_array($base, $done)) { - PortableContact::loadWorker(0, 0, 0, $base); - } - } - DBA::close($contacts); - } - - /** - * Removes unwanted parts from a contact url - * - * @param string $url Contact url - * - * @return string Contact url with the wanted parts - * @throws Exception - */ - public static function cleanContactUrl($url) - { - $parts = parse_url($url); - - if (empty($parts['scheme']) || empty($parts['host'])) { - return $url; - } - - $new_url = $parts['scheme'] . '://' . $parts['host']; - - if (!empty($parts['port'])) { - $new_url .= ':' . $parts['port']; - } - - if (!empty($parts['path'])) { - $new_url .= $parts['path']; - } - - if ($new_url != $url) { - Logger::info('Cleaned contact url', ['url' => $url, 'new_url' => $new_url, 'callstack' => System::callstack()]); - } - - return $new_url; - } - - /** - * Fetch the gcontact id, add an entry if not existed - * - * @param array $contact contact array - * - * @return bool|int Returns false if not found, integer if contact was found - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function getId($contact) - { - $gcontact_id = 0; - - if (empty($contact['network'])) { - Logger::notice('Empty network', ['url' => $contact['url'], 'callstack' => System::callstack()]); - return false; - } - - if (in_array($contact['network'], [Protocol::PHANTOM])) { - Logger::notice('Invalid network', ['url' => $contact['url'], 'callstack' => System::callstack()]); - return false; - } - - if ($contact['network'] == Protocol::STATUSNET) { - $contact['network'] = Protocol::OSTATUS; - } - - // All new contacts are hidden by default - if (!isset($contact['hide'])) { - $contact['hide'] = true; - } - - // Remove unwanted parts from the contact url (e.g. '?zrl=...') - if (in_array($contact['network'], Protocol::FEDERATED)) { - $contact['url'] = self::cleanContactUrl($contact['url']); - } - - DBA::lock('gcontact'); - $fields = ['id', 'last_contact', 'last_failure', 'network']; - $gcnt = DBA::selectFirst('gcontact', $fields, ['nurl' => Strings::normaliseLink($contact['url'])]); - if (DBA::isResult($gcnt)) { - $gcontact_id = $gcnt['id']; - } else { - $contact['location'] = $contact['location'] ?? ''; - $contact['about'] = $contact['about'] ?? ''; - $contact['generation'] = $contact['generation'] ?? 0; - - $fields = ['name' => $contact['name'], 'nick' => $contact['nick'] ?? '', 'addr' => $contact['addr'] ?? '', 'network' => $contact['network'], - 'url' => $contact['url'], 'nurl' => Strings::normaliseLink($contact['url']), 'photo' => $contact['photo'], - 'created' => DateTimeFormat::utcNow(), 'updated' => DateTimeFormat::utcNow(), 'location' => $contact['location'], - 'about' => $contact['about'], 'hide' => $contact['hide'], 'generation' => $contact['generation']]; - - DBA::insert('gcontact', $fields); - - $condition = ['nurl' => Strings::normaliseLink($contact['url'])]; - $cnt = DBA::selectFirst('gcontact', ['id', 'network'], $condition, ['order' => ['id']]); - if (DBA::isResult($cnt)) { - $gcontact_id = $cnt['id']; - } - } - DBA::unlock(); - - return $gcontact_id; - } - - /** - * Updates the gcontact table from a given array - * - * @param array $contact contact array - * - * @return bool|int Returns false if not found, integer if contact was found - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function update($contact) - { - // Check for invalid "contact-type" value - if (isset($contact['contact-type']) && (intval($contact['contact-type']) < 0)) { - $contact['contact-type'] = 0; - } - - /// @todo update contact table as well - - $gcontact_id = self::getId($contact); - - if (!$gcontact_id) { - return false; - } - - $public_contact = DBA::selectFirst('gcontact', [ - 'name', 'nick', 'photo', 'location', 'about', 'addr', 'generation', 'birthday', 'keywords', - 'contact-type', 'hide', 'nsfw', 'network', 'alias', 'notify', 'server_url', 'connect', 'updated', 'url' - ], ['id' => $gcontact_id]); - - if (!DBA::isResult($public_contact)) { - return false; - } - - // Get all field names - $fields = []; - foreach ($public_contact as $field => $data) { - $fields[$field] = $data; - } - - unset($fields['url']); - unset($fields['updated']); - unset($fields['hide']); - - // Bugfix: We had an error in the storing of keywords which lead to the "0" - // This value is still transmitted via poco. - if (isset($contact['keywords']) && ($contact['keywords'] == '0')) { - unset($contact['keywords']); - } - - if (isset($public_contact['keywords']) && ($public_contact['keywords'] == '0')) { - $public_contact['keywords'] = ''; - } - - // assign all unassigned fields from the database entry - foreach ($fields as $field => $data) { - if (empty($contact[$field])) { - $contact[$field] = $public_contact[$field]; - } - } - - if (!isset($contact['hide'])) { - $contact['hide'] = $public_contact['hide']; - } - - $fields['hide'] = $public_contact['hide']; - - if ($contact['network'] == Protocol::STATUSNET) { - $contact['network'] = Protocol::OSTATUS; - } - - if (!isset($contact['updated'])) { - $contact['updated'] = DateTimeFormat::utcNow(); - } - - if ($contact['network'] == Protocol::TWITTER) { - $contact['server_url'] = 'http://twitter.com'; - } - - if (empty($contact['server_url'])) { - $data = Probe::uri($contact['url']); - if ($data['network'] != Protocol::PHANTOM) { - $contact['server_url'] = $data['baseurl']; - } - } else { - $contact['server_url'] = Strings::normaliseLink($contact['server_url']); - } - - if (empty($contact['addr']) && !empty($contact['server_url']) && !empty($contact['nick'])) { - $hostname = str_replace('http://', '', $contact['server_url']); - $contact['addr'] = $contact['nick'] . '@' . $hostname; - } - - // Check if any field changed - $update = false; - unset($fields['generation']); - - if ((($contact['generation'] > 0) && ($contact['generation'] <= $public_contact['generation'])) || ($public_contact['generation'] == 0)) { - foreach ($fields as $field => $data) { - if ($contact[$field] != $public_contact[$field]) { - Logger::debug('Difference found.', ['contact' => $contact['url'], 'field' => $field, 'new' => $contact[$field], 'old' => $public_contact[$field]]); - $update = true; - } - } - - if ($contact['generation'] < $public_contact['generation']) { - Logger::debug('Difference found.', ['contact' => $contact['url'], 'field' => 'generation', 'new' => $contact['generation'], 'old' => $public_contact['generation']]); - $update = true; - } - } - - if ($update) { - Logger::debug('Update gcontact.', ['contact' => $contact['url']]); - $condition = ["`nurl` = ? AND (`generation` = 0 OR `generation` >= ?)", - Strings::normaliseLink($contact['url']), $contact['generation']]; - $contact['updated'] = DateTimeFormat::utc($contact['updated']); - - $updated = [ - 'photo' => $contact['photo'], 'name' => $contact['name'], - 'nick' => $contact['nick'], 'addr' => $contact['addr'], - 'network' => $contact['network'], 'birthday' => $contact['birthday'], - 'keywords' => $contact['keywords'], - 'hide' => $contact['hide'], 'nsfw' => $contact['nsfw'], - 'contact-type' => $contact['contact-type'], 'alias' => $contact['alias'], - 'notify' => $contact['notify'], 'url' => $contact['url'], - 'location' => $contact['location'], 'about' => $contact['about'], - 'generation' => $contact['generation'], 'updated' => $contact['updated'], - 'server_url' => $contact['server_url'], 'connect' => $contact['connect'] - ]; - - DBA::update('gcontact', $updated, $condition, $fields); - } - - return $gcontact_id; - } - - /** - * Set the last date that the contact had posted something - * - * @param string $data Probing result - * @param bool $force force updating - */ - public static function setLastUpdate(array $data, bool $force = false) - { - // Fetch the global contact - $gcontact = DBA::selectFirst('gcontact', ['created', 'updated', 'last_contact', 'last_failure'], - ['nurl' => Strings::normaliseLink($data['url'])]); - if (!DBA::isResult($gcontact)) { - return; - } - - if (!$force && !GServer::updateNeeded($gcontact['created'], $gcontact['updated'], $gcontact['last_failure'], $gcontact['last_contact'])) { - Logger::info("Don't update profile", ['url' => $data['url'], 'updated' => $gcontact['updated']]); - return; - } - - if (self::updateFromNoScrape($data)) { - return; - } - - if (!empty($data['outbox'])) { - self::updateFromOutbox($data['outbox'], $data); - } elseif (!empty($data['poll']) && ($data['network'] == Protocol::ACTIVITYPUB)) { - self::updateFromOutbox($data['poll'], $data); - } elseif (!empty($data['poll'])) { - self::updateFromFeed($data); - } - } - - /** - * Update a global contact via the "noscrape" endpoint - * - * @param string $data Probing result - * - * @return bool 'true' if update was successful or the server was unreachable - */ - private static function updateFromNoScrape(array $data) - { - // Check the 'noscrape' endpoint when it is a Friendica server - $gserver = DBA::selectFirst('gserver', ['noscrape'], ["`nurl` = ? AND `noscrape` != ''", - Strings::normaliseLink($data['baseurl'])]); - if (!DBA::isResult($gserver)) { - return false; - } - - $curlResult = Network::curl($gserver['noscrape'] . '/' . $data['nick']); - - if ($curlResult->isSuccess() && !empty($curlResult->getBody())) { - $noscrape = json_decode($curlResult->getBody(), true); - if (!empty($noscrape) && !empty($noscrape['updated'])) { - $noscrape['updated'] = DateTimeFormat::utc($noscrape['updated'], DateTimeFormat::MYSQL); - $fields = ['last_contact' => DateTimeFormat::utcNow(), 'updated' => $noscrape['updated']]; - DBA::update('gcontact', $fields, ['nurl' => Strings::normaliseLink($data['url'])]); - return true; - } - } elseif ($curlResult->isTimeout()) { - // On a timeout return the existing value, but mark the contact as failure - $fields = ['last_failure' => DateTimeFormat::utcNow()]; - DBA::update('gcontact', $fields, ['nurl' => Strings::normaliseLink($data['url'])]); - return true; - } - return false; - } - - /** - * Update a global contact via an ActivityPub Outbox - * - * @param string $feed - * @param array $data Probing result - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - private static function updateFromOutbox(string $feed, array $data) - { - $outbox = ActivityPub::fetchContent($feed); - if (empty($outbox)) { - return; - } - - if (!empty($outbox['orderedItems'])) { - $items = $outbox['orderedItems']; - } elseif (!empty($outbox['first']['orderedItems'])) { - $items = $outbox['first']['orderedItems']; - } elseif (!empty($outbox['first']['href']) && ($outbox['first']['href'] != $feed)) { - self::updateFromOutbox($outbox['first']['href'], $data); - return; - } elseif (!empty($outbox['first'])) { - if (is_string($outbox['first']) && ($outbox['first'] != $feed)) { - self::updateFromOutbox($outbox['first'], $data); - } else { - Logger::warning('Unexpected data', ['outbox' => $outbox]); - } - return; - } else { - $items = []; - } - - $last_updated = ''; - foreach ($items as $activity) { - if (!empty($activity['published'])) { - $published = DateTimeFormat::utc($activity['published']); - } elseif (!empty($activity['object']['published'])) { - $published = DateTimeFormat::utc($activity['object']['published']); - } else { - continue; - } - - if ($last_updated < $published) { - $last_updated = $published; - } - } - - if (empty($last_updated)) { - return; - } - - $fields = ['last_contact' => DateTimeFormat::utcNow(), 'updated' => $last_updated]; - DBA::update('gcontact', $fields, ['nurl' => Strings::normaliseLink($data['url'])]); - } - - /** - * Update a global contact via an XML feed - * - * @param string $data Probing result - */ - private static function updateFromFeed(array $data) - { - // Search for the newest entry in the feed - $curlResult = Network::curl($data['poll']); - if (!$curlResult->isSuccess()) { - $fields = ['last_failure' => DateTimeFormat::utcNow()]; - DBA::update('gcontact', $fields, ['nurl' => Strings::normaliseLink($data['url'])]); - - Logger::info("Profile wasn't reachable (no feed)", ['url' => $data['url']]); - return; - } - - $doc = new DOMDocument(); - @$doc->loadXML($curlResult->getBody()); - - $xpath = new DOMXPath($doc); - $xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom'); - - $entries = $xpath->query('/atom:feed/atom:entry'); - - $last_updated = ''; - - foreach ($entries as $entry) { - $published_item = $xpath->query('atom:published/text()', $entry)->item(0); - $updated_item = $xpath->query('atom:updated/text()' , $entry)->item(0); - $published = !empty($published_item->nodeValue) ? DateTimeFormat::utc($published_item->nodeValue) : null; - $updated = !empty($updated_item->nodeValue) ? DateTimeFormat::utc($updated_item->nodeValue) : null; - - if (empty($published) || empty($updated)) { - Logger::notice('Invalid entry for XPath.', ['entry' => $entry, 'url' => $data['url']]); - continue; - } - - if ($last_updated < $published) { - $last_updated = $published; - } - - if ($last_updated < $updated) { - $last_updated = $updated; - } - } - - if (empty($last_updated)) { - return; - } - - $fields = ['last_contact' => DateTimeFormat::utcNow(), 'updated' => $last_updated]; - DBA::update('gcontact', $fields, ['nurl' => Strings::normaliseLink($data['url'])]); - } - /** - * Updates the gcontact entry from a given public contact id - * - * @param integer $cid contact id - * @return void - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function updateFromPublicContactID($cid) - { - self::updateFromPublicContact(['id' => $cid]); - } - - /** - * Updates the gcontact entry from a given public contact url - * - * @param string $url contact url - * @return integer gcontact id - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function updateFromPublicContactURL($url) - { - return self::updateFromPublicContact(['nurl' => Strings::normaliseLink($url)]); - } - - /** - * Helper function for updateFromPublicContactID and updateFromPublicContactURL - * - * @param array $condition contact condition - * @return integer gcontact id - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - private static function updateFromPublicContact($condition) - { - $fields = ['name', 'nick', 'url', 'nurl', 'location', 'about', 'keywords', - 'bd', 'contact-type', 'network', 'addr', 'notify', 'alias', 'archive', 'term-date', - 'created', 'updated', 'avatar', 'success_update', 'failure_update', 'forum', 'prv', - 'baseurl', 'sensitive', 'unsearchable']; - - $contact = DBA::selectFirst('contact', $fields, array_merge($condition, ['uid' => 0, 'network' => Protocol::FEDERATED])); - if (!DBA::isResult($contact)) { - return 0; - } - - $fields = ['name', 'nick', 'url', 'nurl', 'location', 'about', 'keywords', 'generation', - 'birthday', 'contact-type', 'network', 'addr', 'notify', 'alias', 'archived', 'archive_date', - 'created', 'updated', 'photo', 'last_contact', 'last_failure', 'community', 'connect', - 'server_url', 'nsfw', 'hide', 'id']; - - $old_gcontact = DBA::selectFirst('gcontact', $fields, ['nurl' => $contact['nurl']]); - $do_insert = !DBA::isResult($old_gcontact); - if ($do_insert) { - $old_gcontact = []; - } - - $gcontact = []; - - // These fields are identical in both contact and gcontact - $fields = ['name', 'nick', 'url', 'nurl', 'location', 'about', 'keywords', - 'contact-type', 'network', 'addr', 'notify', 'alias', 'created', 'updated']; - - foreach ($fields as $field) { - $gcontact[$field] = $contact[$field]; - } - - // These fields are having different names but the same content - $gcontact['server_url'] = $contact['baseurl'] ?? ''; // "baseurl" can be null, "server_url" not - $gcontact['nsfw'] = $contact['sensitive']; - $gcontact['hide'] = $contact['unsearchable']; - $gcontact['archived'] = $contact['archive']; - $gcontact['archive_date'] = $contact['term-date']; - $gcontact['birthday'] = $contact['bd']; - $gcontact['photo'] = $contact['avatar']; - $gcontact['last_contact'] = $contact['success_update']; - $gcontact['last_failure'] = $contact['failure_update']; - $gcontact['community'] = ($contact['forum'] || $contact['prv']); - - foreach (['last_contact', 'last_failure', 'updated'] as $field) { - if (!empty($old_gcontact[$field]) && ($old_gcontact[$field] >= $gcontact[$field])) { - unset($gcontact[$field]); - } - } - - if (!$gcontact['archived']) { - $gcontact['archive_date'] = DBA::NULL_DATETIME; - } - - if (!empty($old_gcontact['created']) && ($old_gcontact['created'] > DBA::NULL_DATETIME) - && ($old_gcontact['created'] <= $gcontact['created'])) { - unset($gcontact['created']); - } - - if (empty($gcontact['birthday']) && ($gcontact['birthday'] <= DBA::NULL_DATETIME)) { - unset($gcontact['birthday']); - } - - if (empty($old_gcontact['generation']) || ($old_gcontact['generation'] > 2)) { - $gcontact['generation'] = 2; // We fetched the data directly from the other server - } - - if (!$do_insert) { - DBA::update('gcontact', $gcontact, ['nurl' => $contact['nurl']], $old_gcontact); - return $old_gcontact['id']; - } elseif (!$gcontact['archived']) { - DBA::insert('gcontact', $gcontact); - return DBA::lastInsertId(); - } - } - - /** - * Updates the gcontact entry from probe - * - * @param string $url profile link - * @param boolean $force Optional forcing of network probing (otherwise we use the cached data) - * - * @return boolean 'true' when contact had been updated - * - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function updateFromProbe($url, $force = false) - { - $data = Probe::uri($url, $force); - - if (in_array($data['network'], [Protocol::PHANTOM])) { - $fields = ['last_failure' => DateTimeFormat::utcNow()]; - DBA::update('gcontact', $fields, ['nurl' => Strings::normaliseLink($url)]); - Logger::info('Invalid network for contact', ['url' => $data['url'], 'callstack' => System::callstack()]); - return false; - } - - $data['server_url'] = $data['baseurl']; - - self::update($data); - - // Set the date of the latest post - self::setLastUpdate($data, $force); - - return true; - } - - /** - * Update the gcontact entry for a given user id - * - * @param int $uid User ID - * @return bool - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function updateForUser($uid) - { - $profile = Profile::getByUID($uid); - if (empty($profile)) { - Logger::error('Cannot find profile', ['uid' => $uid]); - return false; - } - - $user = User::getOwnerDataById($uid); - if (empty($user)) { - Logger::error('Cannot find user', ['uid' => $uid]); - return false; - } - - $userdata = array_merge($profile, $user); - - $location = Profile::formatLocation( - ['locality' => $userdata['locality'], 'region' => $userdata['region'], 'country-name' => $userdata['country-name']] - ); - - $gcontact = ['name' => $userdata['name'], 'location' => $location, 'about' => $userdata['about'], - 'keywords' => $userdata['pub_keywords'], - 'birthday' => $userdata['dob'], 'photo' => $userdata['photo'], - "notify" => $userdata['notify'], 'url' => $userdata['url'], - "hide" => !$userdata['net-publish'], - 'nick' => $userdata['nickname'], 'addr' => $userdata['addr'], - "connect" => $userdata['addr'], "server_url" => DI::baseUrl(), - "generation" => 1, 'network' => Protocol::DFRN]; - - self::update($gcontact); - } - - /** - * Get the basepath for a given contact link - * - * @param string $url The gcontact link - * @param boolean $dont_update Don't update the contact - * - * @return string basepath - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function getBasepath($url, $dont_update = false) - { - $gcontact = DBA::selectFirst('gcontact', ['server_url'], ['nurl' => Strings::normaliseLink($url)]); - if (!empty($gcontact['server_url'])) { - return $gcontact['server_url']; - } elseif ($dont_update) { - return ''; - } - - self::updateFromProbe($url, true); - - // Fetch the result - $gcontact = DBA::selectFirst('gcontact', ['server_url'], ['nurl' => Strings::normaliseLink($url)]); - if (empty($gcontact['server_url'])) { - Logger::info('No baseurl for gcontact', ['url' => $url]); - return ''; - } - - Logger::info('Found baseurl for gcontact', ['url' => $url, 'baseurl' => $gcontact['server_url']]); - return $gcontact['server_url']; - } - - /** - * Fetches users of given GNU Social server - * - * If the "Statistics" addon is enabled (See http://gstools.org/ for details) we query user data with this. - * - * @param string $server Server address - * @return bool - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function fetchGsUsers($server) - { - Logger::info('Fetching users from GNU Social server', ['server' => $server]); - - $url = $server . '/main/statistics'; - - $curlResult = Network::curl($url); - if (!$curlResult->isSuccess()) { - return false; - } - - $statistics = json_decode($curlResult->getBody()); - - if (!empty($statistics->config->instance_address)) { - if (!empty($statistics->config->instance_with_ssl)) { - $server = 'https://'; - } else { - $server = 'http://'; - } - - $server .= $statistics->config->instance_address; - - $hostname = $statistics->config->instance_address; - } elseif (!empty($statistics->instance_address)) { - if (!empty($statistics->instance_with_ssl)) { - $server = 'https://'; - } else { - $server = 'http://'; - } - - $server .= $statistics->instance_address; - - $hostname = $statistics->instance_address; - } - - if (!empty($statistics->users)) { - foreach ($statistics->users as $nick => $user) { - $profile_url = $server . '/' . $user->nickname; - - $contact = ['url' => $profile_url, - 'name' => $user->fullname, - 'addr' => $user->nickname . '@' . $hostname, - 'nick' => $user->nickname, - "network" => Protocol::OSTATUS, - 'photo' => DI::baseUrl() . '/images/person-300.jpg']; - - if (isset($user->bio)) { - $contact['about'] = $user->bio; - } - - self::getId($contact); - } - } - } - - /** - * Asking GNU Social server on a regular base for their user data - * - * @return void - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function discoverGsUsers() - { - $requery_days = intval(DI::config()->get('system', 'poco_requery_days')); - - $last_update = date("c", time() - (60 * 60 * 24 * $requery_days)); - - $r = DBA::select('gserver', ['nurl', 'url'], [ - '`network` = ? - AND `last_contact` >= `last_failure` - AND `last_poco_query` < ?', - Protocol::OSTATUS, - $last_update - ], [ - 'limit' => 5, - 'order' => ['RAND()'] - ]); - - if (!DBA::isResult($r)) { - return; - } - - foreach ($r as $server) { - self::fetchGsUsers($server['url']); - DBA::update('gserver', ['last_poco_query' => DateTimeFormat::utcNow()], ['nurl' => $server['nurl']]); - } - } - - /** - * Fetches the followers of a given profile and adds them - * - * @param string $url URL of a profile - * @return void - */ - public static function discoverFollowers(string $url) - { - $gcontact = DBA::selectFirst('gcontact', ['id', 'last_discovery'], ['nurl' => Strings::normaliseLink(($url))]); - if (!DBA::isResult($gcontact)) { - return; - } - - if ($gcontact['last_discovery'] > DateTimeFormat::utc('now - 1 month')) { - Logger::info('Last discovery was less then a month before.', ['url' => $url, 'discovery' => $gcontact['last_discovery']]); - return; - } - - $gcid = $gcontact['id']; - - $apcontact = APContact::getByURL($url); - - if (!empty($apcontact['followers']) && is_string($apcontact['followers'])) { - $followers = ActivityPub::fetchItems($apcontact['followers']); - } else { - $followers = []; - } - - if (!empty($apcontact['following']) && is_string($apcontact['following'])) { - $followings = ActivityPub::fetchItems($apcontact['following']); - } else { - $followings = []; - } - - if (!empty($followers) || !empty($followings)) { - if (!empty($followers)) { - // Clear the follower list, since it will be recreated in the next step - DBA::update('gfollower', ['deleted' => true], ['gcid' => $gcid]); - } - - $contacts = []; - foreach (array_merge($followers, $followings) as $contact) { - if (is_string($contact)) { - $contacts[] = $contact; - } elseif (!empty($contact['url']) && is_string($contact['url'])) { - $contacts[] = $contact['url']; - } - } - $contacts = array_unique($contacts); - - Logger::info('Discover AP contacts', ['url' => $url, 'contacts' => count($contacts)]); - foreach ($contacts as $contact) { - $gcontact = DBA::selectFirst('gcontact', ['id'], ['nurl' => Strings::normaliseLink(($contact))]); - if (DBA::isResult($gcontact)) { - $fields = []; - if (in_array($contact, $followers)) { - $fields = ['gcid' => $gcid, 'follower-gcid' => $gcontact['id']]; - } elseif (in_array($contact, $followings)) { - $fields = ['gcid' => $gcontact['id'], 'follower-gcid' => $gcid]; - } - - if (!empty($fields)) { - Logger::info('Set relation between contacts', $fields); - DBA::update('gfollower', ['deleted' => false], $fields, true); - continue; - } - } - - if (!Network::isUrlBlocked($contact)) { - Logger::info('Discover new AP contact', ['url' => $contact]); - Worker::add(PRIORITY_LOW, 'UpdateGContact', $contact, 'nodiscover'); - } else { - Logger::info('No discovery, the URL is blocked.', ['url' => $contact]); - } - } - if (!empty($followers)) { - // Delete all followers that aren't undeleted - DBA::delete('gfollower', ['gcid' => $gcid, 'deleted' => true]); - } - - DBA::update('gcontact', ['last_discovery' => DateTimeFormat::utcNow()], ['id' => $gcid]); - Logger::info('AP contacts discovery finished, last discovery set', ['url' => $url]); - return; - } - - $data = Probe::uri($url); - if (empty($data['poco'])) { - return; - } - - $curlResult = Network::curl($data['poco']); - if (!$curlResult->isSuccess()) { - return; - } - $poco = json_decode($curlResult->getBody(), true); - if (empty($poco['entry'])) { - return; - } - - Logger::info('PoCo Discovery started', ['url' => $url, 'contacts' => count($poco['entry'])]); - - foreach ($poco['entry'] as $entries) { - if (!empty($entries['urls'])) { - foreach ($entries['urls'] as $entry) { - if ($entry['type'] == 'profile') { - if (DBA::exists('gcontact', ['nurl' => Strings::normaliseLink(($entry['value']))])) { - continue; - } - if (!Network::isUrlBlocked($entry['value'])) { - Logger::info('Discover new PoCo contact', ['url' => $entry['value']]); - Worker::add(PRIORITY_LOW, 'UpdateGContact', $entry['value'], 'nodiscover'); - } else { - Logger::info('No discovery, the URL is blocked.', ['url' => $entry['value']]); - } - } - } - } - } - - DBA::update('gcontact', ['last_discovery' => DateTimeFormat::utcNow()], ['id' => $gcid]); - Logger::info('PoCo Discovery finished', ['url' => $url]); - } - - /** - * Returns a random, global contact of the current node - * - * @return string The profile URL - * @throws Exception - */ - public static function getRandomUrl() - { - $r = DBA::selectFirst('gcontact', ['url'], [ - '`network` = ? - AND `last_contact` >= `last_failure` - AND `updated` > ?', - Protocol::DFRN, - DateTimeFormat::utc('now - 1 month'), - ], ['order' => ['RAND()']]); - - if (DBA::isResult($r)) { - return $r['url']; - } - - return ''; - } -} diff --git a/src/Model/GServer.php b/src/Model/GServer.php index bc189af9d6..ca939c176c 100644 --- a/src/Model/GServer.php +++ b/src/Model/GServer.php @@ -23,20 +23,21 @@ namespace Friendica\Model; use DOMDocument; use DOMXPath; +use Exception; +use Friendica\Core\Logger; use Friendica\Core\Protocol; +use Friendica\Core\System; use Friendica\Core\Worker; +use Friendica\Database\Database; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Module\Register; use Friendica\Network\CurlResult; -use Friendica\Util\Network; +use Friendica\Protocol\Relay; use Friendica\Util\DateTimeFormat; +use Friendica\Util\Network; use Friendica\Util\Strings; use Friendica\Util\XML; -use Friendica\Core\Logger; -use Friendica\Protocol\PortableContact; -use Friendica\Protocol\Diaspora; -use Friendica\Network\Probe; /** * This class handles GServer related functions @@ -47,6 +48,74 @@ class GServer const DT_NONE = 0; const DT_POCO = 1; const DT_MASTODON = 2; + + // Methods to detect server types + + // Non endpoint specific methods + const DETECT_MANUAL = 0; + const DETECT_HEADER = 1; + const DETECT_BODY = 2; + + // Implementation specific endpoints + const DETECT_FRIENDIKA = 10; + const DETECT_FRIENDICA = 11; + const DETECT_STATUSNET = 12; + const DETECT_GNUSOCIAL = 13; + const DETECT_CONFIG_JSON = 14; // Statusnet, GNU Social, Older Hubzilla/Redmatrix + const DETECT_SITEINFO_JSON = 15; // Newer Hubzilla + const DETECT_MASTODON_API = 16; + const DETECT_STATUS_PHP = 17; // Nextcloud + const DETECT_V1_CONFIG = 18; + + // Standardized endpoints + const DETECT_STATISTICS_JSON = 100; + const DETECT_NODEINFO_1 = 101; + const DETECT_NODEINFO_2 = 102; + + /** + * Check for the existance of a server and adds it in the background if not existant + * + * @param string $url + * @param boolean $only_nodeinfo + * @return void + */ + public static function add(string $url, bool $only_nodeinfo = false) + { + if (self::getID($url, false)) { + return; + } + + Worker::add(PRIORITY_LOW, 'UpdateGServer', $url, $only_nodeinfo); + } + + /** + * Get the ID for the given server URL + * + * @param string $url + * @param boolean $no_check Don't check if the server hadn't been found + * @return int gserver id + */ + public static function getID(string $url, bool $no_check = false) + { + if (empty($url)) { + return null; + } + + $url = self::cleanURL($url); + + $gserver = DBA::selectFirst('gserver', ['id'], ['nurl' => Strings::normaliseLink($url)]); + if (DBA::isResult($gserver)) { + Logger::info('Got ID for URL', ['id' => $gserver['id'], 'url' => $url, 'callstack' => System::callstack(20)]); + return $gserver['id']; + } + + if ($no_check || !self::check($url)) { + return null; + } + + return self::getID($url, true); + } + /** * Checks if the given server is reachable * @@ -60,7 +129,10 @@ class GServer public static function reachable(string $profile, string $server = '', string $network = '', bool $force = false) { if ($server == '') { - $server = GContact::getBasepath($profile); + $contact = Contact::getByURL($profile, null, ['baseurl']); + if (!empty($contact['baseurl'])) { + $server = $contact['baseurl']; + } } if ($server == '') { @@ -70,76 +142,68 @@ class GServer return self::check($server, $network, $force); } - /** - * Decides if a server needs to be updated, based upon several date fields - * - * @param date $created Creation date of that server entry - * @param date $updated When had the server entry be updated - * @param date $last_failure Last failure when contacting that server - * @param date $last_contact Last time the server had been contacted - * - * @return boolean Does the server record needs an update? - */ - public static function updateNeeded($created, $updated, $last_failure, $last_contact) + public static function getNextUpdateDate(bool $success, string $created = '', string $last_contact = '') { + // On successful contact process check again next week + if ($success) { + return DateTimeFormat::utc('now +7 day'); + } + $now = strtotime(DateTimeFormat::utcNow()); - if ($updated > $last_contact) { - $contact_time = strtotime($updated); + if ($created > $last_contact) { + $contact_time = strtotime($created); } else { $contact_time = strtotime($last_contact); } - $failure_time = strtotime($last_failure); - $created_time = strtotime($created); - - // If there is no "created" time then use the current time - if ($created_time <= 0) { - $created_time = $now; + // If the last contact was less than 6 hours before then try again in 6 hours + if (($now - $contact_time) < (60 * 60 * 6)) { + return DateTimeFormat::utc('now +6 hour'); } - // If the last contact was less than 24 hours then don't update + // If the last contact was less than 12 hours before then try again in 12 hours + if (($now - $contact_time) < (60 * 60 * 12)) { + return DateTimeFormat::utc('now +12 hour'); + } + + // If the last contact was less than 24 hours before then try tomorrow again if (($now - $contact_time) < (60 * 60 * 24)) { - return false; + return DateTimeFormat::utc('now +1 day'); + } + + // If the last contact was less than a week before then try again in a week + if (($now - $contact_time) < (60 * 60 * 24 * 7)) { + return DateTimeFormat::utc('now +1 week'); } - // If the last failure was less than 24 hours then don't update - if (($now - $failure_time) < (60 * 60 * 24)) { - return false; + // If the last contact was less than two weeks before then try again in two week + if (($now - $contact_time) < (60 * 60 * 24 * 14)) { + return DateTimeFormat::utc('now +2 week'); } - // If the last contact was less than a week ago and the last failure is older than a week then don't update - //if ((($now - $contact_time) < (60 * 60 * 24 * 7)) && ($contact_time > $failure_time)) - // return false; - - // If the last contact time was more than a week ago and the contact was created more than a week ago, then only try once a week - if ((($now - $contact_time) > (60 * 60 * 24 * 7)) && (($now - $created_time) > (60 * 60 * 24 * 7)) && (($now - $failure_time) < (60 * 60 * 24 * 7))) { - return false; + // If the last contact was less than a month before then try again in a month + if (($now - $contact_time) < (60 * 60 * 24 * 30)) { + return DateTimeFormat::utc('now +1 month'); } - // If the last contact time was more than a month ago and the contact was created more than a month ago, then only try once a month - if ((($now - $contact_time) > (60 * 60 * 24 * 30)) && (($now - $created_time) > (60 * 60 * 24 * 30)) && (($now - $failure_time) < (60 * 60 * 24 * 30))) { - return false; - } - - return true; + // The system hadn't been successul contacted for more than a month, so try again in three months + return DateTimeFormat::utc('now +3 month'); } /** * Checks the state of the given server. * - * @param string $server_url URL of the given server - * @param string $network Network value that is used, when detection failed - * @param boolean $force Force an update. + * @param string $server_url URL of the given server + * @param string $network Network value that is used, when detection failed + * @param boolean $force Force an update. + * @param boolean $only_nodeinfo Only use nodeinfo for server detection * * @return boolean 'true' if server seems vital */ - public static function check(string $server_url, string $network = '', bool $force = false) + public static function check(string $server_url, string $network = '', bool $force = false, bool $only_nodeinfo = false) { - // Unify the server address - $server_url = trim($server_url, '/'); - $server_url = str_replace('/index.php', '', $server_url); - + $server_url = self::cleanURL($server_url); if ($server_url == '') { return false; } @@ -152,69 +216,127 @@ class GServer DBA::update('gserver', $fields, $condition); } - $last_contact = $gserver['last_contact']; - $last_failure = $gserver['last_failure']; - - // See discussion under https://forum.friendi.ca/display/0b6b25a8135aabc37a5a0f5684081633 - // It can happen that a zero date is in the database, but storing it again is forbidden. - if ($last_contact < DBA::NULL_DATETIME) { - $last_contact = DBA::NULL_DATETIME; - } - - if ($last_failure < DBA::NULL_DATETIME) { - $last_failure = DBA::NULL_DATETIME; - } - - if (!$force && !self::updateNeeded($gserver['created'], '', $last_failure, $last_contact)) { + if (!$force && (strtotime($gserver['next_contact']) > time())) { Logger::info('No update needed', ['server' => $server_url]); - return ($last_contact >= $last_failure); + return (!$gserver['failed']); } - Logger::info('Server is outdated. Start discovery.', ['Server' => $server_url, 'Force' => $force, 'Created' => $gserver['created'], 'Failure' => $last_failure, 'Contact' => $last_contact]); + Logger::info('Server is outdated. Start discovery.', ['Server' => $server_url, 'Force' => $force]); } else { Logger::info('Server is unknown. Start discovery.', ['Server' => $server_url]); } - return self::detect($server_url, $network); + return self::detect($server_url, $network, $only_nodeinfo); + } + + /** + * Set failed server status + * + * @param string $url + */ + public static function setFailure(string $url) + { + $gserver = DBA::selectFirst('gserver', [], ['nurl' => Strings::normaliseLink($url)]); + if (DBA::isResult($gserver)) { + $next_update = self::getNextUpdateDate(false, $gserver['created'], $gserver['last_contact']); + DBA::update('gserver', ['failed' => true, 'last_failure' => DateTimeFormat::utcNow(), + 'next_contact' => $next_update, 'detection-method' => null], + ['nurl' => Strings::normaliseLink($url)]); + Logger::info('Set failed status for existing server', ['url' => $url]); + return; + } + DBA::insert('gserver', ['url' => $url, 'nurl' => Strings::normaliseLink($url), + 'network' => Protocol::PHANTOM, 'created' => DateTimeFormat::utcNow(), + 'failed' => true, 'last_failure' => DateTimeFormat::utcNow()]); + Logger::info('Set failed status for new server', ['url' => $url]); + } + + /** + * Remove unwanted content from the given URL + * + * @param string $url + * @return string cleaned URL + */ + public static function cleanURL(string $url) + { + $url = trim($url, '/'); + $url = str_replace('/index.php', '', $url); + + $urlparts = parse_url($url); + unset($urlparts['user']); + unset($urlparts['pass']); + unset($urlparts['query']); + unset($urlparts['fragment']); + return Network::unparseURL($urlparts); + } + + /** + * Return the base URL + * + * @param string $url + * @return string base URL + */ + private static function getBaseURL(string $url) + { + $urlparts = parse_url(self::cleanURL($url)); + unset($urlparts['path']); + return Network::unparseURL($urlparts); } /** * Detect server data (type, protocol, version number, ...) * The detected data is then updated or inserted in the gserver table. * - * @param string $url URL of the given server - * @param string $network Network value that is used, when detection failed + * @param string $url URL of the given server + * @param string $network Network value that is used, when detection failed + * @param boolean $only_nodeinfo Only use nodeinfo for server detection * * @return boolean 'true' if server could be detected */ - public static function detect(string $url, string $network = '') + public static function detect(string $url, string $network = '', bool $only_nodeinfo = false) { Logger::info('Detect server type', ['server' => $url]); - $serverdata = []; + $serverdata = ['detection-method' => self::DETECT_MANUAL]; $original_url = $url; // Remove URL content that is not supposed to exist for a server url - $urlparts = parse_url($url); - unset($urlparts['user']); - unset($urlparts['pass']); - unset($urlparts['query']); - unset($urlparts['fragment']); - $url = Network::unparseURL($urlparts); + $url = self::cleanURL($url); + + // Get base URL + $baseurl = self::getBaseURL($url); // If the URL missmatches, then we mark the old entry as failure if ($url != $original_url) { - DBA::update('gserver', ['last_failure' => DateTimeFormat::utcNow()], ['nurl' => Strings::normaliseLink($original_url)]); + /// @todo What to do with "next_contact" here? + DBA::update('gserver', ['failed' => true, 'last_failure' => DateTimeFormat::utcNow()], + ['nurl' => Strings::normaliseLink($original_url)]); } // When a nodeinfo is present, we don't need to dig further $xrd_timeout = DI::config()->get('system', 'xrd_timeout'); - $curlResult = Network::curl($url . '/.well-known/nodeinfo', false, ['timeout' => $xrd_timeout]); + $curlResult = DI::httpRequest()->get($url . '/.well-known/nodeinfo', ['timeout' => $xrd_timeout]); if ($curlResult->isTimeout()) { - DBA::update('gserver', ['last_failure' => DateTimeFormat::utcNow()], ['nurl' => Strings::normaliseLink($url)]); + self::setFailure($url); return false; } + // On a redirect follow the new host but mark the old one as failure + if ($curlResult->isSuccess() && (parse_url($url, PHP_URL_HOST) != parse_url($curlResult->getRedirectUrl(), PHP_URL_HOST))) { + $curlResult = DI::httpRequest()->get($url, ['timeout' => $xrd_timeout]); + if (parse_url($url, PHP_URL_HOST) != parse_url($curlResult->getRedirectUrl(), PHP_URL_HOST)) { + Logger::info('Found redirect. Mark old entry as failure', ['old' => $url, 'new' => $curlResult->getRedirectUrl()]); + self::setFailure($url); + self::detect($curlResult->getRedirectUrl(), $network, $only_nodeinfo); + return false; + } + } + $nodeinfo = self::fetchNodeinfo($url, $curlResult); + if ($only_nodeinfo && empty($nodeinfo)) { + Logger::info('Invalid nodeinfo in nodeinfo-mode, server is marked as failure', ['url' => $url]); + self::setFailure($url); + return false; + } // When nodeinfo isn't present, we use the older 'statistics.json' endpoint if (empty($nodeinfo)) { @@ -224,18 +346,60 @@ class GServer // If that didn't work out well, we use some protocol specific endpoints // For Friendica and Zot based networks we have to dive deeper to reveal more details if (empty($nodeinfo['network']) || in_array($nodeinfo['network'], [Protocol::DFRN, Protocol::ZOT])) { + if (!empty($nodeinfo['detection-method'])) { + $serverdata['detection-method'] = $nodeinfo['detection-method']; + } + // Fetch the landing page, possibly it reveals some data if (empty($nodeinfo['network'])) { - $curlResult = Network::curl($url, false, ['timeout' => $xrd_timeout]); + if ($baseurl == $url) { + $basedata = $serverdata; + } else { + $basedata = ['detection-method' => self::DETECT_MANUAL]; + } + + $curlResult = DI::httpRequest()->get($baseurl, ['timeout' => $xrd_timeout]); if ($curlResult->isSuccess()) { - $serverdata = self::analyseRootHeader($curlResult, $serverdata); - $serverdata = self::analyseRootBody($curlResult, $serverdata, $url); + if ((parse_url($baseurl, PHP_URL_HOST) != parse_url($curlResult->getRedirectUrl(), PHP_URL_HOST))) { + Logger::info('Found redirect. Mark old entry as failure', ['old' => $url, 'new' => $curlResult->getRedirectUrl()]); + self::setFailure($url); + self::detect($curlResult->getRedirectUrl(), $network, $only_nodeinfo); + return false; + } + + $basedata = self::analyseRootHeader($curlResult, $basedata); + $basedata = self::analyseRootBody($curlResult, $basedata, $baseurl); } if (!$curlResult->isSuccess() || empty($curlResult->getBody()) || self::invalidBody($curlResult->getBody())) { - DBA::update('gserver', ['last_failure' => DateTimeFormat::utcNow()], ['nurl' => Strings::normaliseLink($url)]); + self::setFailure($url); return false; } + + if ($baseurl == $url) { + $serverdata = $basedata; + } else { + // When the base path doesn't seem to contain a social network we try the complete path. + // Most detectable system have to be installed in the root directory. + // We checked the base to avoid false positives. + $curlResult = DI::httpRequest()->get($url, ['timeout' => $xrd_timeout]); + if ($curlResult->isSuccess()) { + $urldata = self::analyseRootHeader($curlResult, $serverdata); + $urldata = self::analyseRootBody($curlResult, $urldata, $url); + + $comparebase = $basedata; + unset($comparebase['info']); + unset($comparebase['site_name']); + $compareurl = $urldata; + unset($compareurl['info']); + unset($compareurl['site_name']); + + // We assume that no one will install the identical system in the root and a subfolder + if (!empty(array_diff($comparebase, $compareurl))) { + $serverdata = $urldata; + } + } + } } if (empty($serverdata['network']) || ($serverdata['network'] == Protocol::ACTIVITYPUB)) { @@ -246,7 +410,7 @@ class GServer // With this check we don't have to waste time and ressources for dead systems. // Also this hopefully prevents us from receiving abuse messages. if (empty($serverdata['network']) && !self::validHostMeta($url)) { - DBA::update('gserver', ['last_failure' => DateTimeFormat::utcNow()], ['nurl' => Strings::normaliseLink($url)]); + self::setFailure($url); return false; } @@ -264,6 +428,10 @@ class GServer $serverdata = self::detectHubzilla($url, $serverdata); } + if (empty($serverdata['network']) || in_array($serverdata['detection-method'], [self::DETECT_MANUAL, self::DETECT_BODY])) { + $serverdata = self::detectPeertube($url, $serverdata); + } + if (empty($serverdata['network'])) { $serverdata = self::detectNextcloud($url, $serverdata); } @@ -271,6 +439,8 @@ class GServer if (empty($serverdata['network'])) { $serverdata = self::detectGNUSocial($url, $serverdata); } + + $serverdata = array_merge($nodeinfo, $serverdata); } else { $serverdata = $nodeinfo; } @@ -301,22 +471,21 @@ class GServer $registeredUsers = 1; } - if ($serverdata['network'] != Protocol::PHANTOM) { - $gcontacts = DBA::count('gcontact', ['server_url' => [$url, $serverdata['nurl']]]); - $apcontacts = DBA::count('apcontact', ['baseurl' => [$url, $serverdata['nurl']]]); - $contacts = DBA::count('contact', ['uid' => 0, 'baseurl' => [$url, $serverdata['nurl']]]); - $serverdata['registered-users'] = max($gcontacts, $apcontacts, $contacts, $registeredUsers); - } else { - $serverdata['registered-users'] = $registeredUsers; + if ($serverdata['network'] == Protocol::PHANTOM) { + $serverdata['registered-users'] = max($registeredUsers, 1); $serverdata = self::detectNetworkViaContacts($url, $serverdata); } + $serverdata['next_contact'] = self::getNextUpdateDate(true); + $serverdata['last_contact'] = DateTimeFormat::utcNow(); + $serverdata['failed'] = false; $gserver = DBA::selectFirst('gserver', ['network'], ['nurl' => Strings::normaliseLink($url)]); if (!DBA::isResult($gserver)) { $serverdata['created'] = DateTimeFormat::utcNow(); $ret = DBA::insert('gserver', $serverdata); + $id = DBA::lastInsertId(); } else { // Don't override the network with 'unknown' when there had been a valid entry before if (($serverdata['network'] == Protocol::PHANTOM) && !empty($gserver['network'])) { @@ -324,11 +493,25 @@ class GServer } $ret = DBA::update('gserver', $serverdata, ['nurl' => $serverdata['nurl']]); + $gserver = DBA::selectFirst('gserver', ['id'], ['nurl' => $serverdata['nurl']]); + if (DBA::isResult($gserver)) { + $id = $gserver['id']; + } + } + + if (!empty($serverdata['network']) && !empty($id) && ($serverdata['network'] != Protocol::PHANTOM)) { + $apcontacts = DBA::count('apcontact', ['gsid' => $id]); + $contacts = DBA::count('contact', ['uid' => 0, 'gsid' => $id]); + $max_users = max($apcontacts, $contacts, $registeredUsers, 1); + if ($max_users > $registeredUsers) { + Logger::info('Update registered users', ['id' => $id, 'url' => $serverdata['nurl'], 'registered-users' => $max_users]); + DBA::update('gserver', ['registered-users' => $max_users], ['id' => $id]); + } } if (!empty($serverdata['network']) && in_array($serverdata['network'], [Protocol::DFRN, Protocol::DIASPORA])) { - self::discoverRelay($url); - } + self::discoverRelay($url); + } return $ret; } @@ -343,7 +526,7 @@ class GServer { Logger::info('Discover relay data', ['server' => $server_url]); - $curlResult = Network::curl($server_url . '/.well-known/x-social-relay'); + $curlResult = DI::httpRequest()->get($server_url . '/.well-known/x-social-relay'); if (!$curlResult->isSuccess()) { return; } @@ -353,7 +536,16 @@ class GServer return; } - $gserver = DBA::selectFirst('gserver', ['id', 'relay-subscribe', 'relay-scope'], ['nurl' => Strings::normaliseLink($server_url)]); + // Sanitize incoming data, see https://github.com/friendica/friendica/issues/8565 + $data['subscribe'] = (bool)$data['subscribe'] ?? false; + + if (!$data['subscribe'] || empty($data['scope']) || !in_array(strtolower($data['scope']), ['all', 'tags'])) { + $data['scope'] = ''; + $data['subscribe'] = false; + $data['tags'] = []; + } + + $gserver = DBA::selectFirst('gserver', ['id', 'url', 'network', 'relay-subscribe', 'relay-scope'], ['nurl' => Strings::normaliseLink($server_url)]); if (!DBA::isResult($gserver)) { return; } @@ -376,7 +568,7 @@ class GServer } foreach ($tags as $tag) { - DBA::insert('gserver-tag', ['gserver-id' => $gserver['id'], 'tag' => $tag], true); + DBA::insert('gserver-tag', ['gserver-id' => $gserver['id'], 'tag' => $tag], Database::INSERT_IGNORE); } } @@ -402,8 +594,22 @@ class GServer $fields['batch'] = $data['protocols']['dfrn']; } } + + if (isset($data['protocols']['activitypub'])) { + $fields['network'] = Protocol::ACTIVITYPUB; + + if (!empty($data['protocols']['activitypub']['actor'])) { + $fields['url'] = $data['protocols']['activitypub']['actor']; + } + if (!empty($data['protocols']['activitypub']['receive'])) { + $fields['batch'] = $data['protocols']['activitypub']['receive']; + } + } } - Diaspora::setRelayContact($server_url, $fields); + + Logger::info('Discovery ended', ['server' => $server_url, 'data' => $fields]); + + Relay::updateContact($gserver, $fields); } /** @@ -415,7 +621,7 @@ class GServer */ private static function fetchStatistics(string $url) { - $curlResult = Network::curl($url . '/statistics.json'); + $curlResult = DI::httpRequest()->get($url . '/statistics.json'); if (!$curlResult->isSuccess()) { return []; } @@ -425,7 +631,7 @@ class GServer return []; } - $serverdata = []; + $serverdata = ['detection-method' => self::DETECT_STATISTICS_JSON]; if (!empty($data['version'])) { $serverdata['version'] = $data['version']; @@ -472,6 +678,10 @@ class GServer */ private static function fetchNodeinfo(string $url, CurlResult $curlResult) { + if (!$curlResult->isSuccess()) { + return []; + } + $nodeinfo = json_decode($curlResult->getBody(), true); if (!is_array($nodeinfo) || empty($nodeinfo['links'])) { @@ -521,7 +731,7 @@ class GServer */ private static function parseNodeinfo1(string $nodeinfo_url) { - $curlResult = Network::curl($nodeinfo_url); + $curlResult = DI::httpRequest()->get($nodeinfo_url); if (!$curlResult->isSuccess()) { return []; @@ -533,9 +743,8 @@ class GServer return []; } - $server = []; - - $server['register_policy'] = Register::CLOSED; + $server = ['detection-method' => self::DETECT_NODEINFO_1, + 'register_policy' => Register::CLOSED]; if (!empty($nodeinfo['openRegistrations'])) { $server['register_policy'] = Register::OPEN; @@ -559,7 +768,7 @@ class GServer } if (!empty($nodeinfo['usage']['users']['total'])) { - $server['registered-users'] = $nodeinfo['usage']['users']['total']; + $server['registered-users'] = max($nodeinfo['usage']['users']['total'], 1); } if (!empty($nodeinfo['protocols']['inbound']) && is_array($nodeinfo['protocols']['inbound'])) { @@ -599,7 +808,7 @@ class GServer */ private static function parseNodeinfo2(string $nodeinfo_url) { - $curlResult = Network::curl($nodeinfo_url); + $curlResult = DI::httpRequest()->get($nodeinfo_url); if (!$curlResult->isSuccess()) { return []; } @@ -610,9 +819,8 @@ class GServer return []; } - $server = []; - - $server['register_policy'] = Register::CLOSED; + $server = ['detection-method' => self::DETECT_NODEINFO_2, + 'register_policy' => Register::CLOSED]; if (!empty($nodeinfo['openRegistrations'])) { $server['register_policy'] = Register::OPEN; @@ -636,7 +844,7 @@ class GServer } if (!empty($nodeinfo['usage']['users']['total'])) { - $server['registered-users'] = $nodeinfo['usage']['users']['total']; + $server['registered-users'] = max($nodeinfo['usage']['users']['total'], 1); } if (!empty($nodeinfo['protocols'])) { @@ -677,7 +885,7 @@ class GServer */ private static function fetchSiteinfo(string $url, array $serverdata) { - $curlResult = Network::curl($url . '/siteinfo.json'); + $curlResult = DI::httpRequest()->get($url . '/siteinfo.json'); if (!$curlResult->isSuccess()) { return $serverdata; } @@ -687,6 +895,10 @@ class GServer return $serverdata; } + if (in_array($serverdata['detection-method'], [self::DETECT_HEADER, self::DETECT_BODY, self::DETECT_MANUAL])) { + $serverdata['detection-method'] = self::DETECT_SITEINFO_JSON; + } + if (!empty($data['url'])) { $serverdata['platform'] = strtolower($data['platform']); $serverdata['version'] = $data['version']; @@ -709,7 +921,7 @@ class GServer } if (!empty($data['channels_total'])) { - $serverdata['registered-users'] = $data['channels_total']; + $serverdata['registered-users'] = max($data['channels_total'], 1); } if (!empty($data['register_policy'])) { @@ -742,7 +954,7 @@ class GServer private static function validHostMeta(string $url) { $xrd_timeout = DI::config()->get('system', 'xrd_timeout'); - $curlResult = Network::curl($url . '/.well-known/host-meta', false, ['timeout' => $xrd_timeout]); + $curlResult = DI::httpRequest()->get($url . '/.well-known/host-meta', ['timeout' => $xrd_timeout]); if (!$curlResult->isSuccess()) { return false; } @@ -789,12 +1001,6 @@ class GServer { $contacts = []; - $gcontacts = DBA::select('gcontact', ['url', 'nurl'], ['server_url' => [$url, $serverdata['nurl']]]); - while ($gcontact = DBA::fetch($gcontacts)) { - $contacts[$gcontact['nurl']] = $gcontact['url']; - } - DBA::close($gcontacts); - $apcontacts = DBA::select('apcontact', ['url'], ['baseurl' => [$url, $serverdata['nurl']]]); while ($apcontact = DBA::fetch($apcontacts)) { $contacts[Strings::normaliseLink($apcontact['url'])] = $apcontact['url']; @@ -812,14 +1018,14 @@ class GServer } foreach ($contacts as $contact) { - $probed = Probe::uri($contact); - if (in_array($probed['network'], Protocol::FEDERATED)) { + $probed = Contact::getByURL($contact); + if (!empty($probed) && in_array($probed['network'], Protocol::FEDERATED)) { $serverdata['network'] = $probed['network']; break; } } - $serverdata['registered-users'] = max($serverdata['registered-users'], count($contacts)); + $serverdata['registered-users'] = max($serverdata['registered-users'], count($contacts), 1); return $serverdata; } @@ -838,7 +1044,7 @@ class GServer { $serverdata['poco'] = ''; - $curlResult = Network::curl($url. '/poco'); + $curlResult = DI::httpRequest()->get($url . '/poco'); if (!$curlResult->isSuccess()) { return $serverdata; } @@ -850,7 +1056,7 @@ class GServer if (!empty($data['totalResults'])) { $registeredUsers = $serverdata['registered-users'] ?? 0; - $serverdata['registered-users'] = max($data['totalResults'], $registeredUsers); + $serverdata['registered-users'] = max($data['totalResults'], $registeredUsers, 1); $serverdata['directory-type'] = self::DT_POCO; $serverdata['poco'] = $url . '/poco'; } @@ -868,7 +1074,7 @@ class GServer */ public static function checkMastodonDirectory(string $url, array $serverdata) { - $curlResult = Network::curl($url . '/api/v1/directory?limit=1'); + $curlResult = DI::httpRequest()->get($url . '/api/v1/directory?limit=1'); if (!$curlResult->isSuccess()) { return $serverdata; } @@ -885,6 +1091,54 @@ class GServer return $serverdata; } + /** + * Detects Peertube via their known endpoint + * + * @param string $url URL of the given server + * @param array $serverdata array with server data + * + * @return array server data + */ + private static function detectPeertube(string $url, array $serverdata) + { + $curlResult = DI::httpRequest()->get($url . '/api/v1/config'); + + if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) { + return $serverdata; + } + + $data = json_decode($curlResult->getBody(), true); + if (empty($data)) { + return $serverdata; + } + + if (!empty($data['instance']) && !empty($data['serverVersion'])) { + $serverdata['platform'] = 'peertube'; + $serverdata['version'] = $data['serverVersion']; + $serverdata['network'] = Protocol::ACTIVITYPUB; + + if (!empty($data['instance']['name'])) { + $serverdata['site_name'] = $data['instance']['name']; + } + + if (!empty($data['instance']['shortDescription'])) { + $serverdata['info'] = $data['instance']['shortDescription']; + } + + if (!empty($data['signup'])) { + if (!empty($data['signup']['allowed'])) { + $serverdata['register_policy'] = Register::OPEN; + } + } + + if (in_array($serverdata['detection-method'], [self::DETECT_HEADER, self::DETECT_BODY, self::DETECT_MANUAL])) { + $serverdata['detection-method'] = self::DETECT_V1_CONFIG; + } + } + + return $serverdata; + } + /** * Detects the version number of a given server when it was a NextCloud installation * @@ -895,7 +1149,7 @@ class GServer */ private static function detectNextcloud(string $url, array $serverdata) { - $curlResult = Network::curl($url . '/status.php'); + $curlResult = DI::httpRequest()->get($url . '/status.php'); if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) { return $serverdata; @@ -910,6 +1164,10 @@ class GServer $serverdata['platform'] = 'nextcloud'; $serverdata['version'] = $data['version']; $serverdata['network'] = Protocol::ACTIVITYPUB; + + if (in_array($serverdata['detection-method'], [self::DETECT_HEADER, self::DETECT_BODY, self::DETECT_MANUAL])) { + $serverdata['detection-method'] = self::DETECT_STATUS_PHP; + } } return $serverdata; @@ -925,7 +1183,7 @@ class GServer */ private static function detectMastodonAlikes(string $url, array $serverdata) { - $curlResult = Network::curl($url . '/api/v1/instance'); + $curlResult = DI::httpRequest()->get($url . '/api/v1/instance'); if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) { return $serverdata; @@ -936,6 +1194,10 @@ class GServer return $serverdata; } + if (in_array($serverdata['detection-method'], [self::DETECT_HEADER, self::DETECT_BODY, self::DETECT_MANUAL])) { + $serverdata['detection-method'] = self::DETECT_MASTODON_API; + } + if (!empty($data['version'])) { $serverdata['platform'] = 'mastodon'; $serverdata['version'] = $data['version'] ?? ''; @@ -956,7 +1218,7 @@ class GServer } if (!empty($data['stats']['user_count'])) { - $serverdata['registered-users'] = $data['stats']['user_count']; + $serverdata['registered-users'] = max($data['stats']['user_count'], 1); } if (!empty($serverdata['version']) && preg_match('/.*?\(compatible;\s(.*)\s(.*)\)/ism', $serverdata['version'], $matches)) { @@ -987,13 +1249,13 @@ class GServer */ private static function detectHubzilla(string $url, array $serverdata) { - $curlResult = Network::curl($url . '/api/statusnet/config.json'); + $curlResult = DI::httpRequest()->get($url . '/api/statusnet/config.json'); if (!$curlResult->isSuccess() || ($curlResult->getBody() == '')) { return $serverdata; } $data = json_decode($curlResult->getBody(), true); - if (empty($data)) { + if (empty($data) || empty($data['site'])) { return $serverdata; } @@ -1041,11 +1303,16 @@ class GServer } if (!$closed && !$private and $inviteonly) { - $register_policy = Register::APPROVE; + $serverdata['register_policy'] = Register::APPROVE; } elseif (!$closed && !$private) { - $register_policy = Register::OPEN; + $serverdata['register_policy'] = Register::OPEN; } else { - $register_policy = Register::CLOSED; + $serverdata['register_policy'] = Register::CLOSED; + } + + if (!empty($serverdata['network']) && in_array($serverdata['detection-method'], + [self::DETECT_HEADER, self::DETECT_BODY, self::DETECT_MANUAL])) { + $serverdata['detection-method'] = self::DETECT_CONFIG_JSON; } return $serverdata; @@ -1080,7 +1347,7 @@ class GServer private static function detectGNUSocial(string $url, array $serverdata) { // Test for GNU Social - $curlResult = Network::curl($url . '/api/gnusocial/version.json'); + $curlResult = DI::httpRequest()->get($url . '/api/gnusocial/version.json'); if ($curlResult->isSuccess() && ($curlResult->getBody() != '{"error":"not implemented"}') && ($curlResult->getBody() != '') && (strlen($curlResult->getBody()) < 30)) { $serverdata['platform'] = 'gnusocial'; @@ -1089,11 +1356,16 @@ class GServer $serverdata['version'] = str_replace(["\r", "\n", "\t"], '', $serverdata['version']); $serverdata['version'] = trim($serverdata['version'], '"'); $serverdata['network'] = Protocol::OSTATUS; + + if (in_array($serverdata['detection-method'], [self::DETECT_HEADER, self::DETECT_BODY, self::DETECT_MANUAL])) { + $serverdata['detection-method'] = self::DETECT_GNUSOCIAL; + } + return $serverdata; } // Test for Statusnet - $curlResult = Network::curl($url . '/api/statusnet/version.json'); + $curlResult = DI::httpRequest()->get($url . '/api/statusnet/version.json'); if ($curlResult->isSuccess() && ($curlResult->getBody() != '{"error":"not implemented"}') && ($curlResult->getBody() != '') && (strlen($curlResult->getBody()) < 30)) { @@ -1110,6 +1382,10 @@ class GServer $serverdata['platform'] = 'statusnet'; $serverdata['network'] = Protocol::OSTATUS; } + + if (in_array($serverdata['detection-method'], [self::DETECT_HEADER, self::DETECT_BODY, self::DETECT_MANUAL])) { + $serverdata['detection-method'] = self::DETECT_STATUSNET; + } } return $serverdata; @@ -1125,9 +1401,14 @@ class GServer */ private static function detectFriendica(string $url, array $serverdata) { - $curlResult = Network::curl($url . '/friendica/json'); + $curlResult = DI::httpRequest()->get($url . '/friendica/json'); if (!$curlResult->isSuccess()) { - $curlResult = Network::curl($url . '/friendika/json'); + $curlResult = DI::httpRequest()->get($url . '/friendika/json'); + $friendika = true; + $platform = 'Friendika'; + } else { + $friendika = false; + $platform = 'Friendica'; } if (!$curlResult->isSuccess()) { @@ -1139,6 +1420,10 @@ class GServer return $serverdata; } + if (in_array($serverdata['detection-method'], [self::DETECT_HEADER, self::DETECT_BODY, self::DETECT_MANUAL])) { + $serverdata['detection-method'] = $friendika ? self::DETECT_FRIENDIKA : self::DETECT_FRIENDICA; + } + $serverdata['network'] = Protocol::DFRN; $serverdata['version'] = $data['version']; @@ -1174,7 +1459,7 @@ class GServer break; } - $serverdata['platform'] = strtolower($data['platform'] ?? ''); + $serverdata['platform'] = strtolower($data['platform'] ?? $platform); return $serverdata; } @@ -1222,7 +1507,8 @@ class GServer $serverdata['info'] = $attr['content']; } - if ($attr['name'] == 'application-name') { + if (in_array($attr['name'], ['application-name', 'al:android:app_name', 'al:ios:app_name', + 'twitter:app:name:googleplay', 'twitter:app:name:iphone', 'twitter:app:name:ipad'])) { $serverdata['platform'] = strtolower($attr['content']); if (in_array($attr['content'], ['Misskey', 'Write.as'])) { $serverdata['network'] = Protocol::ACTIVITYPUB; @@ -1234,7 +1520,7 @@ class GServer if (count($version_part) == 2) { if (in_array($version_part[0], ['WordPress'])) { - $serverdata['platform'] = strtolower($version_part[0]); + $serverdata['platform'] = 'wordpress'; $serverdata['version'] = $version_part[1]; // We still do need a reliable test if some AP plugin is activated @@ -1243,6 +1529,10 @@ class GServer } else { $serverdata['network'] = Protocol::FEED; } + + if ($serverdata['detection-method'] == self::DETECT_MANUAL) { + $serverdata['detection-method'] = self::DETECT_BODY; + } } if (in_array($version_part[0], ['Friendika', 'Friendica'])) { $serverdata['platform'] = strtolower($version_part[0]); @@ -1298,6 +1588,10 @@ class GServer } } + if (!empty($serverdata['network']) && ($serverdata['detection-method'] == self::DETECT_MANUAL)) { + $serverdata['detection-method'] = self::DETECT_BODY; + } + return $serverdata; } @@ -1313,16 +1607,23 @@ class GServer { if ($curlResult->getHeader('server') == 'Mastodon') { $serverdata['platform'] = 'mastodon'; - $serverdata['network'] = $network = Protocol::ACTIVITYPUB; + $serverdata['network'] = Protocol::ACTIVITYPUB; } elseif ($curlResult->inHeader('x-diaspora-version')) { $serverdata['platform'] = 'diaspora'; - $serverdata['network'] = $network = Protocol::DIASPORA; + $serverdata['network'] = Protocol::DIASPORA; $serverdata['version'] = $curlResult->getHeader('x-diaspora-version'); } elseif ($curlResult->inHeader('x-friendica-version')) { $serverdata['platform'] = 'friendica'; - $serverdata['network'] = $network = Protocol::DFRN; + $serverdata['network'] = Protocol::DFRN; $serverdata['version'] = $curlResult->getHeader('x-friendica-version'); + } else { + return $serverdata; } + + if ($serverdata['detection-method'] == self::DETECT_MANUAL) { + $serverdata['detection-method'] = self::DETECT_HEADER; + } + return $serverdata; } @@ -1339,20 +1640,6 @@ class GServer return !strpos($body, '>'); } - /** - * Update the user directory of a given gserver record - * - * @param array $gserver gserver record - */ - public static function updateDirectory(array $gserver) - { - /// @todo Add Mastodon API directory - - if (!empty($gserver['poco'])) { - PortableContact::discoverSingleServer($gserver['id']); - } - } - /** * Update GServer entries */ @@ -1371,25 +1658,24 @@ class GServer $last_update = date('c', time() - (60 * 60 * 24 * $requery_days)); - $gservers = DBA::p("SELECT `id`, `url`, `nurl`, `network`, `poco` + $gservers = DBA::p("SELECT `id`, `url`, `nurl`, `network`, `poco`, `directory-type` FROM `gserver` - WHERE `last_contact` >= `last_failure` - AND `poco` != '' + WHERE NOT `failed` + AND `directory-type` != ? AND `last_poco_query` < ? - ORDER BY RAND()", $last_update + ORDER BY RAND()", self::DT_NONE, $last_update ); while ($gserver = DBA::fetch($gservers)) { - if (!GServer::check($gserver['url'], $gserver['network'])) { - // The server is not reachable? Okay, then we will try it later - $fields = ['last_poco_query' => DateTimeFormat::utcNow()]; - DBA::update('gserver', $fields, ['nurl' => $gserver['nurl']]); - continue; - } + Logger::info('Update peer list', ['server' => $gserver['url'], 'id' => $gserver['id']]); + Worker::add(PRIORITY_LOW, 'UpdateServerPeers', $gserver['url']); Logger::info('Update directory', ['server' => $gserver['url'], 'id' => $gserver['id']]); Worker::add(PRIORITY_LOW, 'UpdateServerDirectory', $gserver); + $fields = ['last_poco_query' => DateTimeFormat::utcNow()]; + DBA::update('gserver', $fields, ['nurl' => $gserver['nurl']]); + if (--$no_of_queries == 0) { break; } @@ -1414,14 +1700,17 @@ class GServer } // Discover federated servers - $curlResult = Network::fetchUrl("http://the-federation.info/pods.json"); - - if (!empty($curlResult)) { - $servers = json_decode($curlResult, true); - - if (!empty($servers['pods'])) { - foreach ($servers['pods'] as $server) { - Worker::add(PRIORITY_LOW, 'UpdateGServer', 'https://' . $server['host']); + $protocols = ['activitypub', 'diaspora', 'dfrn', 'ostatus']; + foreach ($protocols as $protocol) { + $query = '{nodes(protocol:"' . $protocol . '"){host}}'; + $curlResult = DI::httpRequest()->fetch('https://the-federation.info/graphql?query=' . urlencode($query)); + if (!empty($curlResult)) { + $data = json_decode($curlResult, true); + if (!empty($data['data']['nodes'])) { + foreach ($data['data']['nodes'] as $server) { + // Using "only_nodeinfo" since servers that are listed on that page should always have it. + self::add('https://' . $server['host'], true); + } } } } @@ -1432,18 +1721,100 @@ class GServer if (!empty($accesstoken)) { $api = 'https://instances.social/api/1.0/instances/list?count=0'; $header = ['Authorization: Bearer '.$accesstoken]; - $curlResult = Network::curl($api, false, ['headers' => $header]); + $curlResult = DI::httpRequest()->get($api, ['header' => $header]); if ($curlResult->isSuccess()) { $servers = json_decode($curlResult->getBody(), true); foreach ($servers['instances'] as $server) { $url = (is_null($server['https_score']) ? 'http' : 'https') . '://' . $server['name']; - Worker::add(PRIORITY_LOW, 'UpdateGServer', $url); + self::add($url); } } } DI::config()->set('poco', 'last_federation_discovery', time()); } + + /** + * Set the protocol for the given server + * + * @param int $gsid Server id + * @param int $protocol Protocol id + * @return void + * @throws Exception + */ + public static function setProtocol(int $gsid, int $protocol) + { + if (empty($gsid)) { + return; + } + + $gserver = DBA::selectFirst('gserver', ['protocol', 'url'], ['id' => $gsid]); + if (!DBA::isResult($gserver)) { + return; + } + + $old = $gserver['protocol']; + + if (!is_null($old)) { + /* + The priority for the protocols is: + 1. ActivityPub + 2. DFRN via Diaspora + 3. Legacy DFRN + 4. Diaspora + 5. OStatus + */ + + // We don't need to change it when nothing is to be changed + if ($old == $protocol) { + return; + } + + // We don't want to mark a server as OStatus when it had been marked with any other protocol before + if ($protocol == Post\DeliveryData::OSTATUS) { + return; + } + + // If the server is marked as ActivityPub then we won't change it to anything different + if ($old == Post\DeliveryData::ACTIVITYPUB) { + return; + } + + // Don't change it to anything lower than DFRN if the new one wasn't ActivityPub + if (($old == Post\DeliveryData::DFRN) && ($protocol != Post\DeliveryData::ACTIVITYPUB)) { + return; + } + + // Don't change it to Diaspora when it is a legacy DFRN server + if (($old == Post\DeliveryData::LEGACY_DFRN) && ($protocol == Post\DeliveryData::DIASPORA)) { + return; + } + } + + Logger::info('Protocol for server', ['protocol' => $protocol, 'old' => $old, 'id' => $gsid, 'url' => $gserver['url']]); + DBA::update('gserver', ['protocol' => $protocol], ['id' => $gsid]); + } + + /** + * Fetch the protocol of the given server + * + * @param int $gsid Server id + * @return int + * @throws Exception + */ + public static function getProtocol(int $gsid) + { + if (empty($gsid)) { + return null; + } + + $gserver = DBA::selectFirst('gserver', ['protocol'], ['id' => $gsid]); + if (DBA::isResult($gserver)) { + return $gserver['protocol']; + } + + return null; + } } diff --git a/src/Model/Group.php b/src/Model/Group.php index b4dbb87d82..0a51518324 100644 --- a/src/Model/Group.php +++ b/src/Model/Group.php @@ -89,7 +89,7 @@ class Group $group = DBA::selectFirst('group', ['deleted'], ['id' => $gid]); if (DBA::isResult($group) && $group['deleted']) { DBA::update('group', ['deleted' => 0], ['id' => $gid]); - notice(DI::l10n()->t('A deleted group with this name was revived. Existing item permissions may apply to this group and any future members. If this is not what you intended, please create another group with a different name.') . EOL); + notice(DI::l10n()->t('A deleted group with this name was revived. Existing item permissions may apply to this group and any future members. If this is not what you intended, please create another group with a different name.')); } return true; } @@ -505,10 +505,17 @@ class Group $groupedit = null; } + if ($each == 'group') { + $count = DBA::count('group_member', ['gid' => $group['id']]); + $group_name = sprintf('%s (%d)', $group['name'], $count); + } else { + $group_name = $group['name']; + } + $display_groups[] = [ 'id' => $group['id'], 'cid' => $cid, - 'text' => $group['name'], + 'text' => $group_name, 'href' => $each . '/' . $group['id'], 'edit' => $groupedit, 'selected' => $selected, diff --git a/src/Model/Host.php b/src/Model/Host.php new file mode 100644 index 0000000000..15f75bd100 --- /dev/null +++ b/src/Model/Host.php @@ -0,0 +1,79 @@ +. + * + */ + +namespace Friendica\Model; + +use Friendica\Core\Logger; +use Friendica\Database\DBA; + +class Host +{ + /** + * Get the id for a given hostname + * When empty, the current hostname is used + * + * @param string $hostname + * + * @return integer host name id + * @throws \Exception + */ + public static function getId(string $hostname = '') + { + if (empty($hostname)) { + $hostname = php_uname('n'); + } + + $hostname = strtolower($hostname); + + $host = DBA::selectFirst('host', ['id'], ['name' => $hostname]); + if (!empty($host['id'])) { + return $host['id']; + } + + DBA::replace('host', ['name' => $hostname]); + + $host = DBA::selectFirst('host', ['id'], ['name' => $hostname]); + if (empty($host['id'])) { + Logger::warning('Host name could not be inserted', ['name' => $hostname]); + return 0; + } + + return $host['id']; + } + + /** + * Get the hostname for a given id + * + * @param int $id + * + * @return string host name + * @throws \Exception + */ + public static function getName(int $id) + { + $host = DBA::selectFirst('host', ['name'], ['id' => $id]); + if (!empty($host['name'])) { + return $host['name']; + } + + return ''; + } +} diff --git a/src/Model/Introduction.php b/src/Model/Introduction.php index 8b939aa2a5..aab4a9a8e4 100644 --- a/src/Model/Introduction.php +++ b/src/Model/Introduction.php @@ -164,19 +164,16 @@ class Introduction extends BaseModel } $contact = Contact::selectFirst([], ['id' => $this->{'contact-id'}, 'uid' => $this->uid]); + if (!empty($contact)) { + if (!empty($contact['protocol'])) { + $protocol = $contact['protocol']; + } else { + $protocol = $contact['network']; + } - if (!$contact) { - throw new HTTPException\NotFoundException('Contact record not found.'); - } - - if (!empty($contact['protocol'])) { - $protocol = $contact['protocol']; - } else { - $protocol = $contact['network']; - } - - if ($protocol == Protocol::ACTIVITYPUB) { - ActivityPub\Transmitter::sendContactReject($contact['url'], $contact['hub-verify'], $contact['uid']); + if ($protocol == Protocol::ACTIVITYPUB) { + ActivityPub\Transmitter::sendContactReject($contact['url'], $contact['hub-verify'], $contact['uid']); + } } return $this->intro->delete($this); diff --git a/src/Model/Item.php b/src/Model/Item.php index 17c841fc2f..e4266b41ee 100644 --- a/src/Model/Item.php +++ b/src/Model/Item.php @@ -29,23 +29,22 @@ use Friendica\Core\Protocol; use Friendica\Core\Renderer; use Friendica\Core\Session; use Friendica\Core\System; +use Friendica\Model\Tag; use Friendica\Core\Worker; use Friendica\Database\DBA; +use Friendica\Database\DBStructure; use Friendica\DI; -use Friendica\Model\Post\Category; +use Friendica\Model\Post; use Friendica\Protocol\Activity; use Friendica\Protocol\ActivityPub; use Friendica\Protocol\Diaspora; -use Friendica\Protocol\OStatus; use Friendica\Util\DateTimeFormat; use Friendica\Util\Map; use Friendica\Util\Network; -use Friendica\Util\Security; use Friendica\Util\Strings; -use Friendica\Util\XML; use Friendica\Worker\Delivery; -use Text_LanguageDetect; use Friendica\Repository\PermissionSet as RepPermissionSet; +use LanguageDetection\Language; class Item { @@ -58,17 +57,30 @@ class Item const PT_VIDEO = 18; const PT_DOCUMENT = 19; const PT_EVENT = 32; + const PT_TAG = 64; + const PT_TO = 65; + const PT_CC = 66; + const PT_BTO = 67; + const PT_BCC = 68; + const PT_FOLLOWER = 69; + const PT_ANNOUNCEMENT = 70; + const PT_COMMENT = 71; + const PT_STORED = 72; + const PT_GLOBAL = 73; + const PT_RELAY = 74; + const PT_FETCHED = 75; const PT_PERSONAL_NOTE = 128; // Field list that is used to display the items const DISPLAY_FIELDLIST = [ 'uid', 'id', 'parent', 'uri-id', 'uri', 'thr-parent', 'parent-uri', 'guid', 'network', 'gravity', 'commented', 'created', 'edited', 'received', 'verb', 'object-type', 'postopts', 'plink', - 'wall', 'private', 'starred', 'origin', 'title', 'body', 'file', 'attach', 'language', + 'wall', 'private', 'starred', 'origin', 'title', 'body', 'file', 'language', 'content-warning', 'location', 'coord', 'app', 'rendered-hash', 'rendered-html', 'object', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid', 'item_id', 'author-id', 'author-link', 'author-name', 'author-avatar', 'author-network', 'owner-id', 'owner-link', 'owner-name', 'owner-avatar', 'owner-network', + 'causer-id', 'causer-link', 'causer-name', 'causer-avatar', 'causer-contact-type', 'contact-id', 'contact-uid', 'contact-link', 'contact-name', 'contact-avatar', 'writable', 'self', 'cid', 'alias', 'pinned', 'event-id', 'event-created', 'event-edited', 'event-start', 'event-finish', @@ -81,7 +93,7 @@ class Item const DELIVER_FIELDLIST = ['uid', 'id', 'parent', 'uri-id', 'uri', 'thr-parent', 'parent-uri', 'guid', 'parent-guid', 'created', 'edited', 'verb', 'object-type', 'object', 'target', 'private', 'title', 'body', 'location', 'coord', 'app', - 'attach', 'deleted', 'extid', 'post-type', + 'deleted', 'extid', 'post-type', 'gravity', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid', 'author-id', 'author-link', 'owner-link', 'contact-uid', 'signed_text', 'signature', 'signer', 'network']; @@ -92,24 +104,24 @@ class Item 'object-type', 'object', 'target-type', 'target', 'plink']; // Field list for "item-content" table that is not present in the "item" table - const CONTENT_FIELDLIST = ['language']; + const CONTENT_FIELDLIST = ['language', 'raw-body']; // All fields in the item table const ITEM_FIELDLIST = ['id', 'uid', 'parent', 'uri', 'parent-uri', 'thr-parent', - 'guid', 'uri-id', 'parent-uri-id', 'thr-parent-id', - 'contact-id', 'type', 'wall', 'gravity', 'extid', 'icid', 'iaid', 'psid', + 'guid', 'uri-id', 'parent-uri-id', 'thr-parent-id', 'vid', + 'contact-id', 'type', 'wall', 'gravity', 'extid', 'icid', 'psid', 'created', 'edited', 'commented', 'received', 'changed', 'verb', - 'postopts', 'plink', 'resource-id', 'event-id', 'attach', 'inform', + 'postopts', 'plink', 'resource-id', 'event-id', 'inform', 'file', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid', 'post-type', 'private', 'pubmail', 'moderated', 'visible', 'starred', 'bookmark', 'unseen', 'deleted', 'origin', 'forum_mode', 'mention', 'global', 'network', 'title', 'content-warning', 'body', 'location', 'coord', 'app', 'rendered-hash', 'rendered-html', 'object-type', 'object', 'target-type', 'target', 'author-id', 'author-link', 'author-name', 'author-avatar', 'author-network', - 'owner-id', 'owner-link', 'owner-name', 'owner-avatar']; + 'owner-id', 'owner-link', 'owner-name', 'owner-avatar', 'causer-id']; + // List of all verbs that don't need additional content data. // Never reorder or remove entries from this list. Just add new ones at the end, if needed. - // The item-activity table only stores the index and needs this array to know the matching activity. const ACTIVITIES = [ Activity::LIKE, Activity::DISLIKE, Activity::ATTEND, Activity::ATTENDNO, Activity::ATTENDMAYBE, @@ -120,8 +132,22 @@ class Item const PRIVATE = 1; const UNLISTED = 2; + const TABLES = ['item', 'user-item', 'item-content', 'post-delivery-data', 'diaspora-interaction']; + private static $legacy_mode = null; + private static function getItemFields() + { + $definition = DBStructure::definition('', false); + + $postfields = []; + foreach (self::TABLES as $table) { + $postfields[$table] = array_keys($definition[$table]['fields']); + } + + return $postfields; + } + public static function isLegacyMode() { if (is_null(self::$legacy_mode)) { @@ -188,69 +214,28 @@ class Item return []; } - if (empty($condition) || !is_array($condition)) { - $condition = ['iid' => $pinned]; - } else { - reset($condition); - $first_key = key($condition); - if (!is_int($first_key)) { - $condition['iid'] = $pinned; - } else { - $values_string = substr(str_repeat("?, ", count($pinned)), 0, -2); - $condition[0] = '(' . $condition[0] . ") AND `iid` IN (" . $values_string . ")"; - $condition = array_merge($condition, $pinned); - } - } + $condition = DBA::mergeConditions(['iid' => $pinned], $condition); return self::selectThreadForUser($uid, $selected, $condition, $params); } - /** - * returns an activity index from an activity string - * - * @param string $activity activity string - * @return integer Activity index - */ - public static function activityToIndex($activity) - { - $index = array_search($activity, self::ACTIVITIES); - - if (is_bool($index)) { - $index = -1; - } - - return $index; - } - - /** - * returns an activity string from an activity index - * - * @param integer $index activity index - * @return string Activity string - */ - private static function indexToActivity($index) - { - if (is_null($index) || !array_key_exists($index, self::ACTIVITIES)) { - return ''; - } - - return self::ACTIVITIES[$index]; - } - /** * Fetch a single item row * * @param mixed $stmt statement object - * @return array current row + * @return array|false current row or false + * @throws \Exception */ public static function fetch($stmt) { $row = DBA::fetch($stmt); - if (is_bool($row)) { + if (!is_array($row)) { return $row; } + $row = DBA::castFields('item', $row); + // ---------------------- Transform item structure data ---------------------- // We prefer the data from the user's contact over the public one @@ -293,34 +278,40 @@ class Item } } - if (!empty($row['internal-iaid']) && array_key_exists('verb', $row)) { - $row['verb'] = self::indexToActivity($row['internal-activity']); - if (array_key_exists('title', $row)) { - $row['title'] = ''; + if (array_key_exists('verb', $row)) { + if (!is_null($row['internal-verb'])) { + $row['verb'] = $row['internal-verb']; } - if (array_key_exists('body', $row)) { - $row['body'] = $row['verb']; - } - if (array_key_exists('object', $row)) { - $row['object'] = ''; - } - if (array_key_exists('object-type', $row)) { - $row['object-type'] = Activity\ObjectType::NOTE; - } - } elseif (array_key_exists('verb', $row) && in_array($row['verb'], ['', Activity::POST, Activity::SHARE])) { - // Posts don't have a target - but having tags or files. - // We safe some performance by building tag and file strings only here. - // We remove the target since they aren't used for this type. - // In mail posts we do store some mail header data in the object. - if (array_key_exists('target', $row)) { - $row['target'] = ''; + + if (in_array($row['verb'], self::ACTIVITIES)) { + if (array_key_exists('title', $row)) { + $row['title'] = ''; + } + if (array_key_exists('body', $row)) { + $row['body'] = $row['verb']; + } + if (array_key_exists('object', $row)) { + $row['object'] = ''; + } + if (array_key_exists('object-type', $row)) { + $row['object-type'] = Activity\ObjectType::NOTE; + } + } elseif (in_array($row['verb'], ['', Activity::POST, Activity::SHARE])) { + // Posts don't have a target - but having tags or files. + if (array_key_exists('target', $row)) { + $row['target'] = ''; + } } } + if (array_key_exists('vid', $row) && is_null($row['vid']) && !empty($row['verb'])) { + $row['vid'] = Verb::getID($row['verb']); + } + if (!array_key_exists('verb', $row) || in_array($row['verb'], ['', Activity::POST, Activity::SHARE])) { // Build the file string out of the term entries if (array_key_exists('file', $row) && empty($row['file'])) { - $row['file'] = Category::getTextByURIId($row['internal-uri-id'], $row['internal-uid']); + $row['file'] = Post\Category::getTextByURIId($row['internal-uri-id'], $row['internal-uid']); } } @@ -344,12 +335,11 @@ class Item } // Remove internal fields - unset($row['internal-activity']); unset($row['internal-network']); unset($row['internal-uri-id']); unset($row['internal-uid']); unset($row['internal-psid']); - unset($row['internal-iaid']); + unset($row['internal-verb']); unset($row['internal-user-ignored']); unset($row['interaction']); @@ -667,26 +657,26 @@ class Item $fields = []; $fields['item'] = ['id', 'uid', 'parent', 'uri', 'parent-uri', 'thr-parent', - 'guid', 'uri-id', 'parent-uri-id', 'thr-parent-id', + 'guid', 'uri-id', 'parent-uri-id', 'thr-parent-id', 'vid', 'causer-id', 'contact-id', 'owner-id', 'author-id', 'type', 'wall', 'gravity', 'extid', 'created', 'edited', 'commented', 'received', 'changed', 'psid', - 'resource-id', 'event-id', 'attach', 'post-type', 'file', + 'resource-id', 'event-id', 'post-type', 'file', 'private', 'pubmail', 'moderated', 'visible', 'starred', 'bookmark', 'unseen', 'deleted', 'origin', 'forum_mode', 'mention', 'global', - 'id' => 'item_id', 'network', 'icid', 'iaid', + 'id' => 'item_id', 'network', 'icid', 'uri-id' => 'internal-uri-id', 'uid' => 'internal-uid', - 'network' => 'internal-network', 'iaid' => 'internal-iaid', 'psid' => 'internal-psid']; + 'network' => 'internal-network', 'psid' => 'internal-psid']; if ($usermode) { $fields['user-item'] = ['pinned', 'notification-type', 'ignored' => 'internal-user-ignored']; } - $fields['item-activity'] = ['activity', 'activity' => 'internal-activity']; - $fields['item-content'] = array_merge(self::CONTENT_FIELDLIST, self::MIXED_CONTENT_FIELDLIST); $fields['post-delivery-data'] = array_merge(Post\DeliveryData::LEGACY_FIELD_LIST, Post\DeliveryData::FIELD_LIST); + $fields['verb'] = ['name' => 'internal-verb']; + $fields['permissionset'] = ['allow_cid', 'allow_gid', 'deny_cid', 'deny_gid']; $fields['author'] = ['url' => 'author-link', 'name' => 'author-name', 'addr' => 'author-addr', @@ -695,13 +685,18 @@ class Item $fields['owner'] = ['url' => 'owner-link', 'name' => 'owner-name', 'addr' => 'owner-addr', 'thumb' => 'owner-avatar', 'nick' => 'owner-nick', 'network' => 'owner-network']; + $fields['causer'] = ['url' => 'causer-link', 'name' => 'causer-name', 'addr' => 'causer-addr', + 'thumb' => 'causer-avatar', 'nick' => 'causer-nick', 'network' => 'causer-network', + 'contact-type' => 'causer-contact-type']; + $fields['contact'] = ['url' => 'contact-link', 'name' => 'contact-name', 'thumb' => 'contact-avatar', 'writable', 'self', 'id' => 'cid', 'alias', 'uid' => 'contact-uid', 'photo', 'name-date', 'uri-date', 'avatar-date', 'thumb', 'dfrn-id']; - $fields['parent-item'] = ['guid' => 'parent-guid', 'network' => 'parent-network']; + $fields['parent-item'] = ['guid' => 'parent-guid', 'network' => 'parent-network', 'author-id' => 'parent-author-id']; - $fields['parent-item-author'] = ['url' => 'parent-author-link', 'name' => 'parent-author-name']; + $fields['parent-item-author'] = ['url' => 'parent-author-link', 'name' => 'parent-author-name', + 'network' => 'parent-author-network']; $fields['event'] = ['created' => 'event-created', 'edited' => 'event-edited', 'start' => 'event-start','finish' => 'event-finish', @@ -782,6 +777,9 @@ class Item $joins .= " LEFT JOIN `contact` AS `owner` ON `owner`.`id` = $master_table.`owner-id`"; } } + if (strpos($sql_commands, "`causer`.") !== false) { + $joins .= " LEFT JOIN `contact` AS `causer` ON `causer`.`id` = `item`.`causer-id`"; + } if (strpos($sql_commands, "`group_member`.") !== false) { $joins .= " STRAIGHT_JOIN `group_member` ON `group_member`.`contact-id` = $master_table.`contact-id`"; @@ -799,10 +797,6 @@ class Item $joins .= " LEFT JOIN `diaspora-interaction` ON `diaspora-interaction`.`uri-id` = `item`.`uri-id`"; } - if (strpos($sql_commands, "`item-activity`.") !== false) { - $joins .= " LEFT JOIN `item-activity` ON `item-activity`.`uri-id` = `item`.`uri-id`"; - } - if (strpos($sql_commands, "`item-content`.") !== false) { $joins .= " LEFT JOIN `item-content` ON `item-content`.`uri-id` = `item`.`uri-id`"; } @@ -811,16 +805,20 @@ class Item $joins .= " LEFT JOIN `post-delivery-data` ON `post-delivery-data`.`uri-id` = `item`.`uri-id` AND `item`.`origin`"; } + if (strpos($sql_commands, "`verb`.") !== false) { + $joins .= " LEFT JOIN `verb` ON `verb`.`id` = `item`.`vid`"; + } + if (strpos($sql_commands, "`permissionset`.") !== false) { $joins .= " LEFT JOIN `permissionset` ON `permissionset`.`id` = `item`.`psid`"; } - if ((strpos($sql_commands, "`parent-item`.") !== false) || (strpos($sql_commands, "`parent-author`.") !== false)) { + if ((strpos($sql_commands, "`parent-item`.") !== false) || (strpos($sql_commands, "`parent-item-author`.") !== false)) { $joins .= " STRAIGHT_JOIN `item` AS `parent-item` ON `parent-item`.`id` = `item`.`parent`"; - } - if (strpos($sql_commands, "`parent-item-author`.") !== false) { - $joins .= " STRAIGHT_JOIN `contact` AS `parent-item-author` ON `parent-item-author`.`id` = `parent-item`.`author-id`"; + if (strpos($sql_commands, "`parent-item-author`.") !== false) { + $joins .= " STRAIGHT_JOIN `contact` AS `parent-item-author` ON `parent-item-author`.`id` = `parent-item`.`author-id`"; + } } return $joins; @@ -837,11 +835,11 @@ class Item private static function constructSelectFields(array $fields, array $selected) { if (!empty($selected)) { - $selected = array_merge($selected, ['internal-uri-id', 'internal-uid', 'internal-psid', 'internal-iaid', 'internal-network']); + $selected = array_merge($selected, ['internal-uri-id', 'internal-uid', 'internal-psid', 'internal-network']); } if (in_array('verb', $selected)) { - $selected[] = 'internal-activity'; + $selected = array_merge($selected, ['internal-verb']); } if (in_array('ignored', $selected)) { @@ -914,13 +912,15 @@ class Item return false; } + $data_fields = $fields; + // To ensure the data integrity we do it in an transaction DBA::transaction(); // We cannot simply expand the condition to check for origin entries // The condition needn't to be a simple array but could be a complex condition. // And we have to execute this query before the update to ensure to fetch the same data. - $items = DBA::select('item', ['id', 'origin', 'uri', 'uri-id', 'iaid', 'icid', 'uid', 'file'], $condition); + $items = DBA::select('item', ['id', 'origin', 'uri', 'uri-id', 'icid', 'uid', 'file'], $condition); $content_fields = []; foreach (array_merge(self::CONTENT_FIELDLIST, self::MIXED_CONTENT_FIELDLIST) as $field) { @@ -950,6 +950,10 @@ class Item $files = null; } + if (!empty($content_fields['verb'])) { + $fields['vid'] = Verb::getID($content_fields['verb']); + } + if (!empty($fields)) { $success = DBA::update('item', $fields, $condition); @@ -966,34 +970,17 @@ class Item $notify_items = []; while ($item = DBA::fetch($items)) { - if (!empty($item['iaid']) || (!empty($content_fields['verb']) && (self::activityToIndex($content_fields['verb']) >= 0))) { - self::updateActivity($content_fields, ['uri-id' => $item['uri-id']]); + Post\User::update($item['uri-id'], $item['uid'], $data_fields); - if (empty($item['iaid'])) { - $item_activity = DBA::selectFirst('item-activity', ['id'], ['uri-id' => $item['uri-id']]); - if (DBA::isResult($item_activity)) { - $item_fields = ['iaid' => $item_activity['id'], 'icid' => null]; - foreach (self::MIXED_CONTENT_FIELDLIST as $field) { - if (self::isLegacyMode()) { - $item_fields[$field] = null; - } else { - unset($item_fields[$field]); - } - } - DBA::update('item', $item_fields, ['id' => $item['id']]); - - if (!empty($item['icid']) && !DBA::exists('item', ['icid' => $item['icid']])) { - DBA::delete('item-content', ['id' => $item['icid']]); - } - } - } elseif (!empty($item['icid'])) { - DBA::update('item', ['icid' => null], ['id' => $item['id']]); - - if (!DBA::exists('item', ['icid' => $item['icid']])) { - DBA::delete('item-content', ['id' => $item['icid']]); - } + if (empty($content_fields['verb']) || !in_array($content_fields['verb'], self::ACTIVITIES)) { + if (!empty($content_fields['body'])) { + $content_fields['raw-body'] = trim($content_fields['raw-body'] ?? $content_fields['body']); + + // Remove all media attachments from the body and store them in the post-media table + $content_fields['raw-body'] = Post\Media::insertFromBody($item['uri-id'], $content_fields['raw-body']); + $content_fields['raw-body'] = self::setHashtags($content_fields['raw-body']); } - } else { + self::updateContent($content_fields, ['uri-id' => $item['uri-id']]); if (empty($item['icid'])) { @@ -1001,12 +988,10 @@ class Item if (DBA::isResult($item_content)) { $item_fields = ['icid' => $item_content['id']]; // Clear all fields in the item table that have a content in the item-content table - foreach ($item_content as $field => $content) { - if (in_array($field, self::MIXED_CONTENT_FIELDLIST) && !empty($item_content[$field])) { - if (self::isLegacyMode()) { + if (self::isLegacyMode()) { + foreach ($item_content as $field => $content) { + if (in_array($field, self::MIXED_CONTENT_FIELDLIST) && !empty($content)) { $item_fields[$field] = null; - } else { - unset($item_fields[$field]); } } } @@ -1016,12 +1001,16 @@ class Item } if (!is_null($files)) { - Category::storeTextByURIId($item['uri-id'], $item['uid'], $files); + Post\Category::storeTextByURIId($item['uri-id'], $item['uid'], $files); if (!empty($item['file'])) { DBA::update('item', ['file' => ''], ['id' => $item['id']]); } } + if (!empty($fields['attach'])) { + Post\Media::insertFromAttachment($item['uri-id'], $fields['attach']); + } + Post\DeliveryData::update($item['uri-id'], $delivery_data); self::updateThread($item['id']); @@ -1072,14 +1061,13 @@ class Item return; } - $items = self::select(['id', 'uid'], $condition); + $items = self::select(['id', 'uid', 'uri-id'], $condition); while ($item = self::fetch($items)) { + Post\User::update($item['uri-id'], $item['uid'], ['hidden' => true]); + // "Deleting" global items just means hiding them if ($item['uid'] == 0) { DBA::update('user-item', ['hidden' => true], ['iid' => $item['id'], 'uid' => $uid], true); - - // Delete notifications - DBA::delete('notify', ['iid' => $item['id'], 'uid' => $uid]); } elseif ($item['uid'] == $uid) { self::markForDeletionById($item['id'], PRIORITY_HIGH); } else { @@ -1103,9 +1091,9 @@ class Item Logger::info('Mark item for deletion by id', ['id' => $item_id, 'callstack' => System::callstack()]); // locate item to be deleted $fields = ['id', 'uri', 'uri-id', 'uid', 'parent', 'parent-uri', 'origin', - 'deleted', 'file', 'resource-id', 'event-id', 'attach', + 'deleted', 'file', 'resource-id', 'event-id', 'verb', 'object-type', 'object', 'target', 'contact-id', - 'icid', 'iaid', 'psid']; + 'icid', 'psid', 'gravity']; $item = self::selectFirst($fields, ['id' => $item_id]); if (!DBA::isResult($item)) { Logger::info('Item not found.', ['id' => $item_id]); @@ -1124,7 +1112,7 @@ class Item // clean up categories and tags so they don't end up as orphans - $matches = false; + $matches = []; $cnt = preg_match_all('/<(.*?)>/', $item['file'], $matches, PREG_SET_ORDER); if ($cnt) { @@ -1133,7 +1121,7 @@ class Item } } - $matches = false; + $matches = []; $cnt = preg_match_all('/\[(.*?)\]/', $item['file'], $matches, PREG_SET_ORDER); @@ -1160,22 +1148,18 @@ class Item } // If item has attachments, drop them - /// @TODO: this should first check if attachment is used elsewhere - foreach (explode(",", $item['attach']) as $attach) { - preg_match("|attach/(\d+)|", $attach, $matches); - if (is_array($matches) && count($matches) > 1) { + $attachments = Post\Media::getByURIId($item['uri-id'], [Post\Media::DOCUMENT]); + foreach($attachments as $attachment) { + if (preg_match("|attach/(\d+)|", $attachment['url'], $matches)) { Attach::delete(['id' => $matches[1], 'uid' => $item['uid']]); } } - // Delete notifications - DBA::delete('notify', ['iid' => $item['id'], 'uid' => $item['uid']]); - // Set the item to "deleted" $item_fields = ['deleted' => true, 'edited' => DateTimeFormat::utcNow(), 'changed' => DateTimeFormat::utcNow()]; DBA::update('item', $item_fields, ['id' => $item['id']]); - Category::storeTextByURIId($item['uri-id'], $item['uid'], ''); + Post\Category::storeTextByURIId($item['uri-id'], $item['uid'], ''); self::deleteThread($item['id'], $item['parent-uri']); if (!self::exists(["`uri` = ? AND `uid` != 0 AND NOT `deleted`", $item['uri']])) { @@ -1184,11 +1168,6 @@ class Item Post\DeliveryData::delete($item['uri-id']); - // We don't delete the item-activity here, since we need some of the data for ActivityPub - - if (!empty($item['icid']) && !self::exists(['icid' => $item['icid'], 'deleted' => false])) { - DBA::delete('item-content', ['id' => $item['icid']], ['cascade' => false]); - } // When the permission set will be used in photo and events as well, // this query here needs to be extended. // @todo Currently deactivated. We need the permission set in the deletion process. @@ -1198,18 +1177,21 @@ class Item //} // If it's the parent of a comment thread, kill all the kids - if ($item['id'] == $item['parent']) { + if ($item['gravity'] == GRAVITY_PARENT) { self::markForDeletion(['parent' => $item['parent'], 'deleted' => false], $priority); } // Is it our comment and/or our thread? - if ($item['origin'] || $parent['origin']) { + if (($item['origin'] || $parent['origin']) && ($item['uid'] != 0)) { // When we delete the original post we will delete all existing copies on the server as well self::markForDeletion(['uri' => $item['uri'], 'deleted' => false], $priority); // send the notification upstream/downstream - Worker::add(['priority' => $priority, 'dont_fork' => true], "Notifier", Delivery::DELETION, intval($item['id'])); + if ($priority) { + Worker::add(['priority' => $priority, 'dont_fork' => true], "Notifier", Delivery::DELETION, intval($item['id'])); + } } elseif ($item['uid'] != 0) { + Post\User::update($item['uri-id'], $item['uid'], ['hidden' => true]); // When we delete just our local user copy of an item, we have to set a marker to hide it $global_item = self::selectFirst(['id'], ['uri' => $item['uri'], 'uid' => 0, 'deleted' => false]); @@ -1333,88 +1315,144 @@ class Item } } - public static function insert($item, $force_parent = false, $notify = false, $dontcache = false) + /** + * Check if the item array is a duplicate + * + * @param array $item + * @return boolean is it a duplicate? + */ + private static function isDuplicate(array $item) { - $orig_item = $item; - - $priority = PRIORITY_HIGH; - - // If it is a posting where users should get notifications, then define it as wall posting - if ($notify) { - $item['wall'] = 1; - $item['origin'] = 1; - $item['network'] = Protocol::DFRN; - $item['protocol'] = Conversation::PARCEL_DFRN; - - if (is_int($notify)) { - $priority = $notify; - } - } else { - $item['network'] = trim(($item['network'] ?? '') ?: Protocol::PHANTOM); + // Checking if there is already an item with the same guid + $condition = ['guid' => $item['guid'], 'network' => $item['network'], 'uid' => $item['uid']]; + if (self::exists($condition)) { + Logger::notice('Found already existing item', [ + 'guid' => $item['guid'], + 'uid' => $item['uid'], + 'network' => $item['network'] + ]); + return true; } - $item['guid'] = self::guid($item, $notify); - $item['uri'] = substr(Strings::escapeTags(trim(($item['uri'] ?? '') ?: self::newURI($item['uid'], $item['guid']))), 0, 255); + $condition = ["`uri` = ? AND `network` IN (?, ?) AND `uid` = ?", + $item['uri'], $item['network'], Protocol::DFRN, $item['uid']]; + if (self::exists($condition)) { + Logger::notice('duplicated item with the same uri found.', $item); + return true; + } - // Store URI data - $item['uri-id'] = ItemURI::insert(['uri' => $item['uri'], 'guid' => $item['guid']]); - - // Store conversation data - $item = Conversation::insert($item); + // On Friendica and Diaspora the GUID is unique + if (in_array($item['network'], [Protocol::DFRN, Protocol::DIASPORA])) { + $condition = ['guid' => $item['guid'], 'uid' => $item['uid']]; + if (self::exists($condition)) { + Logger::notice('duplicated item with the same guid found.', $item); + return true; + } + } elseif ($item['network'] == Protocol::OSTATUS) { + // Check for an existing post with the same content. There seems to be a problem with OStatus. + $condition = ["`body` = ? AND `network` = ? AND `created` = ? AND `contact-id` = ? AND `uid` = ?", + $item['body'], $item['network'], $item['created'], $item['contact-id'], $item['uid']]; + if (self::exists($condition)) { + Logger::notice('duplicated item with the same body found.', $item); + return true; + } + } /* - * If a Diaspora signature structure was passed in, pull it out of the - * item array and set it aside for later storage. + * Check for already added items. + * There is a timing issue here that sometimes creates double postings. + * An unique index would help - but the limitations of MySQL (maximum size of index values) prevent this. */ - - $dsprsig = null; - if (isset($item['dsprsig'])) { - $encoded_signature = $item['dsprsig']; - $dsprsig = json_decode(base64_decode($item['dsprsig'])); - unset($item['dsprsig']); + if (($item['uid'] == 0) && self::exists(['uri' => trim($item['uri']), 'uid' => 0])) { + Logger::notice('Global item already stored.', ['uri' => $item['uri'], 'network' => $item['network']]); + return true; } - $diaspora_signed_text = ''; - if (isset($item['diaspora_signed_text'])) { - $diaspora_signed_text = $item['diaspora_signed_text']; - unset($item['diaspora_signed_text']); + return false; + } + + /** + * Check if the item array is valid + * + * @param array $item + * @return boolean item is valid + */ + public static function isValid(array $item) + { + // When there is no content then we don't post it + if ($item['body'] . $item['title'] == '') { + Logger::notice('No body, no title.'); + return false; } - // Converting the plink - /// @TODO Check if this is really still needed - if ($item['network'] == Protocol::OSTATUS) { - if (isset($item['plink'])) { - $item['plink'] = OStatus::convertHref($item['plink']); - } elseif (isset($item['uri'])) { - $item['plink'] = OStatus::convertHref($item['uri']); + if (!empty($item['uid'])) { + $owner = User::getOwnerDataById($item['uid'], false); + if (!$owner) { + Logger::notice('Missing item user owner data', ['uid' => $item['uid']]); + return false; + } + + if ($owner['account_expired'] || $owner['account_removed']) { + Logger::notice('Item user has been deleted/expired/removed', ['uid' => $item['uid'], 'deleted' => $owner['deleted'], 'account_expired' => $owner['account_expired'], 'account_removed' => $owner['account_removed']]); + return false; } } - if (!empty($item['thr-parent'])) { - $item['parent-uri'] = $item['thr-parent']; + if (!empty($item['author-id']) && Contact::isBlocked($item['author-id'])) { + Logger::notice('Author is blocked node-wide', ['author-link' => $item['author-link'], 'item-uri' => $item['uri']]); + return false; } - $activity = DI::activity(); - - if (isset($item['gravity'])) { - $item['gravity'] = intval($item['gravity']); - } elseif ($item['parent-uri'] === $item['uri']) { - $item['gravity'] = GRAVITY_PARENT; - } elseif ($activity->match($item['verb'], Activity::POST)) { - $item['gravity'] = GRAVITY_COMMENT; - } elseif ($activity->match($item['verb'], Activity::FOLLOW)) { - $item['gravity'] = GRAVITY_ACTIVITY; - } else { - $item['gravity'] = GRAVITY_UNKNOWN; // Should not happen - Logger::log('Unknown gravity for verb: ' . $item['verb'], Logger::DEBUG); + if (!empty($item['author-link']) && Network::isUrlBlocked($item['author-link'])) { + Logger::notice('Author server is blocked', ['author-link' => $item['author-link'], 'item-uri' => $item['uri']]); + return false; } - $uid = intval($item['uid']); + if (!empty($item['owner-id']) && Contact::isBlocked($item['owner-id'])) { + Logger::notice('Owner is blocked node-wide', ['owner-link' => $item['owner-link'], 'item-uri' => $item['uri']]); + return false; + } + if (!empty($item['owner-link']) && Network::isUrlBlocked($item['owner-link'])) { + Logger::notice('Owner server is blocked', ['owner-link' => $item['owner-link'], 'item-uri' => $item['uri']]); + return false; + } + + if (!empty($item['uid']) && !self::isAllowedByUser($item, $item['uid'])) { + return false; + } + + if ($item['verb'] == Activity::FOLLOW) { + if (!$item['origin'] && ($item['author-id'] == Contact::getPublicIdByUserId($item['uid']))) { + // Our own follow request can be relayed to us. We don't store it to avoid notification chaos. + Logger::info("Follow: Don't store not origin follow request", ['parent-uri' => $item['parent-uri']]); + return false; + } + + $condition = ['verb' => Activity::FOLLOW, 'uid' => $item['uid'], + 'parent-uri' => $item['parent-uri'], 'author-id' => $item['author-id']]; + if (self::exists($condition)) { + // It happens that we receive multiple follow requests by the same author - we only store one. + Logger::info('Follow: Found existing follow request from author', ['author-id' => $item['author-id'], 'parent-uri' => $item['parent-uri']]); + return false; + } + } + + return true; + } + + /** + * Check if the item array is too old + * + * @param array $item + * @return boolean item is too old + */ + public static function isTooOld(array $item) + { // check for create date and expire time $expire_interval = DI::config()->get('system', 'dbclean-expire-days', 0); - $user = DBA::selectFirst('user', ['expire'], ['uid' => $uid]); + $user = DBA::selectFirst('user', ['expire'], ['uid' => $item['uid']]); if (DBA::isResult($user) && ($user['expire'] > 0) && (($user['expire'] < $expire_interval) || ($expire_interval == 0))) { $expire_interval = $user['expire']; } @@ -1428,15 +1466,21 @@ class Item 'expired' => date('c', $expire_date), '$item' => $item ]); - return 0; + return true; } } - /* - * Do we already have this item? - * We have to check several networks since Friendica posts could be repeated - * via OStatus (maybe Diasporsa as well) - */ + return false; + } + + /** + * Return the id of the given item array if it has been stored before + * + * @param array $item + * @return integer item id + */ + private static function getDuplicateID(array $item) + { if (empty($item['network']) || in_array($item['network'], Protocol::FEDERATED)) { $condition = ["`uri` = ? AND `uid` = ? AND `network` IN (?, ?, ?, ?)", trim($item['uri']), $item['uid'], @@ -1444,10 +1488,10 @@ class Item $existing = self::selectFirst(['id', 'network'], $condition); if (DBA::isResult($existing)) { // We only log the entries with a different user id than 0. Otherwise we would have too many false positives - if ($uid != 0) { + if ($item['uid'] != 0) { Logger::notice('Item already existed for user', [ 'uri' => $item['uri'], - 'uid' => $uid, + 'uid' => $item['uid'], 'network' => $item['network'], 'existing_id' => $existing["id"], 'existing_network' => $existing["network"] @@ -1457,6 +1501,126 @@ class Item return $existing["id"]; } } + return 0; + } + + /** + * Fetch top-level parent data for the given item array + * + * @param array $item + * @return array item array with parent data + * @throws \Exception + */ + private static function getTopLevelParent(array $item) + { + $fields = ['uid', 'uri', 'parent-uri', 'id', 'deleted', + 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid', + 'wall', 'private', 'forum_mode', 'origin', 'author-id']; + $condition = ['uri' => $item['thr-parent'], 'uid' => $item['uid']]; + $params = ['order' => ['id' => false]]; + $parent = self::selectFirst($fields, $condition, $params); + + if (!DBA::isResult($parent)) { + Logger::notice('item parent was not found - ignoring item', ['thr-parent' => $item['thr-parent'], 'uid' => $item['uid']]); + return []; + } + + if ($parent['uri'] == $parent['parent-uri']) { + return $parent; + } + + $condition = ['uri' => $parent['parent-uri'], + 'parent-uri' => $parent['parent-uri'], + 'uid' => $parent['uid']]; + $params = ['order' => ['id' => false]]; + $toplevel_parent = self::selectFirst($fields, $condition, $params); + if (!DBA::isResult($toplevel_parent)) { + Logger::notice('item top level parent was not found - ignoring item', ['parent-uri' => $parent['parent-uri'], 'uid' => $parent['uid']]); + return []; + } + + return $toplevel_parent; + } + + /** + * Get the gravity for the given item array + * + * @param array $item + * @return integer gravity + */ + private static function getGravity(array $item) + { + $activity = DI::activity(); + + if (isset($item['gravity'])) { + return intval($item['gravity']); + } elseif ($item['parent-uri'] === $item['uri']) { + return GRAVITY_PARENT; + } elseif ($activity->match($item['verb'], Activity::POST)) { + return GRAVITY_COMMENT; + } elseif ($activity->match($item['verb'], Activity::FOLLOW)) { + return GRAVITY_ACTIVITY; + } elseif ($activity->match($item['verb'], Activity::ANNOUNCE)) { + return GRAVITY_ACTIVITY; + } + Logger::info('Unknown gravity for verb', ['verb' => $item['verb']]); + return GRAVITY_UNKNOWN; // Should not happen + } + + public static function insert($item, $notify = false, $dontcache = false) + { + $structure = self::getItemFields(); + + $orig_item = $item; + + $priority = PRIORITY_HIGH; + + // If it is a posting where users should get notifications, then define it as wall posting + if ($notify) { + $item['wall'] = 1; + $item['origin'] = 1; + $item['network'] = Protocol::DFRN; + $item['protocol'] = Conversation::PARCEL_DIRECT; + $item['direction'] = Conversation::PUSH; + + if (in_array($notify, PRIORITIES)) { + $priority = $notify; + } + } else { + $item['network'] = trim(($item['network'] ?? '') ?: Protocol::PHANTOM); + } + + $uid = intval($item['uid']); + + $item['guid'] = self::guid($item, $notify); + $item['uri'] = substr(trim($item['uri'] ?? '') ?: self::newURI($item['uid'], $item['guid']), 0, 255); + + // Store URI data + $item['uri-id'] = ItemURI::insert(['uri' => $item['uri'], 'guid' => $item['guid']]); + + // Backward compatibility: parent-uri used to be the direct parent uri. + // If it is provided without a thr-parent, it probably is the old behavior. + $item['thr-parent'] = trim($item['thr-parent'] ?? $item['parent-uri'] ?? $item['uri']); + $item['parent-uri'] = $item['thr-parent']; + + // Store conversation data + $item = Conversation::insert($item); + + /* + * Do we already have this item? + * We have to check several networks since Friendica posts could be repeated + * via OStatus (maybe Diasporsa as well) + */ + $duplicate = self::getDuplicateID($item); + if ($duplicate) { + return $duplicate; + } + + // Additional duplicate checks + /// @todo Check why the first duplication check returns the item number and the second a 0 + if (self::isDuplicate($item)) { + return 0; + } $item['wall'] = intval($item['wall'] ?? 0); $item['extid'] = trim($item['extid'] ?? ''); @@ -1476,7 +1640,6 @@ class Item $item['coord'] = trim($item['coord'] ?? ''); $item['visible'] = (isset($item['visible']) ? intval($item['visible']) : 1); $item['deleted'] = 0; - $item['parent-uri'] = trim(($item['parent-uri'] ?? '') ?: $item['uri']); $item['post-type'] = ($item['post-type'] ?? '') ?: self::PT_ARTICLE; $item['verb'] = trim($item['verb'] ?? ''); $item['object-type'] = trim($item['object-type'] ?? ''); @@ -1490,7 +1653,7 @@ class Item $item['deny_gid'] = trim($item['deny_gid'] ?? ''); $item['private'] = intval($item['private'] ?? self::PUBLIC); $item['body'] = trim($item['body'] ?? ''); - $item['attach'] = trim($item['attach'] ?? ''); + $item['raw-body'] = trim($item['raw-body'] ?? $item['body']); $item['app'] = trim($item['app'] ?? ''); $item['origin'] = intval($item['origin'] ?? 0); $item['postopts'] = trim($item['postopts'] ?? ''); @@ -1499,14 +1662,6 @@ class Item $item['inform'] = trim($item['inform'] ?? ''); $item['file'] = trim($item['file'] ?? ''); - // When there is no content then we don't post it - if ($item['body'].$item['title'] == '') { - Logger::notice('No body, no title.'); - return 0; - } - - self::addLanguageToItemArray($item); - // Items cannot be stored before they happen ... if ($item['created'] > DateTimeFormat::utcNow()) { $item['created'] = DateTimeFormat::utcNow(); @@ -1519,239 +1674,118 @@ class Item $item['plink'] = ($item['plink'] ?? '') ?: DI::baseUrl() . '/display/' . urlencode($item['guid']); + $item['gravity'] = self::getGravity($item); + + $item['language'] = self::getLanguage($item); + $default = ['url' => $item['author-link'], 'name' => $item['author-name'], 'photo' => $item['author-avatar'], 'network' => $item['network']]; - - $item['author-id'] = ($item['author-id'] ?? 0) ?: Contact::getIdForURL($item['author-link'], 0, false, $default); - - if (Contact::isBlocked($item['author-id'])) { - Logger::notice('Author is blocked node-wide', ['author-link' => $item['author-link'], 'item-uri' => $item['uri']]); - return 0; - } - - if (!empty($item['author-link']) && Network::isUrlBlocked($item['author-link'])) { - Logger::notice('Author server is blocked', ['author-link' => $item['author-link'], 'item-uri' => $item['uri']]); - return 0; - } - - if (!empty($uid) && Contact::isBlockedByUser($item['author-id'], $uid)) { - Logger::notice('Author is blocked by user', ['author-link' => $item['author-link'], 'uid' => $uid, 'item-uri' => $item['uri']]); - return 0; - } + $item['author-id'] = ($item['author-id'] ?? 0) ?: Contact::getIdForURL($item['author-link'], 0, null, $default); $default = ['url' => $item['owner-link'], 'name' => $item['owner-name'], 'photo' => $item['owner-avatar'], 'network' => $item['network']]; + $item['owner-id'] = ($item['owner-id'] ?? 0) ?: Contact::getIdForURL($item['owner-link'], 0, null, $default); - $item['owner-id'] = ($item['owner-id'] ?? 0) ?: Contact::getIdForURL($item['owner-link'], 0, false, $default); - - if (Contact::isBlocked($item['owner-id'])) { - Logger::notice('Owner is blocked node-wide', ['owner-link' => $item['owner-link'], 'item-uri' => $item['uri']]); - return 0; + $actor = ($item['gravity'] == GRAVITY_PARENT) ? $item['owner-id'] : $item['author-id']; + if (!$item['origin'] && ($item['uid'] != 0) && Contact::isSharing($actor, $item['uid'])) { + $item['post-type'] = self::PT_FOLLOWER; } - if (!empty($item['owner-link']) && Network::isUrlBlocked($item['owner-link'])) { - Logger::notice('Owner server is blocked', ['owner-link' => $item['owner-link'], 'item-uri' => $item['uri']]); - return 0; - } - - if (!empty($uid) && Contact::isBlockedByUser($item['owner-id'], $uid)) { - Logger::notice('Owner is blocked by user', ['owner-link' => $item['owner-link'], 'uid' => $uid, 'item-uri' => $item['uri']]); - return 0; - } - - // The causer is set during a thread completion, for example because of a reshare. It countains the responsible actor. - if (!empty($uid) && !empty($item['causer-id']) && Contact::isBlockedByUser($item['causer-id'], $uid)) { - Logger::notice('Causer is blocked by user', ['causer-link' => $item['causer-link'], 'uid' => $uid, 'item-uri' => $item['uri']]); - return 0; - } - - if (!empty($uid) && !empty($item['causer-id']) && ($item['parent-uri'] == $item['uri']) && Contact::isIgnoredByUser($item['causer-id'], $uid)) { - Logger::notice('Causer is ignored by user', ['causer-link' => $item['causer-link'], 'uid' => $uid, 'item-uri' => $item['uri']]); - return 0; - } - - // We don't store the causer, we only have it here for the checks above - unset($item['causer-id']); - unset($item['causer-link']); + // Ensure that there is an avatar cache + Contact::checkAvatarCache($item['author-id']); + Contact::checkAvatarCache($item['owner-id']); // The contact-id should be set before "self::insert" was called - but there seems to be issues sometimes $item["contact-id"] = self::contactId($item); - if ($item['network'] == Protocol::PHANTOM) { - $item['network'] = Protocol::DFRN; - Logger::notice('Missing network, setting to {network}.', [ - 'uri' => $item["uri"], - 'network' => $item['network'], - 'callstack' => System::callstack() - ]); - } - - // Checking if there is already an item with the same guid - $condition = ['guid' => $item['guid'], 'network' => $item['network'], 'uid' => $item['uid']]; - if (self::exists($condition)) { - Logger::notice('Found already existing item', [ - 'guid' => $item['guid'], - 'uid' => $item['uid'], - 'network' => $item['network'] - ]); + if (!empty($item['direction']) && in_array($item['direction'], [Conversation::PUSH, Conversation::RELAY]) && + self::isTooOld($item)) { + Logger::info('Item is too old', ['item' => $item]); return 0; } - if ($item['verb'] == Activity::FOLLOW) { - if (!$item['origin'] && ($item['author-id'] == Contact::getPublicIdByUserId($uid))) { - // Our own follow request can be relayed to us. We don't store it to avoid notification chaos. - Logger::log("Follow: Don't store not origin follow request from us for " . $item['parent-uri'], Logger::DEBUG); + if (!self::isValid($item)) { + return 0; + } + + if ($item['gravity'] !== GRAVITY_PARENT) { + $toplevel_parent = self::getTopLevelParent($item); + if (empty($toplevel_parent)) { return 0; } - $condition = ['verb' => Activity::FOLLOW, 'uid' => $item['uid'], - 'parent-uri' => $item['parent-uri'], 'author-id' => $item['author-id']]; - if (self::exists($condition)) { - // It happens that we receive multiple follow requests by the same author - we only store one. - Logger::log('Follow: Found existing follow request from author ' . $item['author-id'] . ' for ' . $item['parent-uri'], Logger::DEBUG); + // If the thread originated from this node, we check the permission against the thread starter + $condition = ['uri' => $toplevel_parent['uri'], 'wall' => true]; + $localTopLevelParent = self::selectFirst(['uid'], $condition); + if (!empty($localTopLevelParent['uid']) && !self::isAllowedByUser($item, $localTopLevelParent['uid'])) { return 0; } - } - // Check for hashtags in the body and repair or add hashtag links - self::setHashtags($item); + $parent_id = $toplevel_parent['id']; + $item['parent-uri'] = $toplevel_parent['uri']; + $item['deleted'] = $toplevel_parent['deleted']; + $item['allow_cid'] = $toplevel_parent['allow_cid']; + $item['allow_gid'] = $toplevel_parent['allow_gid']; + $item['deny_cid'] = $toplevel_parent['deny_cid']; + $item['deny_gid'] = $toplevel_parent['deny_gid']; + $parent_origin = $toplevel_parent['origin']; - // Store tags from the body if this hadn't been handled previously in the protocol classes - if (!Tag::existsForPost($item['uri-id'])) { - Tag::storeFromBody($item['uri-id'], $item['body']); - } - - $item['thr-parent'] = $item['parent-uri']; - - $notify_type = Delivery::POST; - $allow_cid = ''; - $allow_gid = ''; - $deny_cid = ''; - $deny_gid = ''; - - if ($item['parent-uri'] === $item['uri']) { - $parent_id = 0; - $parent_deleted = 0; - $allow_cid = $item['allow_cid']; - $allow_gid = $item['allow_gid']; - $deny_cid = $item['deny_cid']; - $deny_gid = $item['deny_gid']; - } else { - // find the parent and snarf the item id and ACLs - // and anything else we need to inherit - - $fields = ['uri', 'parent-uri', 'id', 'deleted', - 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid', - 'wall', 'private', 'forum_mode', 'origin', 'author-id']; - $condition = ['uri' => $item['parent-uri'], 'uid' => $item['uid']]; - $params = ['order' => ['id' => false]]; - $parent = self::selectFirst($fields, $condition, $params); - - if (DBA::isResult($parent)) { - // is the new message multi-level threaded? - // even though we don't support it now, preserve the info - // and re-attach to the conversation parent. - - if ($parent['uri'] != $parent['parent-uri']) { - $item['parent-uri'] = $parent['parent-uri']; - - $condition = ['uri' => $item['parent-uri'], - 'parent-uri' => $item['parent-uri'], - 'uid' => $item['uid']]; - $params = ['order' => ['id' => false]]; - $toplevel_parent = self::selectFirst($fields, $condition, $params); - - if (DBA::isResult($toplevel_parent)) { - $parent = $toplevel_parent; - } - } - - $parent_id = $parent['id']; - $parent_deleted = $parent['deleted']; - $allow_cid = $parent['allow_cid']; - $allow_gid = $parent['allow_gid']; - $deny_cid = $parent['deny_cid']; - $deny_gid = $parent['deny_gid']; - $item['wall'] = $parent['wall']; - - /* - * If the parent is private, force privacy for the entire conversation - * This differs from the above settings as it subtly allows comments from - * email correspondents to be private even if the overall thread is not. - */ - if ($parent['private']) { - $item['private'] = $parent['private']; - } - - /* - * Edge case. We host a public forum that was originally posted to privately. - * The original author commented, but as this is a comment, the permissions - * weren't fixed up so it will still show the comment as private unless we fix it here. - */ - if ((intval($parent['forum_mode']) == 1) && ($parent['private'] != self::PUBLIC)) { - $item['private'] = self::PUBLIC; - } - - // If its a post that originated here then tag the thread as "mention" - if ($item['origin'] && $item['uid']) { - DBA::update('thread', ['mention' => true], ['iid' => $parent_id]); - Logger::log('tagged thread ' . $parent_id . ' as mention for user ' . $item['uid'], Logger::DEBUG); - } - - // Update the contact relations - if ($item['author-id'] != $parent['author-id']) { - DBA::update('contact-relation', ['last-interaction' => $item['created']], ['cid' => $parent['author-id'], 'relation-cid' => $item['author-id']], true); - } + // Don't federate received participation messages + if ($item['verb'] != Activity::FOLLOW) { + $item['wall'] = $toplevel_parent['wall']; } else { - /* - * Allow one to see reply tweets from status.net even when - * we don't have or can't see the original post. - */ - if ($force_parent) { - Logger::log('$force_parent=true, reply converted to top-level post.'); - $parent_id = 0; - $item['parent-uri'] = $item['uri']; - $item['gravity'] = GRAVITY_PARENT; - } else { - Logger::log('item parent '.$item['parent-uri'].' for '.$item['uid'].' was not found - ignoring item'); - return 0; - } - - $parent_deleted = 0; + $item['wall'] = false; } + + /* + * If the parent is private, force privacy for the entire conversation + * This differs from the above settings as it subtly allows comments from + * email correspondents to be private even if the overall thread is not. + */ + if ($toplevel_parent['private']) { + $item['private'] = $toplevel_parent['private']; + } + + /* + * Edge case. We host a public forum that was originally posted to privately. + * The original author commented, but as this is a comment, the permissions + * weren't fixed up so it will still show the comment as private unless we fix it here. + */ + if ((intval($toplevel_parent['forum_mode']) == 1) && ($toplevel_parent['private'] != self::PUBLIC)) { + $item['private'] = self::PUBLIC; + } + + // If its a post that originated here then tag the thread as "mention" + if ($item['origin'] && $item['uid']) { + DBA::update('thread', ['mention' => true], ['iid' => $parent_id]); + Logger::info('tagged thread as mention', ['parent' => $parent_id, 'uid' => $item['uid']]); + } + + // Update the contact relations + Contact\Relation::store($toplevel_parent['author-id'], $item['author-id'], $item['created']); + + unset($item['parent_origin']); + } else { + $parent_id = 0; + $parent_origin = $item['origin']; } - if (stristr($item['verb'], Activity::POKE)) { - $notify_type = Delivery::POKE; - } + // We don't store the causer link, only the id + unset($item['causer-link']); + + // We don't store these fields anymore in the item table + unset($item['author-link']); + unset($item['author-name']); + unset($item['author-avatar']); + unset($item['author-network']); + + unset($item['owner-link']); + unset($item['owner-name']); + unset($item['owner-avatar']); $item['parent-uri-id'] = ItemURI::getIdByURI($item['parent-uri']); $item['thr-parent-id'] = ItemURI::getIdByURI($item['thr-parent']); - $condition = ["`uri` = ? AND `network` IN (?, ?) AND `uid` = ?", - $item['uri'], $item['network'], Protocol::DFRN, $item['uid']]; - if (self::exists($condition)) { - Logger::log('duplicated item with the same uri found. '.print_r($item,true)); - return 0; - } - - // On Friendica and Diaspora the GUID is unique - if (in_array($item['network'], [Protocol::DFRN, Protocol::DIASPORA])) { - $condition = ['guid' => $item['guid'], 'uid' => $item['uid']]; - if (self::exists($condition)) { - Logger::log('duplicated item with the same guid found. '.print_r($item,true)); - return 0; - } - } elseif ($item['network'] == Protocol::OSTATUS) { - // Check for an existing post with the same content. There seems to be a problem with OStatus. - $condition = ["`body` = ? AND `network` = ? AND `created` = ? AND `contact-id` = ? AND `uid` = ?", - $item['body'], $item['network'], $item['created'], $item['contact-id'], $item['uid']]; - if (self::exists($condition)) { - Logger::log('duplicated item with the same body found. '.print_r($item,true)); - return 0; - } - } - // Is this item available in the global items (with uid=0)? if ($item["uid"] == 0) { $item["global"] = true; @@ -1763,60 +1797,29 @@ class Item } // ACL settings - if (strlen($allow_cid) || strlen($allow_gid) || strlen($deny_cid) || strlen($deny_gid)) { - $private = self::PRIVATE; - } else { - $private = $item['private']; + if (!empty($item["allow_cid"] . $item["allow_gid"] . $item["deny_cid"] . $item["deny_gid"])) { + $item["private"] = self::PRIVATE; } - $item["allow_cid"] = $allow_cid; - $item["allow_gid"] = $allow_gid; - $item["deny_cid"] = $deny_cid; - $item["deny_gid"] = $deny_gid; - $item["private"] = $private; - $item["deleted"] = $parent_deleted; - - // Fill the cache field - self::putInCache($item); - if ($notify) { $item['edit'] = false; $item['parent'] = $parent_id; Hook::callAll('post_local', $item); unset($item['edit']); - unset($item['parent']); } else { Hook::callAll('post_remote', $item); } - // This array field is used to trigger some automatic reactions - // It is mainly used in the "post_local" hook. - unset($item['api_source']); + // Set after the insert because top-level posts are self-referencing + unset($item['parent']); if (!empty($item['cancel'])) { Logger::log('post cancelled by addon.'); return 0; } - /* - * Check for already added items. - * There is a timing issue here that sometimes creates double postings. - * An unique index would help - but the limitations of MySQL (maximum size of index values) prevent this. - */ - if ($item["uid"] == 0) { - if (self::exists(['uri' => trim($item['uri']), 'uid' => 0])) { - Logger::log('Global item already stored. URI: '.$item['uri'].' on network '.$item['network'], Logger::DEBUG); - return 0; - } - } - - Logger::log('' . print_r($item,true), Logger::DATA); - - if (array_key_exists('file', $item)) { - $files = $item['file']; - unset($item['file']); - } else { - $files = ''; + if (empty($item['vid']) && !empty($item['verb'])) { + $item['vid'] = Verb::getID($item['verb']); } // Creates or assigns the permission set @@ -1828,77 +1831,121 @@ class Item $item['deny_gid'] ); - $item['allow_cid'] = null; - $item['allow_gid'] = null; - $item['deny_cid'] = null; - $item['deny_gid'] = null; + unset($item['allow_cid']); + unset($item['allow_gid']); + unset($item['deny_cid']); + unset($item['deny_gid']); - // We are doing this outside of the transaction to avoid timing problems - if (!self::insertActivity($item)) { - self::insertContent($item); + // This array field is used to trigger some automatic reactions + // It is mainly used in the "post_local" hook. + unset($item['api_source']); + + if ($item['verb'] == Activity::ANNOUNCE) { + self::setOwnerforResharedItem($item); } - $delivery_data = Post\DeliveryData::extractFields($item); + // Remove all media attachments from the body and store them in the post-media table + $item['raw-body'] = Post\Media::insertFromBody($item['uri-id'], $item['raw-body']); + $item['raw-body'] = self::setHashtags($item['raw-body']); + // Check for hashtags in the body and repair or add hashtag links + $item['body'] = self::setHashtags($item['body']); + + if (!empty($item['attach'])) { + Post\Media::insertFromAttachment($item['uri-id'], $item['attach']); + } + + // Fill the cache field + self::putInCache($item); + + if (stristr($item['verb'], Activity::POKE)) { + $notify_type = Delivery::POKE; + } else { + $notify_type = Delivery::POST; + } + + if (!in_array($item['verb'], self::ACTIVITIES)) { + $item['icid'] = self::insertContent($item); + if (empty($item['icid'])) { + // This shouldn't happen + Logger::warning('No content stored, quitting', ['guid' => $item['guid'], 'uri-id' => $item['uri-id'], 'causer-id' => ($item['causer-id'] ?? 0), 'post-type' => $item['post-type'], 'network' => $item['network']]); + return 0; + } + } + + $body = $item['body']; + $verb = $item['verb']; + + // We just remove everything that is content + foreach (array_merge(self::CONTENT_FIELDLIST, self::MIXED_CONTENT_FIELDLIST) as $field) { + unset($item[$field]); + } + + unset($item['activity']); + + // Filling item related side tables + + // Diaspora signature + if (!empty($item['diaspora_signed_text'])) { + DBA::replace('diaspora-interaction', ['uri-id' => $item['uri-id'], 'interaction' => $item['diaspora_signed_text']]); + } + + unset($item['diaspora_signed_text']); + + // Attached file links + if (!empty($item['file'])) { + Post\Category::storeTextByURIId($item['uri-id'], $item['uid'], $item['file']); + } + + unset($item['file']); + + // Delivery relevant data + $delivery_data = Post\DeliveryData::extractFields($item); unset($item['postopts']); unset($item['inform']); - // These fields aren't stored anymore in the item table, they are fetched upon request - unset($item['author-link']); - unset($item['author-name']); - unset($item['author-avatar']); - unset($item['author-network']); + if (!empty($item['origin']) || !empty($item['wall']) || !empty($delivery_data['postopts']) || !empty($delivery_data['inform'])) { + Post\DeliveryData::insert($item['uri-id'], $delivery_data); + } - unset($item['owner-link']); - unset($item['owner-name']); - unset($item['owner-avatar']); + // Store tags from the body if this hadn't been handled previously in the protocol classes + if (!Tag::existsForPost($item['uri-id'])) { + Tag::storeFromBody($item['uri-id'], $body); + } - $like_no_comment = DI::config()->get('system', 'like_no_comment'); + if (Post\User::insert($item['uri-id'], $item['uid'], $item)) { + // Remove all fields that aren't part of the item table + foreach ($item as $field => $value) { + if (!in_array($field, $structure['item'])) { + unset($item[$field]); + } + } - DBA::transaction(); - $ret = DBA::insert('item', $item); + $condition = ['uri-id' => $item['uri-id'], 'uid' => $item['uid'], 'network' => $item['network']]; + if (DBA::exists('item', $condition)) { + Logger::notice('Item is already inserted - aborting', $condition); + return 0; + } - // When the item was successfully stored we fetch the ID of the item. - if (DBA::isResult($ret)) { + $result = DBA::insert('item', $item); + + // When the item was successfully stored we fetch the ID of the item. $current_post = DBA::lastInsertId(); } else { - // This can happen - for example - if there are locking timeouts. - DBA::rollback(); + Logger::notice('Post-User is already inserted - aborting', ['uid' => $item['uid'], 'uri-id' => $item['uri-id']]); + return 0; + } - // Store the data into a spool file so that we can try again later. + if (empty($current_post) || !DBA::isResult($result)) { + // On failure store the data into a spool file so that the "SpoolPost" worker can try again later. + Logger::warning('Could not store item. it will be spooled', ['result' => $result, 'id' => $current_post]); self::spool($orig_item); return 0; } - if ($current_post == 0) { - // This is one of these error messages that never should occur. - Logger::log("couldn't find created item - we better quit now."); - DBA::rollback(); - return 0; - } + Logger::notice('created item', ['id' => $current_post, 'uid' => $item['uid'], 'network' => $item['network'], 'uri-id' => $item['uri-id'], 'guid' => $item['guid']]); - // How much entries have we created? - // We wouldn't need this query when we could use an unique index - but MySQL has length problems with them. - $entries = DBA::count('item', ['uri' => $item['uri'], 'uid' => $item['uid'], 'network' => $item['network']]); - - if ($entries > 1) { - // There are duplicates. We delete our just created entry. - Logger::info('Delete duplicated item', ['id' => $current_post, 'uri' => $item['uri'], 'uid' => $item['uid'], 'guid' => $item['guid']]); - - // Yes, we could do a rollback here - but we possibly are still having users with MyISAM. - DBA::delete('item', ['id' => $current_post]); - DBA::commit(); - return 0; - } elseif ($entries == 0) { - // This really should never happen since we quit earlier if there were problems. - Logger::log("Something is terribly wrong. We haven't found our created entry."); - DBA::rollback(); - return 0; - } - - Logger::log('created item '.$current_post); - - if (!$parent_id || ($item['parent-uri'] === $item['uri'])) { + if (!$parent_id || ($item['gravity'] === GRAVITY_PARENT)) { $parent_id = $current_post; } @@ -1909,52 +1956,26 @@ class Item $item['parent'] = $parent_id; // update the commented timestamp on the parent - // Only update "commented" if it is really a comment - if (($item['gravity'] != GRAVITY_ACTIVITY) || !$like_no_comment) { + if (DI::config()->get('system', 'like_no_comment')) { + // Update when it is a comment + $update_commented = in_array($item['gravity'], [GRAVITY_PARENT, GRAVITY_COMMENT]); + } else { + // Update when it isn't a follow or tag verb + $update_commented = !in_array($verb, [Activity::FOLLOW, Activity::TAG]); + } + + if ($update_commented) { DBA::update('item', ['commented' => DateTimeFormat::utcNow(), 'changed' => DateTimeFormat::utcNow()], ['id' => $parent_id]); } else { DBA::update('item', ['changed' => DateTimeFormat::utcNow()], ['id' => $parent_id]); } - if ($dsprsig) { - /* - * Friendica servers lower than 3.4.3-2 had double encoded the signature ... - * We can check for this condition when we decode and encode the stuff again. - */ - if (base64_encode(base64_decode(base64_decode($dsprsig->signature))) == base64_decode($dsprsig->signature)) { - $dsprsig->signature = base64_decode($dsprsig->signature); - Logger::log("Repaired double encoded signature from handle ".$dsprsig->signer, Logger::DEBUG); - } - - if (!empty($dsprsig->signed_text) && empty($dsprsig->signature) && empty($dsprsig->signer)) { - DBA::insert('diaspora-interaction', ['uri-id' => $item['uri-id'], 'interaction' => $dsprsig->signed_text], true); - } - } - - if (!empty($diaspora_signed_text)) { - DBA::insert('diaspora-interaction', ['uri-id' => $item['uri-id'], 'interaction' => $diaspora_signed_text], true); - } - - if ($item['parent-uri'] === $item['uri']) { + if ($item['gravity'] === GRAVITY_PARENT) { self::addThread($current_post); } else { self::updateThread($parent_id); } - if (!empty($item['origin']) || !empty($item['wall']) || !empty($delivery_data['postopts']) || !empty($delivery_data['inform'])) { - Post\DeliveryData::insert($item['uri-id'], $delivery_data); - } - - DBA::commit(); - - /* - * Due to deadlock issues with the "term" table we are doing these steps after the commit. - * This is not perfect - but a workable solution until we found the reason for the problem. - */ - if (!empty($files)) { - Category::storeTextByURIId($item['uri-id'], $item['uid'], $files); - } - // In that function we check if this is a forum post. Additionally we delete the item under certain circumstances if (self::tagDeliver($item['uid'], $current_post)) { // Get the user information for the logging @@ -1977,7 +1998,7 @@ class Item } } - if ($item['parent-uri'] === $item['uri']) { + if ($item['gravity'] === GRAVITY_PARENT) { self::addShadow($current_post); } else { self::addShadowPost($current_post); @@ -1989,7 +2010,25 @@ class Item check_user_notification($current_post); - if ($notify || ($item['visible'] && ((!empty($parent) && $parent['origin']) || $item['origin']))) { + // Distribute items to users who subscribed to their tags + self::distributeByTags($item); + + // Automatically reshare the item if the "remote_self" option is selected + self::autoReshare($item); + + $transmit = $notify || ($item['visible'] && ($parent_origin || $item['origin'])); + + if ($transmit) { + $transmit_item = Item::selectFirst(['verb', 'origin'], ['id' => $item['id']]); + // Don't relay participation messages + if (($transmit_item['verb'] == Activity::FOLLOW) && + (!$transmit_item['origin'] || ($item['author-id'] != Contact::getPublicIdByUserId($uid)))) { + Logger::info('Participation messages will not be relayed', ['item' => $item['id'], 'uri' => $item['uri'], 'verb' => $transmit_item['verb']]); + $transmit = false; + } + } + + if ($transmit) { Worker::add(['priority' => $priority, 'dont_fork' => true], 'Notifier', $notify_type, $current_post); } @@ -1997,51 +2036,77 @@ class Item } /** - * Insert a new item content entry + * Change the owner of a parent item if it had been shared by a forum * - * @param array $item The item fields that are to be inserted - * @return bool - * @throws \Exception + * (public) forum posts in the new format consist of the regular post by the author + * followed by an announce message sent from the forum account. + * Changing the owner helps in grouping forum posts. + * + * @param array $item + * @return void */ - private static function insertActivity(&$item) + private static function setOwnerforResharedItem(array $item) { - $activity_index = self::activityToIndex($item['verb']); - - if ($activity_index < 0) { - return false; + $parent = self::selectFirst(['id', 'causer-id', 'owner-id', 'author-id', 'author-link', 'origin', 'post-type'], + ['uri-id' => $item['thr-parent-id'], 'uid' => $item['uid']]); + if (!DBA::isResult($parent)) { + Logger::error('Parent not found', ['uri-id' => $item['thr-parent-id'], 'uid' => $item['uid']]); + return; } - $fields = ['activity' => $activity_index, 'uri-hash' => (string)$item['uri-id'], 'uri-id' => $item['uri-id']]; - - // We just remove everything that is content - foreach (array_merge(self::CONTENT_FIELDLIST, self::MIXED_CONTENT_FIELDLIST) as $field) { - unset($item[$field]); + $author = Contact::selectFirst(['url', 'contact-type'], ['id' => $item['author-id']]); + if (!DBA::isResult($author)) { + Logger::error('Author not found', ['id' => $item['author-id']]); + return; } - // To avoid timing problems, we are using locks. - $locked = DI::lock()->acquire('item_insert_activity'); - if (!$locked) { - Logger::log("Couldn't acquire lock for URI " . $item['uri'] . " - proceeding anyway."); + $cid = Contact::getIdForURL($author['url'], $item['uid']); + if (empty($cid) || !Contact::isSharing($cid, $item['uid'])) { + Logger::info('The resharer is not a following contact: quit', ['resharer' => $author['url'], 'uid' => $item['uid']]); + return; } - // Do we already have this content? - $item_activity = DBA::selectFirst('item-activity', ['id'], ['uri-id' => $item['uri-id']]); - if (DBA::isResult($item_activity)) { - $item['iaid'] = $item_activity['id']; - Logger::log('Fetched activity for URI ' . $item['uri'] . ' (' . $item['iaid'] . ')'); - } elseif (DBA::insert('item-activity', $fields)) { - $item['iaid'] = DBA::lastInsertId(); - Logger::log('Inserted activity for URI ' . $item['uri'] . ' (' . $item['iaid'] . ')'); - } else { - // This shouldn't happen. - Logger::log('Could not insert activity for URI ' . $item['uri'] . ' - should not happen'); - DI::lock()->release('item_insert_activity'); - return false; + if ($author['contact-type'] != Contact::TYPE_COMMUNITY) { + if ($parent['post-type'] == self::PT_ANNOUNCEMENT) { + Logger::info('The parent is already marked as announced: quit', ['causer' => $parent['causer-id'], 'owner' => $parent['owner-id'], 'author' => $parent['author-id'], 'uid' => $item['uid']]); + return; + } + + if (Contact::isSharing($parent['owner-id'], $item['uid'])) { + Logger::info('The resharer is no forum: quit', ['resharer' => $item['author-id'], 'owner' => $parent['owner-id'], 'author' => $parent['author-id'], 'uid' => $item['uid']]); + return; + } + self::update(['post-type' => self::PT_ANNOUNCEMENT, 'causer-id' => $item['author-id']], ['id' => $parent['id']]); + Logger::info('Set announcement post-type', ['uri-id' => $item['uri-id'], 'thr-parent-id' => $item['thr-parent-id'], 'uid' => $item['uid']]); + return; } - if ($locked) { - DI::lock()->release('item_insert_activity'); + + self::update(['owner-id' => $item['author-id'], 'contact-id' => $cid], ['id' => $parent['id']]); + Logger::info('Change owner of the parent', ['uri-id' => $item['uri-id'], 'thr-parent-id' => $item['thr-parent-id'], 'uid' => $item['uid'], 'owner-id' => $item['author-id'], 'contact-id' => $cid]); + } + + /** + * Distribute the given item to users who subscribed to their tags + * + * @param array $item Processed item + */ + private static function distributeByTags(array $item) + { + if (($item['uid'] != 0) || ($item['gravity'] != GRAVITY_PARENT) || !in_array($item['network'], Protocol::FEDERATED)) { + return; + } + + $uids = Tag::getUIDListByURIId($item['uri-id']); + foreach ($uids as $uid) { + if (Contact::isSharing($item['author-id'], $uid)) { + $fields = []; + } else { + $fields = ['post-type' => self::PT_TAG]; + } + + $stored = self::storeForUserByUriId($item['uri-id'], $uid, $fields); + Logger::info('Stored item for users', ['uri-id' => $item['uri-id'], 'uid' => $uid, 'fields' => $fields, 'stored' => $stored]); } - return true; } /** @@ -2050,66 +2115,35 @@ class Item * @param array $item The item fields that are to be inserted * @throws \Exception */ - private static function insertContent(&$item) + private static function insertContent(array $item) { $fields = ['uri-plink-hash' => (string)$item['uri-id'], 'uri-id' => $item['uri-id']]; foreach (array_merge(self::CONTENT_FIELDLIST, self::MIXED_CONTENT_FIELDLIST) as $field) { if (isset($item[$field])) { $fields[$field] = $item[$field]; - unset($item[$field]); } } - // To avoid timing problems, we are using locks. - $locked = DI::lock()->acquire('item_insert_content'); - if (!$locked) { - Logger::log("Couldn't acquire lock for URI " . $item['uri'] . " - proceeding anyway."); - } - - // Do we already have this content? $item_content = DBA::selectFirst('item-content', ['id'], ['uri-id' => $item['uri-id']]); if (DBA::isResult($item_content)) { - $item['icid'] = $item_content['id']; - Logger::log('Fetched content for URI ' . $item['uri'] . ' (' . $item['icid'] . ')'); - } elseif (DBA::insert('item-content', $fields)) { - $item['icid'] = DBA::lastInsertId(); - Logger::log('Inserted content for URI ' . $item['uri'] . ' (' . $item['icid'] . ')'); - } else { - // This shouldn't happen. - Logger::log('Could not insert content for URI ' . $item['uri'] . ' - should not happen'); - } - if ($locked) { - DI::lock()->release('item_insert_content'); - } - } - - /** - * Update existing item content entries - * - * @param array $item The item fields that are to be changed - * @param array $condition The condition for finding the item content entries - * @return bool - * @throws \Exception - */ - private static function updateActivity($item, $condition) - { - if (empty($item['verb'])) { - return false; - } - $activity_index = self::activityToIndex($item['verb']); - - if ($activity_index < 0) { - return false; + $icid = $item_content['id']; + Logger::info('Existing content found', ['icid' => $icid, 'uri' => $item['uri']]); + return $icid; } - $fields = ['activity' => $activity_index]; + DBA::replace('item-content', $fields); - Logger::log('Update activity for ' . json_encode($condition)); + $item_content = DBA::selectFirst('item-content', ['id'], ['uri-id' => $item['uri-id']]); + if (DBA::isResult($item_content)) { + $icid = $item_content['id']; + Logger::notice('Content inserted', ['icid' => $icid, 'uri' => $item['uri']]); + return $icid; + } - DBA::update('item-activity', $fields, $condition, true); - - return true; + // This shouldn't happen. + Logger::error("Content wasn't inserted", $item); + return null; } /** @@ -2130,14 +2164,11 @@ class Item } if (empty($fields)) { - // when there are no fields at all, just use the condition - // This is to ensure that we always store content. - $fields = $condition; + return; } - Logger::log('Update content for ' . json_encode($condition)); - DBA::update('item-content', $fields, $condition, true); + Logger::info('Updated content', ['condition' => $condition]); } /** @@ -2166,13 +2197,6 @@ class Item $origin = $item['origin']; - unset($item['id']); - unset($item['parent']); - unset($item['mention']); - unset($item['wall']); - unset($item['origin']); - unset($item['starred']); - $users = []; /// @todo add a field "pcid" in the contact table that referrs to the public contact id. @@ -2204,7 +2228,7 @@ class Item DBA::close($contacts); if (!empty($owner['alias'])) { - $condition = ['url' => $owner['alias'], 'rel' => [Contact::SHARING, Contact::FRIEND]]; + $condition = ['nurl' => Strings::normaliseLink($owner['alias']), 'rel' => [Contact::SHARING, Contact::FRIEND]]; $contacts = DBA::select('contact', ['uid'], $condition); while ($contact = DBA::fetch($contacts)) { if ($contact['uid'] == 0) { @@ -2232,33 +2256,81 @@ class Item if ($origin_uid == $uid) { $item['diaspora_signed_text'] = $signed_text; } - self::storeForUser($itemid, $item, $uid); + self::storeForUser($item, $uid); } } /** - * Store public items for the receivers + * Store a public item defined by their URI-ID for the given users + * + * @param integer $uri_id URI-ID of the given item + * @param integer $uid The user that will receive the item entry + * @param array $fields Additional fields to be stored + * @return integer stored item id + */ + public static function storeForUserByUriId(int $uri_id, int $uid, array $fields = []) + { + $item = self::selectFirst(self::ITEM_FIELDLIST, ['uri-id' => $uri_id, 'uid' => 0]); + if (!DBA::isResult($item)) { + return 0; + } + + if (($item['private'] == self::PRIVATE) || !in_array($item['network'], Protocol::FEDERATED)) { + Logger::notice('Item is private or not from a federated network. It will not be stored for the user.', ['uri-id' => $uri_id, 'uid' => $uid, 'private' => $item['private'], 'network' => $item['network']]); + return 0; + } + + $item['post-type'] = self::PT_STORED; + + $item = array_merge($item, $fields); + + $stored = self::storeForUser($item, $uid); + Logger::info('Public item stored for user', ['uri-id' => $item['uri-id'], 'uid' => $uid, 'stored' => $stored]); + return $stored; + } + + /** + * Store a public item array for the given users * - * @param integer $itemid Item ID that should be added * @param array $item The item entry that will be stored * @param integer $uid The user that will receive the item entry + * @return integer stored item id * @throws \Exception */ - private static function storeForUser($itemid, $item, $uid) + private static function storeForUser(array $item, int $uid) { + if (self::exists(['uri-id' => $item['uri-id'], 'uid' => $uid])) { + Logger::info('Item already exists', ['uri-id' => $item['uri-id'], 'uid' => $uid]); + return 0; + } + + unset($item['id']); + unset($item['parent']); + unset($item['mention']); + unset($item['starred']); + unset($item['unseen']); + unset($item['psid']); + $item['uid'] = $uid; $item['origin'] = 0; $item['wall'] = 0; - if ($item['uri'] == $item['parent-uri']) { - $item['contact-id'] = Contact::getIdForURL($item['owner-link'], $uid); + + if ($item['gravity'] == GRAVITY_PARENT) { + $contact = Contact::getByURLForUser($item['owner-link'], $uid, false, ['id']); } else { - $item['contact-id'] = Contact::getIdForURL($item['author-link'], $uid); + $contact = Contact::getByURLForUser($item['author-link'], $uid, false, ['id']); } - if (empty($item['contact-id'])) { + if (!empty($contact['id'])) { + $item['contact-id'] = $contact['id']; + } else { + // Shouldn't happen at all + Logger::warning('contact-id could not be fetched', ['uid' => $uid, 'item' => $item]); $self = DBA::selectFirst('contact', ['id'], ['self' => true, 'uid' => $uid]); if (!DBA::isResult($self)) { - return; + // Shouldn't happen even less + Logger::warning('self contact could not be fetched', ['uid' => $uid, 'item' => $item]); + return 0; } $item['contact-id'] = $self['id']; } @@ -2266,20 +2338,21 @@ class Item /// @todo Handling of "event-id" $notify = false; - if ($item['uri'] == $item['parent-uri']) { + if ($item['gravity'] == GRAVITY_PARENT) { $contact = DBA::selectFirst('contact', [], ['id' => $item['contact-id'], 'self' => false]); if (DBA::isResult($contact)) { $notify = self::isRemoteSelf($contact, $item); } } - $distributed = self::insert($item, false, $notify, true); + $distributed = self::insert($item, $notify, true); if (!$distributed) { - Logger::log("Distributed public item " . $itemid . " for user " . $uid . " wasn't stored", Logger::DEBUG); + Logger::info("Distributed public item wasn't stored", ['uri-id' => $item['uri-id'], 'user' => $uid]); } else { - Logger::log("Distributed public item " . $itemid . " for user " . $uid . " with id " . $distributed, Logger::DEBUG); + Logger::info('Distributed public item was stored', ['uri-id' => $item['uri-id'], 'user' => $uid, 'stored' => $distributed]); } + return $distributed; } /** @@ -2292,7 +2365,7 @@ class Item * @param integer $itemid Item ID that should be added * @throws \Exception */ - public static function addShadow($itemid) + private static function addShadow($itemid) { $fields = ['uid', 'private', 'moderated', 'visible', 'deleted', 'network', 'uri']; $condition = ['id' => $itemid, 'parent' => [0, $itemid]]; @@ -2334,15 +2407,16 @@ class Item unset($item['starred']); unset($item['postopts']); unset($item['inform']); + unset($item['post-type']); if ($item['uri'] == $item['parent-uri']) { $item['contact-id'] = $item['owner-id']; } else { $item['contact-id'] = $item['author-id']; } - $public_shadow = self::insert($item, false, false, true); + $public_shadow = self::insert($item, false, true); - Logger::log("Stored public shadow for thread ".$itemid." under id ".$public_shadow, Logger::DEBUG); + Logger::info('Stored public shadow', ['thread' => $itemid, 'id' => $public_shadow]); } } @@ -2354,7 +2428,7 @@ class Item * @param integer $itemid Item ID that should be added * @throws \Exception */ - public static function addShadowPost($itemid) + private static function addShadowPost($itemid) { $item = self::selectFirst(self::ITEM_FIELDLIST, ['id' => $itemid]); if (!DBA::isResult($item)) { @@ -2362,7 +2436,7 @@ class Item } // Is it a toplevel post? - if ($item['id'] == $item['parent']) { + if ($item['gravity'] == GRAVITY_PARENT) { self::addShadow($itemid); return; } @@ -2396,11 +2470,12 @@ class Item unset($item['starred']); unset($item['postopts']); unset($item['inform']); + unset($item['post-type']); $item['contact-id'] = Contact::getIdForURL($item['author-link']); - $public_shadow = self::insert($item, false, false, true); + $public_shadow = self::insert($item, false, true); - Logger::log("Stored public shadow for comment ".$item['uri']." under id ".$public_shadow, Logger::DEBUG); + Logger::info('Stored public shadow', ['uri' => $item['uri'], 'id' => $public_shadow]); // If this was a comment to a Diaspora post we don't get our comment back. // This means that we have to distribute the comment by ourselves. @@ -2413,20 +2488,54 @@ class Item * Adds a language specification in a "language" element of given $arr. * Expects "body" element to exist in $arr. * - * @param $item + * @param array $item + * @return string detected language * @throws \Text_LanguageDetect_Exception */ - private static function addLanguageToItemArray(&$item) + private static function getLanguage(array $item) { - $naked_body = BBCode::toPlaintext($item['body'], false); - - $ld = new Text_LanguageDetect(); - $ld->setNameMode(2); - $languages = $ld->detect($naked_body, 3); - - if (is_array($languages)) { - $item['language'] = json_encode($languages); + if (!in_array($item['gravity'], [GRAVITY_PARENT, GRAVITY_COMMENT]) || empty($item['body'])) { + return ''; } + + // Convert attachments to links + $naked_body = BBCode::removeAttachment($item['body']); + if (empty($naked_body)) { + return ''; + } + + // Remove links and pictures + $naked_body = BBCode::removeLinks($naked_body); + + // Convert the title and the body to plain text + $naked_body = trim($item['title'] . "\n" . BBCode::toPlaintext($naked_body)); + + // Remove possibly remaining links + $naked_body = preg_replace(Strings::autoLinkRegEx(), '', $naked_body); + + if (empty($naked_body)) { + return ''; + } + + $ld = new Language(DI::l10n()->getAvailableLanguages()); + $languages = $ld->detect($naked_body)->limit(0, 3)->close(); + if (is_array($languages)) { + return json_encode($languages); + } + + return ''; + } + + public static function getLanguageMessage(array $item) + { + $iso639 = new \Matriphe\ISO639\ISO639; + + $used_languages = ''; + foreach (json_decode($item['language'], true) as $language => $reliability) { + $used_languages .= $iso639->languageByCode1($language) . ' (' . $language . "): " . number_format($reliability, 5) . '\n'; + } + $used_languages = DI::l10n()->t('Detected languages in this post:\n%s', $used_languages); + return $used_languages; } /** @@ -2500,7 +2609,7 @@ class Item } /// @todo On private posts we could obfuscate the date - $update = ($arr['private'] != self::PRIVATE); + $update = ($arr['private'] != self::PRIVATE) || in_array($arr['network'], Protocol::FEDERATED); // Is it a forum? Then we don't care about the rules from above if (!$update && in_array($arr["network"], [Protocol::ACTIVITYPUB, Protocol::DFRN]) && ($arr["parent-uri"] === $arr["uri"])) { @@ -2518,98 +2627,83 @@ class Item } else { $condition = ['id' => $arr['contact-id'], 'self' => false]; } - DBA::update('contact', ['success_update' => $arr['received'], 'last-item' => $arr['received']], $condition); + DBA::update('contact', ['failed' => false, 'success_update' => $arr['received'], 'last-item' => $arr['received']], $condition); } // Now do the same for the system wide contacts with uid=0 if ($arr['private'] != self::PRIVATE) { - DBA::update('contact', ['success_update' => $arr['received'], 'last-item' => $arr['received']], + DBA::update('contact', ['failed' => false, 'success_update' => $arr['received'], 'last-item' => $arr['received']], ['id' => $arr['owner-id']]); if ($arr['owner-id'] != $arr['author-id']) { - DBA::update('contact', ['success_update' => $arr['received'], 'last-item' => $arr['received']], + DBA::update('contact', ['failed' => false, 'success_update' => $arr['received'], 'last-item' => $arr['received']], ['id' => $arr['author-id']]); } } } - public static function setHashtags(&$item) + public static function setHashtags($body) { - $tags = BBCode::getTags($item["body"]); + $body = BBCode::performWithEscapedTags($body, ['noparse', 'pre', 'code'], function ($body) { + $tags = BBCode::getTags($body); - // No hashtags? - if (!count($tags)) { - return false; - } - - // What happens in [code], stays in [code]! - // escape the # and the [ - // hint: we will also get in trouble with #tags, when we want markdown in posts -> ### Headline 3 - $item["body"] = preg_replace_callback("/\[code(.*?)\](.*?)\[\/code\]/ism", - function ($match) { - // we truly ESCape all # and [ to prevent gettin weird tags in [code] blocks - $find = ['#', '[']; - $replace = [chr(27).'sharp', chr(27).'leftsquarebracket']; - return ("[code" . $match[1] . "]" . str_replace($find, $replace, $match[2]) . "[/code]"); - }, $item["body"]); - - // This sorting is important when there are hashtags that are part of other hashtags - // Otherwise there could be problems with hashtags like #test and #test2 - // Because of this we are sorting from the longest to the shortest tag. - usort($tags, function($a, $b) { - return strlen($b) <=> strlen($a); - }); - - $URLSearchString = "^\[\]"; - - // All hashtags should point to the home server if "local_tags" is activated - if (DI::config()->get('system', 'local_tags')) { - $item["body"] = preg_replace("/#\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", - "#[url=".DI::baseUrl()."/search?tag=$2]$2[/url]", $item["body"]); - } - - // mask hashtags inside of url, bookmarks and attachments to avoid urls in urls - $item["body"] = preg_replace_callback("/\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", - function ($match) { - return ("[url=" . str_replace("#", "#", $match[1]) . "]" . str_replace("#", "#", $match[2]) . "[/url]"); - }, $item["body"]); - - $item["body"] = preg_replace_callback("/\[bookmark\=([$URLSearchString]*)\](.*?)\[\/bookmark\]/ism", - function ($match) { - return ("[bookmark=" . str_replace("#", "#", $match[1]) . "]" . str_replace("#", "#", $match[2]) . "[/bookmark]"); - }, $item["body"]); - - $item["body"] = preg_replace_callback("/\[attachment (.*)\](.*?)\[\/attachment\]/ism", - function ($match) { - return ("[attachment " . str_replace("#", "#", $match[1]) . "]" . $match[2] . "[/attachment]"); - }, $item["body"]); - - // Repair recursive urls - $item["body"] = preg_replace("/#\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", - "#$2", $item["body"]); - - foreach ($tags as $tag) { - if ((strpos($tag, '#') !== 0) || strpos($tag, '[url=') || strlen($tag) < 2 || $tag[1] == '#') { - continue; + // No hashtags? + if (!count($tags)) { + return $body; } - $basetag = str_replace('_',' ',substr($tag,1)); - $newtag = '#[url=' . DI::baseUrl() . '/search?tag=' . $basetag . ']' . $basetag . '[/url]'; + // This sorting is important when there are hashtags that are part of other hashtags + // Otherwise there could be problems with hashtags like #test and #test2 + // Because of this we are sorting from the longest to the shortest tag. + usort($tags, function ($a, $b) { + return strlen($b) <=> strlen($a); + }); - $item["body"] = str_replace($tag, $newtag, $item["body"]); - } + $URLSearchString = "^\[\]"; - // Convert back the masked hashtags - $item["body"] = str_replace("#", "#", $item["body"]); + // All hashtags should point to the home server if "local_tags" is activated + if (DI::config()->get('system', 'local_tags')) { + $body = preg_replace("/#\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", + "#[url=" . DI::baseUrl() . "/search?tag=$2]$2[/url]", $body); + } - // Remember! What happens in [code], stays in [code] - // roleback the # and [ - $item["body"] = preg_replace_callback("/\[code(.*?)\](.*?)\[\/code\]/ism", - function ($match) { - // we truly unESCape all sharp and leftsquarebracket - $find = [chr(27).'sharp', chr(27).'leftsquarebracket']; - $replace = ['#', '[']; - return ("[code" . $match[1] . "]" . str_replace($find, $replace, $match[2]) . "[/code]"); - }, $item["body"]); + // mask hashtags inside of url, bookmarks and attachments to avoid urls in urls + $body = preg_replace_callback("/\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", + function ($match) { + return ("[url=" . str_replace("#", "#", $match[1]) . "]" . str_replace("#", "#", $match[2]) . "[/url]"); + }, $body); + + $body = preg_replace_callback("/\[bookmark\=([$URLSearchString]*)\](.*?)\[\/bookmark\]/ism", + function ($match) { + return ("[bookmark=" . str_replace("#", "#", $match[1]) . "]" . str_replace("#", "#", $match[2]) . "[/bookmark]"); + }, $body); + + $body = preg_replace_callback("/\[attachment (.*)\](.*?)\[\/attachment\]/ism", + function ($match) { + return ("[attachment " . str_replace("#", "#", $match[1]) . "]" . $match[2] . "[/attachment]"); + }, $body); + + // Repair recursive urls + $body = preg_replace("/#\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", + "#$2", $body); + + foreach ($tags as $tag) { + if ((strpos($tag, '#') !== 0) || strpos($tag, '[url=') || strlen($tag) < 2 || $tag[1] == '#') { + continue; + } + + $basetag = str_replace('_', ' ', substr($tag, 1)); + $newtag = '#[url=' . DI::baseUrl() . '/search?tag=' . $basetag . ']' . $basetag . '[/url]'; + + $body = str_replace($tag, $newtag, $body); + } + + // Convert back the masked hashtags + $body = str_replace("#", "#", $body); + + return $body; + }); + + return $body; } /** @@ -2656,9 +2750,19 @@ class Item } } + if (!$mention) { + $tags = Tag::getByURIId($item['uri-id'], [Tag::MENTION, Tag::EXCLUSIVE_MENTION]); + foreach ($tags as $tag) { + if (Strings::compareLink($link, $tag['url']) || Strings::compareLink($dlink, $tag['url'])) { + $mention = true; + DI::logger()->info('mention found in tag.', ['url' => $tag['url']]); + } + } + } + if (!$mention) { if (($community_page || $prvgroup) && - !$item['wall'] && !$item['origin'] && ($item['id'] == $item['parent'])) { + !$item['wall'] && !$item['origin'] && ($item['gravity'] == GRAVITY_PARENT)) { Logger::info('Delete private group/communiy top-level item without mention', ['id' => $item_id, 'guid'=> $item['guid']]); DBA::delete('item', ['id' => $item_id]); return true; @@ -2709,13 +2813,38 @@ class Item 'owner-id' => $owner_id, 'private' => $private, 'psid' => $psid]; self::update($fields, ['id' => $item_id]); - self::updateThread($item_id); - Worker::add(['priority' => PRIORITY_HIGH, 'dont_fork' => true], 'Notifier', Delivery::POST, $item_id); + Item::performActivity($item_id, 'announce', $uid); + return false; } + /** + * Automatically reshare the item if the "remote_self" option is selected + * + * @param array $item + * @return void + */ + private static function autoReshare(array $item) + { + if ($item['gravity'] != GRAVITY_PARENT) { + return; + } + + if (!DBA::exists('contact', ['id' => $item['contact-id'], 'remote_self' => Contact::MIRROR_NATIVE_RESHARE])) { + return; + } + + if (!in_array($item['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN])) { + return; + } + + Logger::info('Automatically reshare item', ['uid' => $item['uid'], 'id' => $item['id'], 'guid' => $item['guid'], 'uri-id' => $item['uri-id']]); + + Item::performActivity($item['id'], 'announce', $item['uid']); + } + public static function isRemoteSelf($contact, &$datarray) { if (!$contact['remote_self']) { @@ -2724,30 +2853,30 @@ class Item // Prevent the forwarding of posts that are forwarded if (!empty($datarray["extid"]) && ($datarray["extid"] == Protocol::DFRN)) { - Logger::log('Already forwarded', Logger::DEBUG); + Logger::info('Already forwarded'); return false; } // Prevent to forward already forwarded posts if ($datarray["app"] == DI::baseUrl()->getHostname()) { - Logger::log('Already forwarded (second test)', Logger::DEBUG); + Logger::info('Already forwarded (second test)'); return false; } // Only forward posts if ($datarray["verb"] != Activity::POST) { - Logger::log('No post', Logger::DEBUG); + Logger::info('No post'); return false; } if (($contact['network'] != Protocol::FEED) && ($datarray['private'] == self::PRIVATE)) { - Logger::log('Not public', Logger::DEBUG); + Logger::info('Not public'); return false; } $datarray2 = $datarray; - Logger::log('remote-self start - Contact '.$contact['url'].' - '.$contact['remote_self'].' Item '.print_r($datarray, true), Logger::DEBUG); - if ($contact['remote_self'] == 2) { + Logger::info('remote-self start', ['contact' => $contact['url'], 'remote_self'=> $contact['remote_self'], 'item' => $datarray]); + if ($contact['remote_self'] == Contact::MIRROR_OWN_POST) { $self = DBA::selectFirst('contact', ['id', 'name', 'url', 'thumb'], ['uid' => $contact['uid'], 'self' => true]); if (DBA::isResult($self)) { @@ -2769,14 +2898,20 @@ class Item } if ($contact['network'] != Protocol::FEED) { + $old_uri_id = $datarray["uri-id"] ?? 0; $datarray["guid"] = System::createUUID(); unset($datarray["plink"]); $datarray["uri"] = self::newURI($contact['uid'], $datarray["guid"]); - $datarray["parent-uri"] = $datarray["uri"]; - $datarray["thr-parent"] = $datarray["uri"]; + $datarray["uri-id"] = ItemURI::getIdByURI($datarray["uri"]); $datarray["extid"] = Protocol::DFRN; $urlpart = parse_url($datarray2['author-link']); $datarray["app"] = $urlpart["host"]; + if (!empty($old_uri_id)) { + Post\Media::copy($old_uri_id, $datarray["uri-id"]); + } + + unset($datarray["parent-uri"]); + unset($datarray["thr-parent"]); } else { $datarray['private'] = self::PUBLIC; } @@ -2784,8 +2919,8 @@ class Item if ($contact['network'] != Protocol::FEED) { // Store the original post - $result = self::insert($datarray2, false, false); - Logger::log('remote-self post original item - Contact '.$contact['url'].' return '.$result.' Item '.print_r($datarray2, true), Logger::DEBUG); + $result = self::insert($datarray2); + Logger::info('remote-self post original item', ['contact' => $contact['url'], 'result'=> $result, 'item' => $datarray2]); } else { $datarray["app"] = "Feed"; $result = true; @@ -2795,10 +2930,10 @@ class Item $datarray['api_source'] = true; // We have to tell the hooks who we are - this really should be improved - $_SESSION["authenticated"] = true; - $_SESSION["uid"] = $contact['uid']; + $_SESSION['authenticated'] = true; + $_SESSION['uid'] = $contact['uid']; - return $result; + return (bool)$result; } /** @@ -2817,7 +2952,7 @@ class Item return $s; } - Logger::log('check for photos', Logger::DEBUG); + Logger::info('check for photos'); $site = substr(DI::baseUrl(), strpos(DI::baseUrl(), '://')); $orig_body = $s; @@ -2831,7 +2966,7 @@ class Item $img_st_close++; // make it point to AFTER the closing bracket $image = substr($orig_body, $img_start + $img_st_close, $img_len); - Logger::log('found photo ' . $image, Logger::DEBUG); + Logger::info('found photo', ['image' => $image]); if (stristr($image, $site . '/photo/')) { // Only embed locally hosted photos @@ -2870,7 +3005,7 @@ class Item $photo_img = Photo::getImageForPhoto($photo); // If a custom width and height were specified, apply before embedding if (preg_match("/\[img\=([0-9]*)x([0-9]*)\]/is", substr($orig_body, $img_start, $img_st_close), $match)) { - Logger::log('scaling photo', Logger::DEBUG); + Logger::info('scaling photo'); $width = intval($match[1]); $height = intval($match[2]); @@ -2881,9 +3016,9 @@ class Item $data = $photo_img->asString(); $type = $photo_img->getType(); - Logger::log('replacing photo', Logger::DEBUG); + Logger::info('replacing photo'); $image = 'data:' . $type . ';base64,' . base64_encode($data); - Logger::log('replaced: ' . $image, Logger::DATA); + Logger::debug('replaced', ['image' => $image]); } } } @@ -2953,13 +3088,13 @@ class Item return $recipients; } - public static function expire($uid, $days, $network = "", $force = false) + public static function expire(int $uid, int $days, string $network = "", bool $force = false) { if (!$uid || ($days < 1)) { return; } - $condition = ["`uid` = ? AND NOT `deleted` AND `id` = `parent` AND `gravity` = ?", + $condition = ["`uid` = ? AND NOT `deleted` AND `gravity` = ?", $uid, GRAVITY_PARENT]; /* @@ -2999,6 +3134,8 @@ class Item $expired = 0; + $priority = DI::config()->get('system', 'expire-notify-priority'); + while ($item = Item::fetch($items)) { // don't expire filed items @@ -3018,7 +3155,7 @@ class Item continue; } - self::markForDeletionById($item['id'], PRIORITY_LOW); + self::markForDeletionById($item['id'], $priority); ++$expired; } @@ -3042,11 +3179,12 @@ class Item * * Toggle activities as like,dislike,attend of an item * - * @param string $item_id + * @param int $item_id * @param string $verb * Activity verb. One of * like, unlike, dislike, undislike, attendyes, unattendyes, - * attendno, unattendno, attendmaybe, unattendmaybe + * attendno, unattendno, attendmaybe, unattendmaybe, + * announce, unannouce * @return bool * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException @@ -3054,12 +3192,52 @@ class Item * array $arr * 'post_id' => ID of posted item */ - public static function performActivity($item_id, $verb) + public static function performActivity(int $item_id, string $verb, int $uid) { - if (!Session::isAuthenticated()) { + if (empty($uid)) { return false; } + Logger::notice('Start create activity', ['verb' => $verb, 'item' => $item_id, 'user' => $uid]); + + $item = self::selectFirst(self::ITEM_FIELDLIST, ['id' => $item_id]); + if (!DBA::isResult($item)) { + Logger::log('like: unknown item ' . $item_id); + return false; + } + + $item_uri = $item['uri']; + + if (!in_array($item['uid'], [0, $uid])) { + return false; + } + + if (!Item::exists(['uri-id' => $item['parent-uri-id'], 'uid' => $uid])) { + $stored = self::storeForUserByUriId($item['parent-uri-id'], $uid); + if (($item['parent-uri-id'] == $item['uri-id']) && !empty($stored)) { + $item = self::selectFirst(self::ITEM_FIELDLIST, ['id' => $stored]); + if (!DBA::isResult($item)) { + Logger::info('Could not fetch just created item - should not happen', ['stored' => $stored, 'uid' => $uid, 'item-uri' => $item_uri]); + return false; + } + } + } + + // Retrieves the local post owner + $owner = User::getOwnerDataById($uid); + if (empty($owner)) { + Logger::info('Empty owner for user', ['uid' => $uid]); + return false; + } + + // Retrieve the current logged in user's public contact + $author_id = Contact::getIdForURL($owner['url']); + if (empty($author_id)) { + Logger::info('Empty public contact'); + return false; + } + + $activity = null; switch ($verb) { case 'like': case 'unlike': @@ -3085,93 +3263,73 @@ class Item case 'unfollow': $activity = Activity::FOLLOW; break; + case 'announce': + case 'unannounce': + $activity = Activity::ANNOUNCE; + break; default: - Logger::log('like: unknown verb ' . $verb . ' for item ' . $item_id); + Logger::notice('unknown verb', ['verb' => $verb, 'item' => $item_id]); return false; } + $mode = Strings::startsWith($verb, 'un') ? 'delete' : 'create'; + // Enable activity toggling instead of on/off $event_verb_flag = $activity === Activity::ATTEND || $activity === Activity::ATTENDNO || $activity === Activity::ATTENDMAYBE; - Logger::log('like: verb ' . $verb . ' item ' . $item_id); - - $item = self::selectFirst(self::ITEM_FIELDLIST, ['`id` = ? OR `uri` = ?', $item_id, $item_id]); - if (!DBA::isResult($item)) { - Logger::log('like: unknown item ' . $item_id); - return false; - } - - $item_uri = $item['uri']; - - $uid = $item['uid']; - if (($uid == 0) && local_user()) { - $uid = local_user(); - } - - if (!Security::canWriteToUserWall($uid)) { - Logger::log('like: unable to write on wall ' . $uid); - return false; - } - - // Retrieves the local post owner - $owner_self_contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'self' => true]); - if (!DBA::isResult($owner_self_contact)) { - Logger::log('like: unknown owner ' . $uid); - return false; - } - - // Retrieve the current logged in user's public contact - $author_id = public_contact(); - - $author_contact = DBA::selectFirst('contact', ['url'], ['id' => $author_id]); - if (!DBA::isResult($author_contact)) { - Logger::log('like: unknown author ' . $author_id); - return false; - } - - // Contact-id is the uid-dependant author contact - if (local_user() == $uid) { - $item_contact_id = $owner_self_contact['id']; - } else { - $item_contact_id = Contact::getIdForURL($author_contact['url'], $uid, true); - $item_contact = DBA::selectFirst('contact', [], ['id' => $item_contact_id]); - if (!DBA::isResult($item_contact)) { - Logger::log('like: unknown item contact ' . $item_contact_id); - return false; - } - } - // Look for an existing verb row - // event participation are essentially radio toggles. If you make a subsequent choice, - // we need to eradicate your first choice. + // Event participation activities are mutually exclusive, only one of them can exist at all times. if ($event_verb_flag) { $verbs = [Activity::ATTEND, Activity::ATTENDNO, Activity::ATTENDMAYBE]; // Translate to the index based activity index - $activities = []; + $vids = []; foreach ($verbs as $verb) { - $activities[] = self::activityToIndex($verb); + $vids[] = Verb::getID($verb); } } else { - $activities = self::activityToIndex($activity); + $vids = Verb::getID($activity); } - $condition = ['activity' => $activities, 'deleted' => false, 'gravity' => GRAVITY_ACTIVITY, + $condition = ['vid' => $vids, 'deleted' => false, 'gravity' => GRAVITY_ACTIVITY, 'author-id' => $author_id, 'uid' => $item['uid'], 'thr-parent' => $item_uri]; - $like_item = self::selectFirst(['id', 'guid', 'verb'], $condition); - // If it exists, mark it as deleted if (DBA::isResult($like_item)) { - self::markForDeletionById($like_item['id']); + /** + * Truth table for existing activities + * + * | Inputs || Outputs | + * |----------------------------||-------------------| + * | Mode | Event | Same verb || Delete? | Return? | + * |--------|-------|-----------||---------|---------| + * | create | Yes | Yes || No | Yes | + * | create | Yes | No || Yes | No | + * | create | No | Yes || No | Yes | + * | create | No | No || N/A† | + * | delete | Yes | Yes || Yes | N/A‡ | + * | delete | Yes | No || No | N/A‡ | + * | delete | No | Yes || Yes | N/A‡ | + * | delete | No | No || N/A† | + * |--------|-------|-----------||---------|---------| + * | A | B | C || A xor C | !B or C | + * + * † Can't happen: It's impossible to find an existing non-event activity without + * the same verb because we are only looking for this single verb. + * + * ‡ The "mode = delete" is returning early whether an existing activity was found or not. + */ + if ($mode == 'create' xor $like_item['verb'] == $activity) { + self::markForDeletionById($like_item['id']); + } if (!$event_verb_flag || $like_item['verb'] == $activity) { return true; } } - // Verb is "un-something", just trying to delete existing entries - if (strpos($verb, 'un') === 0) { + // No need to go further if we aren't creating anything + if ($mode == 'delete') { return true; } @@ -3181,13 +3339,14 @@ class Item 'guid' => System::createUUID(), 'uri' => self::newURI($item['uid']), 'uid' => $item['uid'], - 'contact-id' => $item_contact_id, + 'contact-id' => $owner['id'], 'wall' => $item['wall'], 'origin' => 1, 'network' => Protocol::DFRN, + 'protocol' => Conversation::PARCEL_DIRECT, + 'direction' => Conversation::PUSH, 'gravity' => GRAVITY_ACTIVITY, 'parent' => $item['id'], - 'parent-uri' => $item['uri'], 'thr-parent' => $item['uri'], 'owner-id' => $author_id, 'author-id' => $author_id, @@ -3224,7 +3383,7 @@ class Item private static function addThread($itemid, $onlyshadow = false) { $fields = ['uid', 'created', 'edited', 'commented', 'received', 'changed', 'wall', 'private', 'pubmail', - 'moderated', 'visible', 'starred', 'contact-id', 'post-type', + 'moderated', 'visible', 'starred', 'contact-id', 'post-type', 'uri-id', 'deleted', 'origin', 'forum_mode', 'mention', 'network', 'author-id', 'owner-id']; $condition = ["`id` = ? AND (`parent` = ? OR `parent` = 0)", $itemid, $itemid]; $item = self::selectFirst($fields, $condition); @@ -3236,20 +3395,19 @@ class Item $item['iid'] = $itemid; if (!$onlyshadow) { - $result = DBA::insert('thread', $item); + $result = DBA::replace('thread', $item); - Logger::log("Add thread for item ".$itemid." - ".print_r($result, true), Logger::DEBUG); + Logger::info('Add thread', ['item' => $itemid, 'result' => $result]); } } private static function updateThread($itemid, $setmention = false) { $fields = ['uid', 'guid', 'created', 'edited', 'commented', 'received', 'changed', 'post-type', - 'wall', 'private', 'pubmail', 'moderated', 'visible', 'starred', 'contact-id', + 'wall', 'private', 'pubmail', 'moderated', 'visible', 'starred', 'contact-id', 'uri-id', 'deleted', 'origin', 'forum_mode', 'network', 'author-id', 'owner-id']; - $condition = ["`id` = ? AND (`parent` = ? OR `parent` = 0)", $itemid, $itemid]; - $item = self::selectFirst($fields, $condition); + $item = self::selectFirst($fields, ['id' => $itemid, 'gravity' => GRAVITY_PARENT]); if (!DBA::isResult($item)) { return; } @@ -3268,20 +3426,20 @@ class Item $result = DBA::update('thread', $fields, ['iid' => $itemid]); - Logger::log("Update thread for item ".$itemid." - guid ".$item["guid"]." - ".(int)$result, Logger::DEBUG); + Logger::info('Update thread', ['item' => $itemid, 'guid' => $item["guid"], 'result' => $result]); } private static function deleteThread($itemid, $itemuri = "") { $item = DBA::selectFirst('thread', ['uid'], ['iid' => $itemid]); if (!DBA::isResult($item)) { - Logger::log('No thread found for id '.$itemid, Logger::DEBUG); + Logger::info('No thread found', ['id' => $itemid]); return; } $result = DBA::delete('thread', ['iid' => $itemid], ['cascade' => false]); - Logger::log("deleteThread: Deleted thread for item ".$itemid." - ".print_r($result, true), Logger::DEBUG); + Logger::info('Deleted thread', ['item' => $itemid, 'result' => $result]); if ($itemuri != "") { $condition = ["`uri` = ? AND NOT `deleted` AND NOT (`uid` IN (?, 0))", $itemuri, $item["uid"]]; @@ -3292,6 +3450,37 @@ class Item } } + /** + * Fetch the SQL condition for the given user id + * + * @param integer $owner_id User ID for which the permissions should be fetched + * @return array condition + */ + public static function getPermissionsConditionArrayByUserId(int $owner_id) + { + $local_user = local_user(); + $remote_user = Session::getRemoteContactID($owner_id); + + // default permissions - anonymous user + $condition = ["`private` != ?", self::PRIVATE]; + + if ($local_user && ($local_user == $owner_id)) { + // Profile owner - everything is visible + $condition = []; + } elseif ($remote_user) { + // Authenticated visitor - fetch the matching permissionsets + $set = PermissionSet::get($owner_id, $remote_user); + if (!empty($set)) { + $condition = ["(`private` != ? OR (`private` = ? AND `wall` + AND `psid` IN (" . implode(', ', array_fill(0, count($set), '?')) . ")))", + Item::PRIVATE, Item::PRIVATE]; + $condition = array_merge($condition, $set); + } + } + + return $condition; + } + public static function getPermissionsSQLByUserId($owner_id) { $local_user = local_user(); @@ -3341,9 +3530,9 @@ class Item return DI::l10n()->t('event'); } elseif (!empty($item['resource-id'])) { return DI::l10n()->t('photo'); - } elseif (!empty($item['verb']) && $item['verb'] !== Activity::POST) { + } elseif ($item['gravity'] == GRAVITY_ACTIVITY) { return DI::l10n()->t('activity'); - } elseif ($item['id'] != $item['parent']) { + } elseif ($item['gravity'] == GRAVITY_COMMENT) { return DI::l10n()->t('comment'); } @@ -3363,20 +3552,21 @@ class Item */ public static function putInCache(&$item, $update = false) { - $body = $item["body"]; + // Save original body to prevent addons to modify it + $body = $item['body']; $rendered_hash = $item['rendered-hash'] ?? ''; $rendered_html = $item['rendered-html'] ?? ''; if ($rendered_hash == '' - || $rendered_html == "" - || $rendered_hash != hash("md5", $item["body"]) - || DI::config()->get("system", "ignore_cache") + || $rendered_html == '' + || $rendered_hash != hash('md5', BBCode::VERSION . '::' . $body) + || DI::config()->get('system', 'ignore_cache') ) { self::addRedirToImageTags($item); - $item["rendered-html"] = BBCode::convert($item["body"]); - $item["rendered-hash"] = hash("md5", $item["body"]); + $item['rendered-html'] = BBCode::convert($item['body']); + $item['rendered-hash'] = hash('md5', BBCode::VERSION . '::' . $body); $hook_data = ['item' => $item, 'rendered-html' => $item['rendered-html'], 'rendered-hash' => $item['rendered-hash']]; Hook::callAll('put_item_in_cache', $hook_data); @@ -3385,27 +3575,27 @@ class Item unset($hook_data); // Force an update if the generated values differ from the existing ones - if ($rendered_hash != $item["rendered-hash"]) { + if ($rendered_hash != $item['rendered-hash']) { $update = true; } // Only compare the HTML when we forcefully ignore the cache - if (DI::config()->get("system", "ignore_cache") && ($rendered_html != $item["rendered-html"])) { + if (DI::config()->get('system', 'ignore_cache') && ($rendered_html != $item['rendered-html'])) { $update = true; } - if ($update && !empty($item["id"])) { + if ($update && !empty($item['id'])) { self::update( [ - 'rendered-html' => $item["rendered-html"], - 'rendered-hash' => $item["rendered-hash"] + 'rendered-html' => $item['rendered-html'], + 'rendered-hash' => $item['rendered-hash'] ], - ['id' => $item["id"]] + ['id' => $item['id']] ); } } - $item["body"] = $body; + $item['body'] = $body; } /** @@ -3512,12 +3702,10 @@ class Item $as = ''; $vhead = false; - $matches = []; - preg_match_all('|\[attach\]href=\"(.*?)\" length=\"(.*?)\" type=\"(.*?)\"(?: title=\"(.*?)\")?|', $item['attach'], $matches, PREG_SET_ORDER); - foreach ($matches as $mtch) { - $mime = $mtch[3]; + foreach (Post\Media::getByURIId($item['uri-id'], [Post\Media::DOCUMENT, Post\Media::TORRENT, Post\Media::UNKNOWN]) as $attachment) { + $mime = $attachment['mimetype']; - $the_url = Contact::magicLinkById($item['author-id'], $mtch[1]); + $the_url = Contact::magicLinkById($item['author-id'], $attachment['url']); if (strpos($mime, 'video') !== false) { if (!$vhead) { @@ -3525,11 +3713,9 @@ class Item DI::page()['htmlhead'] .= Renderer::replaceMacros(Renderer::getMarkupTemplate('videos_head.tpl')); } - $url_parts = explode('/', $the_url); - $id = end($url_parts); $as .= Renderer::replaceMacros(Renderer::getMarkupTemplate('video_top.tpl'), [ '$video' => [ - 'id' => $id, + 'id' => $item['author-id'], 'title' => DI::l10n()->t('View Video'), 'src' => $the_url, 'mime' => $mime, @@ -3546,8 +3732,8 @@ class Item $filesubtype = 'unkn'; } - $title = Strings::escapeHtml(trim(($mtch[4] ?? '') ?: $mtch[1])); - $title .= ' ' . $mtch[2] . ' ' . DI::l10n()->t('bytes'); + $title = Strings::escapeHtml(trim(($attachment['description'] ?? '') ?: $attachment['url'])); + $title .= ' ' . ($attachment['size'] ?? 0) . ' ' . DI::l10n()->t('bytes'); $icon = '
    '; $as .= '' . $icon . ''; @@ -3588,9 +3774,7 @@ class Item */ public static function getPlink($item) { - $a = DI::app(); - - if ($a->user['nickname'] != "") { + if (local_user()) { $ret = [ 'href' => "display/" . $item['guid'], 'orig' => "display/" . $item['guid'], @@ -3602,7 +3786,6 @@ class Item $ret["href"] = DI::baseUrl()->remove($item['plink']); $ret["title"] = DI::l10n()->t('link to source'); } - } elseif (!empty($item['plink']) && ($item['private'] != self::PRIVATE)) { $ret = [ 'href' => $item['plink'], @@ -3717,10 +3900,12 @@ class Item * * @return integer item id */ - public static function fetchByLink($uri, $uid = 0) + public static function fetchByLink(string $uri, int $uid = 0) { + Logger::info('Trying to fetch link', ['uid' => $uid, 'uri' => $uri]); $item_id = self::searchByLink($uri, $uid); if (!empty($item_id)) { + Logger::info('Link found', ['uid' => $uid, 'uri' => $uri, 'id' => $item_id]); return $item_id; } @@ -3731,9 +3916,11 @@ class Item } if (!empty($item_id)) { + Logger::info('Link fetched', ['uid' => $uid, 'uri' => $uri, 'id' => $item_id]); return $item_id; } + Logger::info('Link not found', ['uid' => $uid, 'uri' => $uri]); return 0; } @@ -3770,7 +3957,7 @@ class Item * * @return array item array with data from the original item */ - public static function addShareDataFromOriginal($item) + public static function addShareDataFromOriginal(array $item) { $shared = self::getShareArray($item); if (empty($shared)) { @@ -3785,20 +3972,20 @@ class Item $uid = $item['uid'] ?? 0; // first try to fetch the item via the GUID. This will work for all reshares that had been created on this system - $shared_item = self::selectFirst(['title', 'body', 'attach'], ['guid' => $shared['guid'], 'uid' => [0, $uid]]); + $shared_item = self::selectFirst(['title', 'body'], ['guid' => $shared['guid'], 'uid' => [0, $uid]]); if (!DBA::isResult($shared_item)) { if (empty($shared['link'])) { return $item; } // Otherwhise try to find (and possibly fetch) the item via the link. This should work for Diaspora and ActivityPub posts - $id = self::fetchByLink($shared['link'], $uid); + $id = self::fetchByLink($shared['link'] ?? '', $uid); if (empty($id)) { - Logger::info('Original item not found', ['url' => $shared['link'], 'callstack' => System::callstack()]); + Logger::info('Original item not found', ['url' => $shared['link'] ?? '', 'callstack' => System::callstack()]); return $item; } - $shared_item = self::selectFirst(['title', 'body', 'attach'], ['id' => $id]); + $shared_item = self::selectFirst(['title', 'body'], ['id' => $id]); if (!DBA::isResult($shared_item)) { return $item; } @@ -3819,4 +4006,41 @@ class Item return array_merge($item, $shared_item); } + + /** + * Check a prospective item array against user-level permissions + * + * @param array $item Expected keys: uri, gravity, and + * author-link if is author-id is set, + * owner-link if is owner-id is set, + * causer-link if is causer-id is set. + * @param int $user_id Local user ID + * @return bool + * @throws \Exception + */ + protected static function isAllowedByUser(array $item, int $user_id) + { + if (!empty($item['author-id']) && Contact\User::isBlocked($item['author-id'], $user_id)) { + Logger::notice('Author is blocked by user', ['author-link' => $item['author-link'], 'uid' => $user_id, 'item-uri' => $item['uri']]); + return false; + } + + if (!empty($item['owner-id']) && Contact\User::isBlocked($item['owner-id'], $user_id)) { + Logger::notice('Owner is blocked by user', ['owner-link' => $item['owner-link'], 'uid' => $user_id, 'item-uri' => $item['uri']]); + return false; + } + + // The causer is set during a thread completion, for example because of a reshare. It countains the responsible actor. + if (!empty($item['causer-id']) && Contact\User::isBlocked($item['causer-id'], $user_id)) { + Logger::notice('Causer is blocked by user', ['causer-link' => $item['causer-link'] ?? $item['causer-id'], 'uid' => $user_id, 'item-uri' => $item['uri']]); + return false; + } + + if (!empty($item['causer-id']) && ($item['gravity'] === GRAVITY_PARENT) && Contact\User::isIgnored($item['causer-id'], $user_id)) { + Logger::notice('Causer is ignored by user', ['causer-link' => $item['causer-link'] ?? $item['causer-id'], 'uid' => $user_id, 'item-uri' => $item['uri']]); + return false; + } + + return true; + } } diff --git a/src/Model/ItemContent.php b/src/Model/ItemContent.php index c6d3e82944..ad402ceceb 100644 --- a/src/Model/ItemContent.php +++ b/src/Model/ItemContent.php @@ -22,11 +22,60 @@ namespace Friendica\Model; use Friendica\Content\Text; +use Friendica\Content\Text\BBCode; use Friendica\Core\Protocol; +use Friendica\Database\DBA; use Friendica\DI; class ItemContent { + /** + * Search posts for given content + * + * @param string $search + * @param integer $uid + * @param integer $start + * @param integer $limit + * @param integer $last_uriid + * @return array + */ + public static function getURIIdListBySearch(string $search, int $uid = 0, int $start = 0, int $limit = 100, int $last_uriid = 0) + { + $condition = ["`uri-id` IN (SELECT `uri-id` FROM `item-content` WHERE MATCH (`title`, `content-warning`, `body`) AGAINST (? IN BOOLEAN MODE)) + AND (NOT `private` OR (`private` AND `uid` = ?)) + AND `uri-id` IN (SELECT `uri-id` FROM `item` WHERE `network` IN (?, ?, ?, ?))", + $search, $uid, Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA, Protocol::OSTATUS]; + + if (!empty($last_uriid)) { + $condition = DBA::mergeConditions($condition, ["`uri-id` < ?", $last_uriid]); + } + + $params = [ + 'order' => ['uri-id' => true], + 'group_by' => ['uri-id'], + 'limit' => [$start, $limit] + ]; + + $tags = DBA::select('item', ['uri-id'], $condition, $params); + + $uriids = []; + while ($tag = DBA::fetch($tags)) { + $uriids[] = $tag['uri-id']; + } + DBA::close($tags); + + return $uriids; + } + + public static function countBySearch(string $search, int $uid = 0) + { + $condition = ["`uri-id` IN (SELECT `uri-id` FROM `item-content` WHERE MATCH (`title`, `content-warning`, `body`) AGAINST (? IN BOOLEAN MODE)) + AND (NOT `private` OR (`private` AND `uid` = ?)) + AND `uri-id` IN (SELECT `uri-id` FROM `item` WHERE `network` IN (?, ?, ?, ?))", + $search, $uid, Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA, Protocol::OSTATUS]; + return DBA::count('item', $condition); + } + /** * Convert a message into plaintext for connectors to other networks * @@ -41,7 +90,7 @@ class ItemContent * @see \Friendica\Content\Text\BBCode::getAttachedData * */ - public static function getPlaintextPost($item, $limit = 0, $includedlinks = false, $htmlmode = 2, $target_network = '') + public static function getPlaintextPost($item, $limit = 0, $includedlinks = false, $htmlmode = BBCode::API, $target_network = '') { // Remove hashtags $URLSearchString = '^\[\]'; @@ -79,11 +128,11 @@ class ItemContent } } else {// Try to guess the correct target network switch ($htmlmode) { - case 8: + case BBCode::TWITTER: $abstract = Text\BBCode::getAbstract($item['body'], Protocol::TWITTER); break; - case 7: + case BBCode::OSTATUS: $abstract = Text\BBCode::getAbstract($item['body'], Protocol::STATUSNET); break; @@ -139,8 +188,8 @@ class ItemContent $msg = trim(str_replace($link, '', $msg)); } elseif (($limit == 0) || ($pos < $limit)) { // The limit has to be increased since it will be shortened - but not now - // Only do it with Twitter (htmlmode = 8) - if (($limit > 0) && (strlen($link) > 23) && ($htmlmode == 8)) { + // Only do it with Twitter + if (($limit > 0) && (strlen($link) > 23) && ($htmlmode == BBCode::TWITTER)) { $limit = $limit - 23 + strlen($link); } diff --git a/src/Model/ItemURI.php b/src/Model/ItemURI.php index 12e8d915df..6421b2dbd9 100644 --- a/src/Model/ItemURI.php +++ b/src/Model/ItemURI.php @@ -21,6 +21,7 @@ namespace Friendica\Model; +use Friendica\Database\Database; use Friendica\Database\DBA; class ItemURI @@ -30,16 +31,16 @@ class ItemURI * * @param array $fields Item-uri fields * - * @return integer item-uri id + * @return int|null item-uri id * @throws \Exception */ - public static function insert($fields) + public static function insert(array $fields) { // If the URI gets too long we only take the first parts and hope for best $uri = substr($fields['uri'], 0, 255); if (!DBA::exists('item-uri', ['uri' => $uri])) { - DBA::insert('item-uri', $fields, true); + DBA::insert('item-uri', $fields, Database::INSERT_UPDATE); } $itemuri = DBA::selectFirst('item-uri', ['id', 'guid'], ['uri' => $uri]); diff --git a/src/Model/Mail.php b/src/Model/Mail.php index 848b419bef..2670a5885f 100644 --- a/src/Model/Mail.php +++ b/src/Model/Mail.php @@ -27,7 +27,6 @@ use Friendica\Core\Worker; use Friendica\DI; use Friendica\Database\DBA; use Friendica\Model\Notify\Type; -use Friendica\Network\Probe; use Friendica\Protocol\Activity; use Friendica\Util\DateTimeFormat; use Friendica\Worker\Delivery; @@ -85,19 +84,12 @@ class Mail // send notifications. $notif_params = [ - 'type' => Type::MAIL, - 'notify_flags' => $user['notify-flags'], - 'language' => $user['language'], - 'to_name' => $user['username'], - 'to_email' => $user['email'], - 'uid' => $user['uid'], - 'item' => $msg, - 'parent' => $msg['id'], - 'source_name' => $msg['from-name'], - 'source_link' => $msg['from-url'], - 'source_photo' => $msg['from-photo'], - 'verb' => Activity::POST, - 'otype' => 'mail' + 'type' => Type::MAIL, + 'otype' => Notify\ObjectType::MAIL, + 'verb' => Activity::POST, + 'uid' => $user['uid'], + 'cid' => $msg['contact-id'], + 'link' => DI::baseUrl() . '/message/' . $msg['id'], ]; notification($notif_params); @@ -130,9 +122,12 @@ class Mail } $me = DBA::selectFirst('contact', [], ['uid' => local_user(), 'self' => true]); - $contact = DBA::selectFirst('contact', [], ['id' => $recipient, 'uid' => local_user()]); + if (!DBA::isResult($me)) { + return -2; + } - if (!(count($me) && (count($contact)))) { + $contact = DBA::selectFirst('contact', [], ['id' => $recipient, 'uid' => local_user()]); + if (!DBA::isResult($contact)) { return -2; } @@ -267,8 +262,7 @@ class Mail $guid = System::createUUID(); $uri = Item::newURI(local_user(), $guid); - $me = Probe::uri($replyto); - + $me = Contact::getByURL($replyto); if (!$me['name']) { return -2; } @@ -277,10 +271,7 @@ class Mail $recip_handle = $recipient['nickname'] . '@' . substr(DI::baseUrl(), strpos(DI::baseUrl(), '://') + 3); - $sender_nick = basename($replyto); - $sender_host = substr($replyto, strpos($replyto, '://') + 3); - $sender_host = substr($sender_host, 0, strpos($sender_host, '/')); - $sender_handle = $sender_nick . '@' . $sender_host; + $sender_handle = $me['addr']; $handles = $recip_handle . ';' . $sender_handle; @@ -313,7 +304,7 @@ class Mail 'reply' => 0, 'replied' => 0, 'uri' => $uri, - 'parent-uri' => $replyto, + 'parent-uri' => $me['url'], 'created' => DateTimeFormat::utcNow(), 'unknown' => 1 ] diff --git a/src/Model/Nodeinfo.php b/src/Model/Nodeinfo.php index d2e168fa59..44181ac941 100644 --- a/src/Model/Nodeinfo.php +++ b/src/Model/Nodeinfo.php @@ -24,6 +24,7 @@ namespace Friendica\Model; use Friendica\Core\Addon; use Friendica\Database\DBA; use Friendica\DI; +use stdClass; /** * Model interaction for the nodeinfo @@ -55,6 +56,7 @@ class Nodeinfo $config->set('nodeinfo', 'total_users', $userStats['total_users']); $config->set('nodeinfo', 'active_users_halfyear', $userStats['active_users_halfyear']); $config->set('nodeinfo', 'active_users_monthly', $userStats['active_users_monthly']); + $config->set('nodeinfo', 'active_users_weekly', $userStats['active_users_weekly']); $logger->debug('user statistics', $userStats); @@ -69,4 +71,109 @@ class Nodeinfo } DBA::close($items); } + + /** + * Return the supported services + * + * @return Object with supported services + */ + public static function getUsage(bool $version2 = false) + { + $config = DI::config(); + + $usage = new stdClass(); + + if (!empty($config->get('system', 'nodeinfo'))) { + $usage->users = [ + 'total' => intval($config->get('nodeinfo', 'total_users')), + 'activeHalfyear' => intval($config->get('nodeinfo', 'active_users_halfyear')), + 'activeMonth' => intval($config->get('nodeinfo', 'active_users_monthly')) + ]; + $usage->localPosts = intval($config->get('nodeinfo', 'local_posts')); + $usage->localComments = intval($config->get('nodeinfo', 'local_comments')); + + if ($version2) { + $usage->users['activeWeek'] = intval($config->get('nodeinfo', 'active_users_weekly')); + } + } + + return $usage; + } + + /** + * Return the supported services + * + * @return array with supported services + */ + public static function getServices() + { + $services = [ + 'inbound' => [], + 'outbound' => [], + ]; + + if (Addon::isEnabled('blogger')) { + $services['outbound'][] = 'blogger'; + } + if (Addon::isEnabled('dwpost')) { + $services['outbound'][] = 'dreamwidth'; + } + if (Addon::isEnabled('statusnet')) { + $services['inbound'][] = 'gnusocial'; + $services['outbound'][] = 'gnusocial'; + } + if (Addon::isEnabled('ijpost')) { + $services['outbound'][] = 'insanejournal'; + } + if (Addon::isEnabled('libertree')) { + $services['outbound'][] = 'libertree'; + } + if (Addon::isEnabled('buffer')) { + $services['outbound'][] = 'linkedin'; + } + if (Addon::isEnabled('ljpost')) { + $services['outbound'][] = 'livejournal'; + } + if (Addon::isEnabled('buffer')) { + $services['outbound'][] = 'pinterest'; + } + if (Addon::isEnabled('posterous')) { + $services['outbound'][] = 'posterous'; + } + if (Addon::isEnabled('pumpio')) { + $services['inbound'][] = 'pumpio'; + $services['outbound'][] = 'pumpio'; + } + + $services['outbound'][] = 'smtp'; + + if (Addon::isEnabled('tumblr')) { + $services['outbound'][] = 'tumblr'; + } + if (Addon::isEnabled('twitter') || Addon::isEnabled('buffer')) { + $services['outbound'][] = 'twitter'; + } + if (Addon::isEnabled('wppost')) { + $services['outbound'][] = 'wordpress'; + } + + return $services; + } + + public static function getOrganization($config) + { + $organization = ['name' => null, 'contact' => null, 'account' => null]; + + if (!empty($config->get('config', 'admin_email'))) { + $adminList = explode(',', str_replace(' ', '', $config->get('config', 'admin_email'))); + $organization['contact'] = $adminList[0]; + $administrator = User::getByEmail($adminList[0], ['username', 'nickname']); + if (!empty($administrator)) { + $organization['name'] = $administrator['username']; + $organization['account'] = DI::baseUrl()->get() . '/profile/' . $administrator['nickname']; + } + } + + return $organization; + } } diff --git a/src/Model/Notify.php b/src/Model/Notify.php index fe1497316f..9ebf5c23b3 100644 --- a/src/Model/Notify.php +++ b/src/Model/Notify.php @@ -70,7 +70,7 @@ class Notify extends BaseModel private function setNameCache() { try { - $this->name_cache = strip_tags(BBCode::convert($this->source_name ?? '')); + $this->name_cache = strip_tags(BBCode::convert($this->source_name)); } catch (InternalServerErrorException $e) { } } diff --git a/src/Model/Photo.php b/src/Model/Photo.php index 9d8b5611f7..e5b2ef87b9 100644 --- a/src/Model/Photo.php +++ b/src/Model/Photo.php @@ -31,8 +31,8 @@ use Friendica\Model\Storage\SystemResource; use Friendica\Object\Image; use Friendica\Util\DateTimeFormat; use Friendica\Util\Images; -use Friendica\Util\Network; -use Friendica\Util\Security; +use Friendica\Security\Security; +use Friendica\Util\Proxy; use Friendica\Util\Strings; require_once "include/dba.php"; @@ -42,6 +42,8 @@ require_once "include/dba.php"; */ class Photo { + const CONTACT_PHOTOS = 'Contact Photos'; + /** * Select rows from the photo table and returns them as array * @@ -176,7 +178,7 @@ class Photo /** - * Get Image object for given row id. null if row id does not exist + * Get Image data for given row id. null if row id does not exist * * @param array $photo Photo data. Needs at least 'id', 'type', 'backend-class', 'backend-ref' * @@ -184,7 +186,7 @@ class Photo * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function getImageForPhoto(array $photo) + public static function getImageDataForPhoto(array $photo) { $backendClass = DI::storageManager()->getByName($photo['backend-class'] ?? ''); if ($backendClass === null) { @@ -198,7 +200,21 @@ class Photo $backendRef = $photo['backend-ref'] ?? ''; $data = $backendClass->get($backendRef); } + return $data; + } + /** + * Get Image object for given row id. null if row id does not exist + * + * @param array $photo Photo data. Needs at least 'id', 'type', 'backend-class', 'backend-ref' + * + * @return \Friendica\Object\Image + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public static function getImageForPhoto(array $photo) + { + $data = self::getImageDataForPhoto($photo); if (empty($data)) { return null; } @@ -303,6 +319,7 @@ class Photo "contact-id" => $cid, "guid" => $guid, "resource-id" => $rid, + "hash" => md5($Image->asString()), "created" => $created, "edited" => DateTimeFormat::utcNow(), "filename" => basename($filename), @@ -409,7 +426,7 @@ class Photo $micro = ""; $photo = DBA::selectFirst( - "photo", ["resource-id"], ["uid" => $uid, "contact-id" => $cid, "scale" => 4, "album" => "Contact Photos"] + "photo", ["resource-id"], ["uid" => $uid, "contact-id" => $cid, "scale" => 4, "album" => self::CONTACT_PHOTOS] ); if (!empty($photo['resource-id'])) { $resource_id = $photo["resource-id"]; @@ -421,7 +438,7 @@ class Photo $filename = basename($image_url); if (!empty($image_url)) { - $ret = Network::curl($image_url, true); + $ret = DI::httpRequest()->get($image_url); $img_str = $ret->getBody(); $type = $ret->getContentType(); } else { @@ -438,7 +455,7 @@ class Photo if ($Image->isValid()) { $Image->scaleToSquare(300); - $r = self::store($Image, $uid, $cid, $resource_id, $filename, "Contact Photos", 4); + $r = self::store($Image, $uid, $cid, $resource_id, $filename, self::CONTACT_PHOTOS, 4); if ($r === false) { $photo_failure = true; @@ -446,7 +463,7 @@ class Photo $Image->scaleDown(80); - $r = self::store($Image, $uid, $cid, $resource_id, $filename, "Contact Photos", 5); + $r = self::store($Image, $uid, $cid, $resource_id, $filename, self::CONTACT_PHOTOS, 5); if ($r === false) { $photo_failure = true; @@ -454,7 +471,7 @@ class Photo $Image->scaleDown(48); - $r = self::store($Image, $uid, $cid, $resource_id, $filename, "Contact Photos", 6); + $r = self::store($Image, $uid, $cid, $resource_id, $filename, self::CONTACT_PHOTOS, 6); if ($r === false) { $photo_failure = true; @@ -493,9 +510,10 @@ class Photo } if ($photo_failure) { - $image_url = DI::baseUrl() . "/images/person-300.jpg"; - $thumb = DI::baseUrl() . "/images/person-80.jpg"; - $micro = DI::baseUrl() . "/images/person-48.jpg"; + $contact = Contact::getById($cid) ?: []; + $image_url = Contact::getDefaultAvatar($contact, Proxy::SIZE_SMALL); + $thumb = Contact::getDefaultAvatar($contact, Proxy::SIZE_THUMB); + $micro = Contact::getDefaultAvatar($contact, Proxy::SIZE_MICRO); } return [$image_url, $thumb, $micro]; @@ -562,8 +580,8 @@ class Photo WHERE `uid` = %d AND `album` != '%s' AND `album` != '%s' $sql_extra GROUP BY `album` ORDER BY `created` DESC", intval($uid), - DBA::escape("Contact Photos"), - DBA::escape(DI::l10n()->t("Contact Photos")) + DBA::escape(self::CONTACT_PHOTOS), + DBA::escape(DI::l10n()->t(self::CONTACT_PHOTOS)) ); } else { // This query doesn't do the count and is much faster @@ -571,8 +589,8 @@ class Photo FROM `photo` USE INDEX (`uid_album_scale_created`) WHERE `uid` = %d AND `album` != '%s' AND `album` != '%s' $sql_extra", intval($uid), - DBA::escape("Contact Photos"), - DBA::escape(DI::l10n()->t("Contact Photos")) + DBA::escape(self::CONTACT_PHOTOS), + DBA::escape(DI::l10n()->t(self::CONTACT_PHOTOS)) ); } DI::cache()->set($key, $albums, Duration::DAY); diff --git a/src/Model/Post/Category.php b/src/Model/Post/Category.php index d4a8469d4f..f785ee62b8 100644 --- a/src/Model/Post/Category.php +++ b/src/Model/Post/Category.php @@ -73,15 +73,13 @@ class Category public static function storeTextByURIId(int $uri_id, int $uid, string $files) { $message = Item::selectFirst(['deleted'], ['uri-id' => $uri_id, 'uid' => $uid]); - if (!DBA::isResult($message)) { - return; - } + if (DBA::isResult($message)) { + // Clean up all tags + DBA::delete('post-category', ['uri-id' => $uri_id, 'uid' => $uid]); - // Clean up all tags - DBA::delete('post-category', ['uri-id' => $uri_id, 'uid' => $uid]); - - if ($message['deleted']) { - return; + if ($message['deleted']) { + return; + } } if (preg_match_all("/\[(.*?)\]/ism", $files, $result)) { @@ -91,7 +89,7 @@ class Category continue; } - DBA::insert('post-category', [ + DBA::replace('post-category', [ 'uri-id' => $uri_id, 'uid' => $uid, 'type' => self::FILE, @@ -107,7 +105,7 @@ class Category continue; } - DBA::insert('post-category', [ + DBA::replace('post-category', [ 'uri-id' => $uri_id, 'uid' => $uid, 'type' => self::CATEGORY, diff --git a/src/Model/Post/Delayed.php b/src/Model/Post/Delayed.php new file mode 100644 index 0000000000..0e6d8b921a --- /dev/null +++ b/src/Model/Post/Delayed.php @@ -0,0 +1,168 @@ +. + * + */ + +namespace Friendica\Model\Post; + +use Friendica\Core\Logger; +use Friendica\Database\DBA; +use Friendica\Core\Worker; +use Friendica\Database\Database; +use Friendica\DI; +use Friendica\Model\Item; +use Friendica\Model\Tag; +use Friendica\Util\DateTimeFormat; + +class Delayed +{ + /** + * Insert a new delayed post + * + * @param string $uri + * @param array $item + * @param integer $notify + * @param bool $unprepared + * @param string $delayed + * @param array $taglist + * @param array $attachments + * @return bool insert success + */ + public static function add(string $uri, array $item, int $notify = 0, bool $unprepared = false, string $delayed = '', array $taglist = [], array $attachments = []) + { + if (empty($item['uid']) || self::exists($uri, $item['uid'])) { + Logger::notice('No uid or already found'); + return false; + } + + if (empty($delayed)) { + $min_posting = DI::config()->get('system', 'minimum_posting_interval', 0); + + $last_publish = DI::pConfig()->get($item['uid'], 'system', 'last_publish', 0, true); + $next_publish = max($last_publish + (60 * $min_posting), time()); + $delayed = date(DateTimeFormat::MYSQL, $next_publish); + } else { + $next_publish = strtotime($delayed); + } + + Logger::notice('Adding post for delayed publishing', ['uid' => $item['uid'], 'delayed' => $delayed, 'uri' => $uri]); + + if (!Worker::add(['priority' => PRIORITY_HIGH, 'delayed' => $delayed], 'DelayedPublish', $item, $notify, $taglist, $attachments, $unprepared, $uri)) { + return false; + } + + DI::pConfig()->set($item['uid'], 'system', 'last_publish', $next_publish); + + return DBA::insert('delayed-post', ['uri' => $uri, 'uid' => $item['uid'], 'delayed' => $delayed], Database::INSERT_IGNORE); + } + + /** + * Delete a delayed post + * + * @param string $uri + * @param int $uid + * + * @return bool delete success + */ + private static function delete(string $uri, int $uid) + { + return DBA::delete('delayed-post', ['uri' => $uri, 'uid' => $uid]); + } + + /** + * Check if an entry exists + * + * @param string $uri + * @param int $uid + * + * @return bool "true" if an entry with that URI exists + */ + public static function exists(string $uri, int $uid) + { + return DBA::exists('delayed-post', ['uri' => $uri, 'uid' => $uid]); + } + + /** + * Publish a delayed post + * + * @param array $item + * @param integer $notify + * @param array $taglist + * @param array $attachments + * @param bool $unprepared + * @param string $uri + * @return bool + */ + public static function publish(array $item, int $notify = 0, array $taglist = [], array $attachments = [], bool $unprepared = false, string $uri = '') + { + if ($unprepared) { + $_SESSION['authenticated'] = true; + $_SESSION['uid'] = $item['uid']; + + $_REQUEST = $item; + $_REQUEST['api_source'] = true; + $_REQUEST['profile_uid'] = $item['uid']; + $_REQUEST['title'] = $item['title'] ?? ''; + + if (!empty($item['app'])) { + $_REQUEST['source'] = $item['app']; + } + + require_once 'mod/item.php'; + $id = item_post(DI::app()); + + if (empty($uri) && !empty($item['extid'])) { + $uri = $item['extid']; + } + + Logger::notice('Unprepared post stored', ['id' => $id, 'uid' => $item['uid'], 'uri' => $uri]); + if (self::exists($uri, $item['uid'])) { + self::delete($uri, $item['uid']); + } + + return $id; + } + $id = Item::insert($item, $notify); + + Logger::notice('Post stored', ['id' => $id, 'uid' => $item['uid'], 'cid' => $item['contact-id']]); + + if (empty($uri) && !empty($item['uri'])) { + $uri = $item['uri']; + } + + if (!empty($uri) && self::exists($uri, $item['uid'])) { + self::delete($uri, $item['uid']); + } + + if (!empty($id) && (!empty($taglist) || !empty($attachments))) { + $feeditem = Item::selectFirst(['uri-id'], ['id' => $id]); + + foreach ($taglist as $tag) { + Tag::store($feeditem['uri-id'], Tag::HASHTAG, $tag); + } + + foreach ($attachments as $attachment) { + $attachment['uri-id'] = $feeditem['uri-id']; + Media::insert($attachment); + } + } + + return $id; + } +} diff --git a/src/Model/Post/DeliveryData.php b/src/Model/Post/DeliveryData.php index 0feb38281b..578f062ec4 100644 --- a/src/Model/Post/DeliveryData.php +++ b/src/Model/Post/DeliveryData.php @@ -148,7 +148,7 @@ class DeliveryData $fields['uri-id'] = $uri_id; - return DBA::insert('post-delivery-data', $fields); + return DBA::replace('post-delivery-data', $fields); } /** diff --git a/src/Model/Post/Media.php b/src/Model/Post/Media.php new file mode 100644 index 0000000000..dd4a2b41e8 --- /dev/null +++ b/src/Model/Post/Media.php @@ -0,0 +1,297 @@ +. + * + */ + +namespace Friendica\Model\Post; + +use Friendica\Core\Logger; +use Friendica\Core\System; +use Friendica\Database\Database; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Util\Images; + +/** + * Class Media + * + * This Model class handles media interactions. + * This tables stores medias (images, videos, audio files) related to posts. + */ +class Media +{ + const UNKNOWN = 0; + const IMAGE = 1; + const VIDEO = 2; + const AUDIO = 3; + const TORRENT = 16; + const DOCUMENT = 128; + + /** + * Insert a post-media record + * + * @param array $media + * @return void + */ + public static function insert(array $media, bool $force = false) + { + if (empty($media['url']) || empty($media['uri-id']) || empty($media['type'])) { + Logger::warning('Incomplete media data', ['media' => $media]); + return; + } + + // "document" has got the lowest priority. So when the same file is both attached as document + // and embedded as picture then we only store the picture or replace the document + $found = DBA::selectFirst('post-media', ['type'], ['uri-id' => $media['uri-id'], 'url' => $media['url']]); + if (!$force && !empty($found) && (($found['type'] != self::DOCUMENT) || ($media['type'] == self::DOCUMENT))) { + Logger::info('Media already exists', ['uri-id' => $media['uri-id'], 'url' => $media['url'], 'callstack' => System::callstack()]); + return; + } + + $fields = ['mimetype', 'height', 'width', 'size', 'preview', 'preview-height', 'preview-width', 'description']; + foreach ($fields as $field) { + if (empty($media[$field])) { + unset($media[$field]); + } + } + + // We are storing as fast as possible to avoid duplicated network requests + // when fetching additional information for pictures and other content. + $result = DBA::insert('post-media', $media, Database::INSERT_UPDATE); + Logger::info('Stored media', ['result' => $result, 'media' => $media, 'callstack' => System::callstack()]); + $stored = $media; + + $media = self::fetchAdditionalData($media); + + if (array_diff_assoc($media, $stored)) { + $result = DBA::insert('post-media', $media, Database::INSERT_UPDATE); + Logger::info('Updated media', ['result' => $result, 'media' => $media]); + } else { + Logger::info('Nothing to update', ['media' => $media]); + } + } + + /** + * Copy attachments from one uri-id to another + * + * @param integer $from_uri_id + * @param integer $to_uri_id + * @return void + */ + public static function copy(int $from_uri_id, int $to_uri_id) + { + $attachments = self::getByURIId($from_uri_id); + foreach ($attachments as $attachment) { + $attachment['uri-id'] = $to_uri_id; + self::insert($attachment); + } + } + + /** + * Creates the "[attach]" element from the given attributes + * + * @param string $href + * @param integer $length + * @param string $type + * @param string $title + * @return string "[attach]" element + */ + public static function getAttachElement(string $href, int $length, string $type, string $title = '') + { + $media = self::fetchAdditionalData(['type' => self::DOCUMENT, 'url' => $href, + 'size' => $length, 'mimetype' => $type, 'description' => $title]); + + return '[attach]href="' . $media['url'] . '" length="' . $media['size'] . + '" type="' . $media['mimetype'] . '" title="' . $media['description'] . '"[/attach]'; + } + + /** + * Fetch additional data for the provided media array + * + * @param array $media + * @return array media array with additional data + */ + public static function fetchAdditionalData(array $media) + { + // Fetch the mimetype or size if missing. + // We don't do it for torrent links since they need special treatment. + // We don't do this for images, since we are fetching their details some lines later anyway. + if (!in_array($media['type'], [self::TORRENT, self::IMAGE]) && (empty($media['mimetype']) || empty($media['size']))) { + $timeout = DI::config()->get('system', 'xrd_timeout'); + $curlResult = DI::httpRequest()->head($media['url'], ['timeout' => $timeout]); + if ($curlResult->isSuccess()) { + $header = $curlResult->getHeaderArray(); + if (empty($media['mimetype']) && !empty($header['content-type'])) { + $media['mimetype'] = $header['content-type']; + } + if (empty($media['size']) && !empty($header['content-length'])) { + $media['size'] = $header['content-length']; + } + } + } + + $filetype = !empty($media['mimetype']) ? strtolower(substr($media['mimetype'], 0, strpos($media['mimetype'], '/'))) : ''; + + if (($media['type'] == self::IMAGE) || ($filetype == 'image')) { + $imagedata = Images::getInfoFromURLCached($media['url']); + if (!empty($imagedata)) { + $media['mimetype'] = $imagedata['mime']; + $media['size'] = $imagedata['size']; + $media['width'] = $imagedata[0]; + $media['height'] = $imagedata[1]; + } + if (!empty($media['preview'])) { + $imagedata = Images::getInfoFromURLCached($media['preview']); + if (!empty($imagedata)) { + $media['preview-width'] = $imagedata[0]; + $media['preview-height'] = $imagedata[1]; + } + } + } + return $media; + } + + /** + * Tests for path patterns that are usef for picture links in Friendica + * + * @param string $page Link to the image page + * @param string $preview Preview picture + * @return boolean + */ + private static function isPictureLink(string $page, string $preview) + { + return preg_match('#/photos/.*/image/#ism', $page) && preg_match('#/photo/.*-1\.#ism', $preview); + } + + /** + * Add media links and remove them from the body + * + * @param integer $uriid + * @param string $body + * @return string Body without media links + */ + public static function insertFromBody(int $uriid, string $body) + { + // Simplify image codes + $body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $body); + + $attachments = []; + if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img=([^\[\]]*)\]([^\[\]]*)\[\/img\]\s*\[/url\]#ism", $body, $pictures, PREG_SET_ORDER)) { + foreach ($pictures as $picture) { + if (!self::isPictureLink($picture[1], $picture[2])) { + continue; + } + $body = str_replace($picture[0], '', $body); + $image = str_replace('-1.', '-0.', $picture[2]); + $attachments[] = ['uri-id' => $uriid, 'type' => self::IMAGE, 'url' => $image, + 'preview' => $picture[2], 'description' => $picture[3]]; + } + } + + if (preg_match_all("/\[img=([^\[\]]*)\]([^\[\]]*)\[\/img\]/Usi", $body, $pictures, PREG_SET_ORDER)) { + foreach ($pictures as $picture) { + $body = str_replace($picture[0], '', $body); + $attachments[] = ['uri-id' => $uriid, 'type' => self::IMAGE, 'url' => $picture[1], 'description' => $picture[2]]; + } + } + + if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img\]([^\[]+?)\[/img\]\s*\[/url\]#ism", $body, $pictures, PREG_SET_ORDER)) { + foreach ($pictures as $picture) { + if (!self::isPictureLink($picture[1], $picture[2])) { + continue; + } + $body = str_replace($picture[0], '', $body); + $image = str_replace('-1.', '-0.', $picture[2]); + $attachments[] = ['uri-id' => $uriid, 'type' => self::IMAGE, 'url' => $image, + 'preview' => $picture[2], 'description' => null]; + } + } + + if (preg_match_all("/\[img\]([^\[\]]*)\[\/img\]/ism", $body, $pictures, PREG_SET_ORDER)) { + foreach ($pictures as $picture) { + $body = str_replace($picture[0], '', $body); + $attachments[] = ['uri-id' => $uriid, 'type' => self::IMAGE, 'url' => $picture[1]]; + } + } + + if (preg_match_all("/\[audio\]([^\[\]]*)\[\/audio\]/ism", $body, $audios, PREG_SET_ORDER)) { + foreach ($audios as $audio) { + $body = str_replace($audio[0], '', $body); + $attachments[] = ['uri-id' => $uriid, 'type' => self::AUDIO, 'url' => $audio[1]]; + } + } + + if (preg_match_all("/\[video\]([^\[\]]*)\[\/video\]/ism", $body, $videos, PREG_SET_ORDER)) { + foreach ($videos as $video) { + $body = str_replace($video[0], '', $body); + $attachments[] = ['uri-id' => $uriid, 'type' => self::VIDEO, 'url' => $video[1]]; + } + } + + foreach ($attachments as $attachment) { + self::insert($attachment); + } + + return trim($body); + } + + /** + * Add media links from the attach field + * + * @param integer $uriid + * @param string $attach + * @return void + */ + public static function insertFromAttachment(int $uriid, string $attach) + { + if (!preg_match_all('|\[attach\]href=\"(.*?)\" length=\"(.*?)\" type=\"(.*?)\"(?: title=\"(.*?)\")?|', $attach, $matches, PREG_SET_ORDER)) { + return; + } + + foreach ($matches as $attachment) { + $media['type'] = self::DOCUMENT; + $media['uri-id'] = $uriid; + $media['url'] = $attachment[1]; + $media['size'] = $attachment[2]; + $media['mimetype'] = $attachment[3]; + $media['description'] = $attachment[4] ?? ''; + + self::insert($media); + } + } + + /** + * Retrieves the media attachments associated with the provided item ID. + * + * @param int $uri_id + * @param array $types + * @return array + * @throws \Exception + */ + public static function getByURIId(int $uri_id, array $types = []) + { + $condition = ['uri-id' => $uri_id]; + + if (!empty($types)) { + $condition = DBA::mergeConditions($condition, ['type' => $types]); + } + + return DBA::selectToArray('post-media', [], $condition); + } +} diff --git a/src/Model/Post/User.php b/src/Model/Post/User.php new file mode 100644 index 0000000000..c3ca5de1d0 --- /dev/null +++ b/src/Model/Post/User.php @@ -0,0 +1,91 @@ +. + * + */ + +namespace Friendica\Model\Post; + +use Friendica\Database\DBA; +use \BadMethodCallException; +use Friendica\Database\Database; +use Friendica\Database\DBStructure; + +class User +{ + /** + * Insert a new URI user entry + * + * @param integer $uri_id + * @param integer $uid + * @param array $fields + * @return bool + * @throws \Exception + */ + public static function insert(int $uri_id, int $uid, array $data = []) + { + if (empty($uri_id)) { + throw new BadMethodCallException('Empty URI_id'); + } + + if (DBA::exists('post-user', ['uri-id' => $uri_id, 'uid' => $uid])) { + return false; + } + + $fields = DBStructure::getFieldsForTable('post-user', $data); + + // Additionally assign the key fields + $fields['uri-id'] = $uri_id; + $fields['uid'] = $uid; + + // Public posts are always seen + if ($uid == 0) { + $fields['unseen'] = false; + } + + return DBA::insert('post-user', $fields, Database::INSERT_IGNORE); + } + + /** + * Update a URI user entry + * + * @param integer $uri_id + * @param integer $uid + * @param array $fields + * @return bool + * @throws \Exception + */ + public static function update(int $uri_id, int $uid, array $data = []) + { + if (empty($uri_id)) { + throw new BadMethodCallException('Empty URI_id'); + } + + $fields = DBStructure::getFieldsForTable('post-user', $data); + + // Remove the key fields + unset($fields['uri-id']); + unset($fields['uid']); + + if (empty($fields)) { + return true; + } + + return DBA::update('post-user', $fields, ['uri-id' => $uri_id, 'uid' => $uid], true); + } +} diff --git a/src/Model/Process.php b/src/Model/Process.php index 18b5f785a1..cc8a184297 100644 --- a/src/Model/Process.php +++ b/src/Model/Process.php @@ -21,7 +21,7 @@ namespace Friendica\Model; -use Friendica\Database\DBA; +use Friendica\Database\Database; use Friendica\Util\DateTimeFormat; /** @@ -29,29 +29,33 @@ use Friendica\Util\DateTimeFormat; */ class Process { + /** @var Database */ + private $dba; + + public function __construct(Database $dba) + { + $this->dba = $dba; + } + /** * Insert a new process row. If the pid parameter is omitted, we use the current pid * * @param string $command - * @param string $pid + * @param int $pid The process id to insert * @return bool * @throws \Exception */ - public static function insert($command, $pid = null) + public function insert(string $command, int $pid) { $return = true; - if (is_null($pid)) { - $pid = getmypid(); + $this->dba->transaction(); + + if (!$this->dba->exists('process', ['pid' => $pid])) { + $return = $this->dba->insert('process', ['pid' => $pid, 'command' => $command, 'created' => DateTimeFormat::utcNow()]); } - DBA::transaction(); - - if (!DBA::exists('process', ['pid' => $pid])) { - $return = DBA::insert('process', ['pid' => $pid, 'command' => $command, 'created' => DateTimeFormat::utcNow()]); - } - - DBA::commit(); + $this->dba->commit(); return $return; } @@ -59,33 +63,29 @@ class Process /** * Remove a process row by pid. If the pid parameter is omitted, we use the current pid * - * @param string $pid + * @param int $pid The pid to delete * @return bool * @throws \Exception */ - public static function deleteByPid($pid = null) + public function deleteByPid(int $pid) { - if ($pid === null) { - $pid = getmypid(); - } - - return DBA::delete('process', ['pid' => $pid]); + return $this->dba->delete('process', ['pid' => $pid]); } /** * Clean the process table of inactive physical processes */ - public static function deleteInactive() + public function deleteInactive() { - DBA::transaction(); + $this->dba->transaction(); - $processes = DBA::select('process', ['pid']); - while($process = DBA::fetch($processes)) { + $processes = $this->dba->select('process', ['pid']); + while($process = $this->dba->fetch($processes)) { if (!posix_kill($process['pid'], 0)) { - self::deleteByPid($process['pid']); + $this->deleteByPid($process['pid']); } } - DBA::close($processes); - DBA::commit(); + $this->dba->close($processes); + $this->dba->commit(); } } diff --git a/src/Model/Profile.php b/src/Model/Profile.php index 348e16badd..c5652c6f7a 100644 --- a/src/Model/Profile.php +++ b/src/Model/Profile.php @@ -27,7 +27,6 @@ use Friendica\Content\Widget\ContactBlock; use Friendica\Core\Cache\Duration; use Friendica\Core\Hook; use Friendica\Core\Logger; -use Friendica\Network\Probe; use Friendica\Core\Protocol; use Friendica\Core\Renderer; use Friendica\Core\Session; @@ -167,7 +166,7 @@ class Profile } } - $profile = self::getByNickname($nickname, $user['uid']); + $profile = !empty($user['uid']) ? User::getOwnerDataById($user['uid'], false) : []; if (empty($profile) && empty($profiledata)) { Logger::log('profile error: ' . DI::args()->getQueryString(), Logger::DEBUG); @@ -259,7 +258,7 @@ class Profile * @hooks 'profile_sidebar' * array $arr */ - private static function sidebar(App $a, $profile, $block = 0, $show_connect = true) + private static function sidebar(App $a, array $profile, $block = 0, $show_connect = true) { $o = ''; $location = false; @@ -267,7 +266,8 @@ class Profile // This function can also use contact information in $profile $is_contact = !empty($profile['cid']); - if (!is_array($profile) && !count($profile)) { + if (empty($profile['nickname'])) { + Logger::warning('Received profile with no nickname', ['profile' => $profile, 'callstack' => System::callstack(10)]); return $o; } @@ -292,8 +292,6 @@ class Profile $subscribe_feed_link = null; $wallmessage_link = null; - - $visitor_contact = []; if (!empty($profile['uid']) && self::getMyURL()) { $visitor_contact = Contact::selectFirst(['rel'], ['uid' => $profile['uid'], 'nurl' => Strings::normaliseLink(self::getMyURL())]); @@ -306,7 +304,7 @@ class Profile $profile_is_dfrn = $profile['network'] == Protocol::DFRN; $profile_is_native = in_array($profile['network'], Protocol::NATIVE_SUPPORT); - $local_user_is_self = local_user() && local_user() == ($profile['uid'] ?? 0); + $local_user_is_self = self::getMyURL() && ($profile['url'] == self::getMyURL()); $visitor_is_authenticated = (bool)self::getMyURL(); $visitor_is_following = in_array($visitor_contact['rel'] ?? 0, [Contact::FOLLOWER, Contact::FRIEND]) @@ -324,9 +322,9 @@ class Profile } } elseif ($profile_is_native) { if ($visitor_is_following) { - $unfollow_link = $visitor_base_path . '/unfollow?url=' . urlencode($profile_url); + $unfollow_link = $visitor_base_path . '/unfollow?url=' . urlencode($profile_url) . '&auto=1'; } else { - $follow_link = $visitor_base_path .'/follow?url=' . urlencode($profile_url); + $follow_link = $visitor_base_path .'/follow?url=' . urlencode($profile_url) . '&auto=1'; } } @@ -356,13 +354,7 @@ class Profile // Fetch the account type $account_type = Contact::getAccountType($profile); - if (!empty($profile['address']) - || !empty($profile['location']) - || !empty($profile['locality']) - || !empty($profile['region']) - || !empty($profile['postal-code']) - || !empty($profile['country-name']) - ) { + if (!empty($profile['address']) || !empty($profile['location'])) { $location = DI::l10n()->t('Location:'); } @@ -414,6 +406,7 @@ class Profile 'pending' => false, 'hidden' => false, 'archive' => false, + 'failed' => false, 'network' => Protocol::FEDERATED, ]); } @@ -429,10 +422,6 @@ class Profile $p['about'] = BBCode::convert($p['about']); } - if (empty($p['address']) && !empty($p['location'])) { - $p['address'] = $p['location']; - } - if (isset($p['address'])) { $p['address'] = BBCode::convert($p['address']); } @@ -601,7 +590,7 @@ class Profile while ($rr = DBA::fetch($s)) { $condition = ['parent-uri' => $rr['uri'], 'uid' => $rr['uid'], 'author-id' => public_contact(), - 'activity' => [Item::activityToIndex( Activity::ATTEND), Item::activityToIndex(Activity::ATTENDMAYBE)], + 'vid' => [Verb::getID(Activity::ATTEND), Verb::getID(Activity::ATTENDMAYBE)], 'visible' => true, 'deleted' => false]; if (!Item::exists($condition)) { continue; @@ -739,7 +728,7 @@ class Profile $magic_path = $basepath . '/magic' . '?owa=1&dest=' . $dest . '&' . $addr_request; // We have to check if the remote server does understand /magic without invoking something - $serverret = Network::curl($basepath . '/magic'); + $serverret = DI::httpRequest()->get($basepath . '/magic'); if ($serverret->isSuccess()) { Logger::log('Doing magic auth for visitor ' . $my_url . ' to ' . $magic_path, Logger::DEBUG); System::externalRedirect($magic_path); @@ -772,7 +761,7 @@ class Profile $_SESSION['visitor_handle'] = $visitor['addr']; $_SESSION['visitor_home'] = $visitor['url']; $_SESSION['my_url'] = $visitor['url']; - $_SESSION['remote_comment'] = Probe::getRemoteFollowLink($visitor['url']); + $_SESSION['remote_comment'] = $visitor['subscribe']; Session::setVisitorsContacts(); diff --git a/src/Model/PushSubscriber.php b/src/Model/PushSubscriber.php index 2a7be3c35a..84d45c4c4d 100644 --- a/src/Model/PushSubscriber.php +++ b/src/Model/PushSubscriber.php @@ -25,6 +25,7 @@ use Friendica\Core\Logger; use Friendica\Core\Worker; use Friendica\Database\DBA; use Friendica\Util\DateTimeFormat; +use Friendica\Util\Network; class PushSubscriber { @@ -170,5 +171,13 @@ class PushSubscriber $fields = ['push' => 0, 'next_try' => DBA::NULL_DATETIME, 'last_update' => $last_update]; DBA::update('push_subscriber', $fields, ['id' => $id]); Logger::log('Subscriber ' . $subscriber['callback_url'] . ' for ' . $subscriber['nickname'] . ' is marked as vital', Logger::DEBUG); + + $parts = parse_url($subscriber['callback_url']); + unset($parts['path']); + $server_url = Network::unparseURL($parts); + $gsid = GServer::getID($server_url, true); + if (!empty($gsid)) { + GServer::setProtocol($gsid, Post\DeliveryData::OSTATUS); + } } } diff --git a/src/Model/Search.php b/src/Model/Search.php index ca8960ef35..0b921ecc18 100644 --- a/src/Model/Search.php +++ b/src/Model/Search.php @@ -42,7 +42,7 @@ class Search $tags = []; while ($term = DBA::fetch($termsStmt)) { - $tags[] = trim($term['term'], '#'); + $tags[] = trim(mb_strtolower($term['term']), '#'); } DBA::close($termsStmt); return $tags; diff --git a/src/Model/Tag.php b/src/Model/Tag.php index 2f46289720..8ffb5b64f8 100644 --- a/src/Model/Tag.php +++ b/src/Model/Tag.php @@ -24,7 +24,9 @@ namespace Friendica\Model; use Friendica\Content\Text\BBCode; use Friendica\Core\Cache\Duration; use Friendica\Core\Logger; +use Friendica\Core\Protocol; use Friendica\Core\System; +use Friendica\Database\Database; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Util\Strings; @@ -68,8 +70,8 @@ class Tag public static function store(int $uriid, int $type, string $name, string $url = '', $probing = true) { if ($type == self::HASHTAG) { - // Remove some common "garbarge" from tags - $name = trim($name, "\x00..\x20\xFF#!@,;.:'/?!^°$%".'"'); + // Trim Unicode non-word characters + $name = preg_replace('/(^\W+)|(\W+$)/us', '', $name); $tags = explode(self::TAG_CHARACTER[self::HASHTAG], $name); if (count($tags) > 1) { @@ -93,6 +95,10 @@ class Tag return; } + if ((substr($url, 0, 7) == 'https//') || (substr($url, 0, 6) == 'http//')) { + Logger::notice('Wrong scheme in url', ['url' => $url, 'callstack' => System::callstack(20)]); + } + if (!$probing) { $condition = ['nurl' => Strings::normaliseLink($url), 'uid' => 0, 'deleted' => false]; $contact = DBA::selectFirst('contact', ['id'], $condition, ['order' => ['id']]); @@ -111,7 +117,7 @@ class Tag } } } else { - $cid = Contact::getIdForURL($url, 0, true); + $cid = Contact::getIdForURL($url, 0, false); Logger::info('Got id by probing', ['cid' => $cid, 'url' => $url]); } @@ -147,7 +153,7 @@ class Tag } } - DBA::insert('post-tag', $fields, true); + DBA::insert('post-tag', $fields, Database::INSERT_IGNORE); Logger::info('Stored tag/mention', ['uri-id' => $uriid, 'tag-id' => $tagid, 'contact-id' => $cid, 'name' => $name, 'type' => $type, 'callstack' => System::callstack(8)]); } @@ -168,7 +174,7 @@ class Tag return $tag['id']; } - DBA::insert('tag', $fields, true); + DBA::insert('tag', $fields, Database::INSERT_IGNORE); $tid = DBA::lastInsertId(); if (!empty($tid)) { return $tid; @@ -325,6 +331,29 @@ class Tag } } + /** + * Create implicit mentions for a given post + * + * @param integer $uri_id + * @param integer $parent_uri_id + */ + public static function createImplicitMentions(int $uri_id, int $parent_uri_id) + { + // Always mention the direct parent author + $parent = Item::selectFirst(['author-link', 'author-name'], ['uri-id' => $parent_uri_id]); + self::store($uri_id, self::IMPLICIT_MENTION, $parent['author-name'], $parent['author-link']); + + if (DI::config()->get('system', 'disable_implicit_mentions')) { + return; + } + + $tags = DBA::select('tag-view', ['name', 'url'], ['uri-id' => $parent_uri_id]); + while ($tag = DBA::fetch($tags)) { + self::store($uri_id, self::IMPLICIT_MENTION, $tag['name'], $tag['url']); + } + DBA::close($tags); + } + /** * Retrieves the terms from the provided type(s) associated with the provided item ID. * @@ -343,12 +372,14 @@ class Tag * Return a string with all tags and mentions * * @param integer $uri_id + * @param array $type * @return string tags and mentions + * @throws \Exception */ - public static function getCSVByURIId(int $uri_id) + public static function getCSVByURIId(int $uri_id, array $type = [self::HASHTAG, self::MENTION, self::IMPLICIT_MENTION, self::EXCLUSIVE_MENTION]) { $tag_list = []; - $tags = self::getByURIId($uri_id); + $tags = self::getByURIId($uri_id, $type); foreach ($tags as $tag) { $tag_list[] = self::TAG_CHARACTER[$tag['type']] . '[url=' . $tag['url'] . ']' . $tag['name'] . '[/url]'; } @@ -411,6 +442,23 @@ class Tag return $return; } + /** + * Counts posts for given tag + * + * @param string $search + * @param integer $uid + * @return integer number of posts + */ + public static function countByTag(string $search, int $uid = 0) + { + $condition = ["`name` = ? AND (NOT `private` OR (`private` AND `uid` = ?)) + AND `uri-id` IN (SELECT `uri-id` FROM `item` WHERE `network` IN (?, ?, ?, ?))", + $search, $uid, Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA, Protocol::OSTATUS]; + $params = ['group_by' => ['uri-id']]; + + return DBA::count('tag-search-view', $condition, $params); + } + /** * Search posts for given tag * @@ -418,11 +466,19 @@ class Tag * @param integer $uid * @param integer $start * @param integer $limit + * @param integer $last_uriid * @return array with URI-ID */ - public static function getURIIdListByTag(string $search, int $uid = 0, int $start = 0, int $limit = 100) + public static function getURIIdListByTag(string $search, int $uid = 0, int $start = 0, int $limit = 100, int $last_uriid = 0) { - $condition = ["`name` = ? AND (NOT `private` OR (`private` AND `uid` = ?))", $search, $uid]; + $condition = ["`name` = ? AND (NOT `private` OR (`private` AND `uid` = ?)) + AND `uri-id` IN (SELECT `uri-id` FROM `item` WHERE `network` IN (?, ?, ?, ?))", + $search, $uid, Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA, Protocol::OSTATUS]; + + if (!empty($last_uriid)) { + $condition = DBA::mergeConditions($condition, ["`uri-id` < ?", $last_uriid]); + } + $params = [ 'order' => ['uri-id' => true], 'group_by' => ['uri-id'], @@ -444,54 +500,86 @@ class Tag * Returns a list of the most frequent global hashtags over the given period * * @param int $period Period in hours to consider posts + * @param int $limit Number of returned tags * @return array * @throws \Exception */ public static function getGlobalTrendingHashtags(int $period, $limit = 10) { - $tags = DI::cache()->get('global_trending_tags'); + $tags = DI::cache()->get('global_trending_tags-' . $period . '-' . $limit); + if (!empty($tags)) { + return $tags; + } else { + return self::setGlobalTrendingHashtags($period, $limit); + } + } - if (empty($tags)) { - $tagsStmt = DBA::p("SELECT `name` AS `term`, COUNT(*) AS `score` - FROM `tag-search-view` - WHERE `private` = ? AND `received` > DATE_SUB(NOW(), INTERVAL ? HOUR) - GROUP BY `term` ORDER BY `score` DESC LIMIT ?", - Item::PUBLIC, $period, $limit); + /** + * Creates a list of the most frequent global hashtags over the given period + * + * @param int $period Period in hours to consider posts + * @param int $limit Number of returned tags + * @return array + * @throws \Exception + */ + public static function setGlobalTrendingHashtags(int $period, int $limit = 10) + { + $tagsStmt = DBA::p("SELECT `name` AS `term`, COUNT(*) AS `score` + FROM `tag-search-view` + WHERE `private` = ? AND `uid` = ? AND `received` > DATE_SUB(NOW(), INTERVAL ? HOUR) + GROUP BY `term` ORDER BY `score` DESC LIMIT ?", + Item::PUBLIC, 0, $period, $limit); - if (DBA::isResult($tagsStmt)) { - $tags = DBA::toArray($tagsStmt); - DI::cache()->set('global_trending_tags', $tags, Duration::HOUR); - } + if (DBA::isResult($tagsStmt)) { + $tags = DBA::toArray($tagsStmt); + DI::cache()->set('global_trending_tags-' . $period . '-' . $limit, $tags, Duration::DAY); + return $tags; } - return $tags ?: []; + return []; } /** * Returns a list of the most frequent local hashtags over the given period * * @param int $period Period in hours to consider posts + * @param int $limit Number of returned tags * @return array * @throws \Exception */ public static function getLocalTrendingHashtags(int $period, $limit = 10) { - $tags = DI::cache()->get('local_trending_tags'); + $tags = DI::cache()->get('local_trending_tags-' . $period . '-' . $limit); + if (!empty($tags)) { + return $tags; + } else { + return self::setLocalTrendingHashtags($period, $limit); + } + } - if (empty($tags)) { - $tagsStmt = DBA::p("SELECT `name` AS `term`, COUNT(*) AS `score` - FROM `tag-search-view` - WHERE `private` = ? AND `wall` AND `origin` AND `received` > DATE_SUB(NOW(), INTERVAL ? HOUR) - GROUP BY `term` ORDER BY `score` DESC LIMIT ?", - Item::PUBLIC, $period, $limit); + /** + * Returns a list of the most frequent local hashtags over the given period + * + * @param int $period Period in hours to consider posts + * @param int $limit Number of returned tags + * @return array + * @throws \Exception + */ + public static function setLocalTrendingHashtags(int $period, int $limit = 10) + { + $tagsStmt = DBA::p("SELECT `name` AS `term`, COUNT(*) AS `score` + FROM `tag-search-view` + WHERE `private` = ? AND `wall` AND `origin` AND `received` > DATE_SUB(NOW(), INTERVAL ? HOUR) + GROUP BY `term` ORDER BY `score` DESC LIMIT ?", + Item::PUBLIC, $period, $limit); - if (DBA::isResult($tagsStmt)) { - $tags = DBA::toArray($tagsStmt); - DI::cache()->set('local_trending_tags', $tags, Duration::HOUR); - } + if (DBA::isResult($tagsStmt)) { + $tags = DBA::toArray($tagsStmt); + DI::cache()->set('local_trending_tags-' . $period . '-' . $limit, $tags, Duration::DAY); + return $tags; } - return $tags ?: []; + return []; } /** @@ -510,6 +598,42 @@ class Tag } } - return Strings::startsWith($tag, $tag_chars); - } + return Strings::startsWithChars($tag, $tag_chars); + } + + /** + * Fetch user who subscribed to the given tag + * + * @param string $tag + * @return array User list + */ + private static function getUIDListByTag(string $tag) + { + $uids = []; + $searches = DBA::select('search', ['uid'], ['term' => $tag]); + while ($search = DBA::fetch($searches)) { + $uids[] = $search['uid']; + } + DBA::close($searches); + + return $uids; + } + + /** + * Fetch user who subscribed to the tags of the given item + * + * @param integer $uri_id + * @return array User list + */ + public static function getUIDListByURIId(int $uri_id) + { + $uids = []; + $tags = self::getByURIId($uri_id, [self::HASHTAG]); + + foreach ($tags as $tag) { + $uids = array_merge($uids, self::getUIDListByTag(self::TAG_CHARACTER[self::HASHTAG] . $tag['name'])); + } + + return array_unique($uids); + } } diff --git a/src/Model/Term.php b/src/Model/Term.php deleted file mode 100644 index ea9ddc1919..0000000000 --- a/src/Model/Term.php +++ /dev/null @@ -1,115 +0,0 @@ -. - * - */ - -namespace Friendica\Model; - -use Friendica\Database\DBA; - -/** - * Class Term - * - * This Model class handles term table interactions. - * This tables stores relevant terms related to posts, photos and searches, like hashtags, mentions and - * user-applied categories. - */ -class Term -{ - const UNKNOWN = 0; - const CATEGORY = 3; - const FILE = 5; - - const OBJECT_TYPE_POST = 1; - - /** - * Generates the legacy item.file field string from an item ID. - * Includes only file and category terms. - * - * @param int $item_id - * @return string - * @throws \Exception - */ - public static function fileTextFromItemId($item_id) - { - $file_text = ''; - - $condition = ['otype' => self::OBJECT_TYPE_POST, 'oid' => $item_id, 'type' => [self::FILE, self::CATEGORY]]; - $tags = DBA::selectToArray('term', ['type', 'term', 'url'], $condition); - foreach ($tags as $tag) { - if ($tag['type'] == self::CATEGORY) { - $file_text .= '<' . $tag['term'] . '>'; - } else { - $file_text .= '[' . $tag['term'] . ']'; - } - } - - return $file_text; - } - - /** - * Inserts new terms for the provided item ID based on the legacy item.file field BBCode content. - * Deletes all previous file terms for the same item ID. - * - * @param integer $item_id item id - * @param $files - * @return void - * @throws \Exception - */ - public static function insertFromFileFieldByItemId($item_id, $files) - { - $message = Item::selectFirst(['uid', 'deleted'], ['id' => $item_id]); - if (!DBA::isResult($message)) { - return; - } - - // Clean up all tags - DBA::delete('term', ['otype' => self::OBJECT_TYPE_POST, 'oid' => $item_id, 'type' => [self::FILE, self::CATEGORY]]); - - if ($message["deleted"]) { - return; - } - - $message['file'] = $files; - - if (preg_match_all("/\[(.*?)\]/ism", $message["file"], $files)) { - foreach ($files[1] as $file) { - DBA::insert('term', [ - 'uid' => $message["uid"], - 'oid' => $item_id, - 'otype' => self::OBJECT_TYPE_POST, - 'type' => self::FILE, - 'term' => $file - ]); - } - } - - if (preg_match_all("/\<(.*?)\>/ism", $message["file"], $files)) { - foreach ($files[1] as $file) { - DBA::insert('term', [ - 'uid' => $message["uid"], - 'oid' => $item_id, - 'otype' => self::OBJECT_TYPE_POST, - 'type' => self::CATEGORY, - 'term' => $file - ]); - } - } - } -} diff --git a/src/Model/User.php b/src/Model/User.php index 89574e7609..dbace74e5d 100644 --- a/src/Model/User.php +++ b/src/Model/User.php @@ -21,7 +21,9 @@ namespace Friendica\Model; +use DivineOmega\DOFileCachePSR6\CacheItemPool; use DivineOmega\PasswordExposed; +use ErrorException; use Exception; use Friendica\Content\Pager; use Friendica\Core\Hook; @@ -33,14 +35,16 @@ use Friendica\Core\Worker; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\TwoFactor\AppSpecificPassword; -use Friendica\Network\HTTPException\InternalServerErrorException; +use Friendica\Network\HTTPException; use Friendica\Object\Image; use Friendica\Util\Crypto; use Friendica\Util\DateTimeFormat; use Friendica\Util\Images; use Friendica\Util\Network; +use Friendica\Util\Proxy; use Friendica\Util\Strings; use Friendica\Worker\Delivery; +use ImagickException; use LightOpenID; /** @@ -93,10 +97,156 @@ class User const ACCOUNT_TYPE_NEWS = 2; const ACCOUNT_TYPE_COMMUNITY = 3; const ACCOUNT_TYPE_RELAY = 4; + const ACCOUNT_TYPE_DELETED = 127; /** * @} */ + private static $owner; + + /** + * Returns the numeric account type by their string + * + * @param string $accounttype as string constant + * @return int|null Numeric account type - or null when not set + */ + public static function getAccountTypeByString(string $accounttype) + { + switch ($accounttype) { + case 'person': + return User::ACCOUNT_TYPE_PERSON; + case 'organisation': + return User::ACCOUNT_TYPE_ORGANISATION; + case 'news': + return User::ACCOUNT_TYPE_NEWS; + case 'community': + return User::ACCOUNT_TYPE_COMMUNITY; + default: + return null; + break; + } + } + + /** + * Fetch the system account + * + * @return array system account + */ + public static function getSystemAccount() + { + $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]); + if (!DBA::isResult($system)) { + self::createSystemAccount(); + $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]); + if (!DBA::isResult($system)) { + return []; + } + } + + $system['sprvkey'] = $system['uprvkey'] = $system['prvkey']; + $system['spubkey'] = $system['upubkey'] = $system['pubkey']; + $system['nickname'] = $system['nick']; + + // Ensure that the user contains data + $user = DBA::selectFirst('user', ['prvkey'], ['uid' => 0]); + if (empty($user['prvkey'])) { + $fields = [ + 'username' => $system['name'], + 'nickname' => $system['nick'], + 'register_date' => $system['created'], + 'pubkey' => $system['pubkey'], + 'prvkey' => $system['prvkey'], + 'spubkey' => $system['spubkey'], + 'sprvkey' => $system['sprvkey'], + 'verified' => true, + 'page-flags' => User::PAGE_FLAGS_SOAPBOX, + 'account-type' => User::ACCOUNT_TYPE_RELAY, + ]; + + DBA::update('user', $fields, ['uid' => 0]); + } + + return $system; + } + + /** + * Create the system account + * + * @return void + */ + private static function createSystemAccount() + { + $system_actor_name = self::getActorName(); + if (empty($system_actor_name)) { + return; + } + + $keys = Crypto::newKeypair(4096); + if ($keys === false) { + throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.')); + } + + $system = []; + $system['uid'] = 0; + $system['created'] = DateTimeFormat::utcNow(); + $system['self'] = true; + $system['network'] = Protocol::ACTIVITYPUB; + $system['name'] = 'System Account'; + $system['addr'] = $system_actor_name . '@' . DI::baseUrl()->getHostname(); + $system['nick'] = $system_actor_name; + $system['url'] = DI::baseUrl() . '/friendica'; + + $system['avatar'] = $system['photo'] = Contact::getDefaultAvatar($system, Proxy::SIZE_SMALL); + $system['thumb'] = Contact::getDefaultAvatar($system, Proxy::SIZE_THUMB); + $system['micro'] = Contact::getDefaultAvatar($system, Proxy::SIZE_MICRO); + + $system['nurl'] = Strings::normaliseLink($system['url']); + $system['pubkey'] = $keys['pubkey']; + $system['prvkey'] = $keys['prvkey']; + $system['blocked'] = 0; + $system['pending'] = 0; + $system['contact-type'] = Contact::TYPE_RELAY; // In AP this is translated to 'Application' + $system['name-date'] = DateTimeFormat::utcNow(); + $system['uri-date'] = DateTimeFormat::utcNow(); + $system['avatar-date'] = DateTimeFormat::utcNow(); + $system['closeness'] = 0; + $system['baseurl'] = DI::baseUrl(); + $system['gsid'] = GServer::getID($system['baseurl']); + DBA::insert('contact', $system); + } + + /** + * Detect a usable actor name + * + * @return string actor account name + */ + public static function getActorName() + { + $system_actor_name = DI::config()->get('system', 'actor_name'); + if (!empty($system_actor_name)) { + $self = Contact::selectFirst(['nick'], ['uid' => 0, 'self' => true]); + if (!empty($self['nick'])) { + if ($self['nick'] != $system_actor_name) { + // Reset the actor name to the already used name + DI::config()->set('system', 'actor_name', $self['nick']); + $system_actor_name = $self['nick']; + } + } + return $system_actor_name; + } + + // List of possible actor names + $possible_accounts = ['friendica', 'actor', 'system', 'internal']; + foreach ($possible_accounts as $name) { + if (!DBA::exists('user', ['nickname' => $name, 'account_removed' => false, 'expire' => false]) && + !DBA::exists('userd', ['username' => $name])) { + DI::config()->set('system', 'actor_name', $name); + return $name; + } + } + return ''; + } + /** * Returns true if a user record exists with the provided id * @@ -117,7 +267,7 @@ class User */ public static function getById($uid, array $fields = []) { - return DBA::selectFirst('user', $fields, ['uid' => $uid]); + return !empty($uid) ? DBA::selectFirst('user', $fields, ['uid' => $uid]) : []; } /** @@ -160,14 +310,29 @@ class User * @return integer user id * @throws Exception */ - public static function getIdForURL($url) + public static function getIdForURL(string $url) { - $self = DBA::selectFirst('contact', ['uid'], ['nurl' => Strings::normaliseLink($url), 'self' => true]); - if (!DBA::isResult($self)) { - return false; - } else { + // Avoid any database requests when the hostname isn't even part of the url. + if (!strpos($url, DI::baseUrl()->getHostname())) { + return 0; + } + + $self = Contact::selectFirst(['uid'], ['self' => true, 'nurl' => Strings::normaliseLink($url)]); + if (!empty($self['uid'])) { return $self['uid']; } + + $self = Contact::selectFirst(['uid'], ['self' => true, 'addr' => $url]); + if (!empty($self['uid'])) { + return $self['uid']; + } + + $self = Contact::selectFirst(['uid'], ['self' => true, 'alias' => [$url, Strings::normaliseLink($url)]]); + if (!empty($self['uid'])) { + return $self['uid']; + } + + return 0; } /** @@ -185,19 +350,45 @@ class User return DBA::selectFirst('user', $fields, ['email' => $email]); } + /** + * Fetch the user array of the administrator. The first one if there are several. + * + * @param array $fields + * @return array user + */ + public static function getFirstAdmin(array $fields = []) + { + if (!empty(DI::config()->get('config', 'admin_nickname'))) { + return self::getByNickname(DI::config()->get('config', 'admin_nickname'), $fields); + } elseif (!empty(DI::config()->get('config', 'admin_email'))) { + $adminList = explode(',', str_replace(' ', '', DI::config()->get('config', 'admin_email'))); + return self::getByEmail($adminList[0], $fields); + } else { + return []; + } + } + /** * Get owner data by user id * - * @param int $uid - * @param boolean $check_valid Test if data is invalid and correct it + * @param int $uid + * @param boolean $repairMissing Repair the owner data if it's missing * @return boolean|array * @throws Exception */ - public static function getOwnerDataById($uid, $check_valid = true) + public static function getOwnerDataById(int $uid, bool $repairMissing = true) { + if ($uid == 0) { + return self::getSystemAccount(); + } + + if (!empty(self::$owner[$uid])) { + return self::$owner[$uid]; + } + $owner = DBA::selectFirst('owner-view', [], ['uid' => $uid]); if (!DBA::isResult($owner)) { - if (!DBA::exists('user', ['uid' => $uid]) || !$check_valid) { + if (!DBA::exists('user', ['uid' => $uid]) || !$repairMissing) { return false; } Contact::createSelfFromUserId($uid); @@ -208,7 +399,7 @@ class User return false; } - if (!$check_valid) { + if (!$repairMissing) { return $owner; } @@ -221,7 +412,7 @@ class User if (!$repair) { // Check if "addr" is present and correct $addr = $owner['nickname'] . '@' . substr(DI::baseUrl(), strpos(DI::baseUrl(), '://') + 3); - $repair = ($addr != $owner['addr']); + $repair = ($addr != $owner['addr']) || empty($owner['prvkey']) || empty($owner['pubkey']); } if (!$repair) { @@ -238,6 +429,7 @@ class User $owner = self::getOwnerDataById($uid, false); } + self::$owner[$uid] = $owner; return $owner; } @@ -262,11 +454,11 @@ class User /** * Returns the default group for a given user and network * - * @param int $uid User id + * @param int $uid User id * @param string $network network name * * @return int group id - * @throws InternalServerErrorException + * @throws Exception */ public static function getDefaultGroup($uid, $network = '') { @@ -318,7 +510,8 @@ class User * @param string $password * @param bool $third_party * @return int User Id if authentication is successful - * @throws Exception + * @throws HTTPException\ForbiddenException + * @throws HTTPException\NotFoundException */ public static function getIdFromPasswordAuthentication($user_info, $password, $third_party = false) { @@ -353,7 +546,7 @@ class User return $user['uid']; } - throw new Exception(DI::l10n()->t('Login failed')); + throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed')); } /** @@ -367,9 +560,9 @@ class User * * @param mixed $user_info * @return array - * @throws Exception + * @throws HTTPException\NotFoundException */ - private static function getAuthenticationInfo($user_info) + public static function getAuthenticationInfo($user_info) { $user = null; @@ -411,7 +604,7 @@ class User } if (!DBA::isResult($user)) { - throw new Exception(DI::l10n()->t('User not found')); + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found')); } } @@ -422,6 +615,7 @@ class User * Generates a human-readable random password * * @return string + * @throws Exception */ public static function generateNewPassword() { @@ -437,7 +631,7 @@ class User */ public static function isPasswordExposed($password) { - $cache = new \DivineOmega\DOFileCachePSR6\CacheItemPool(); + $cache = new CacheItemPool(); $cache->changeConfig([ 'cacheDirectory' => get_temppath() . '/password-exposed-cache/', ]); @@ -446,7 +640,7 @@ class User $passwordExposedChecker = new PasswordExposed\PasswordExposedChecker(null, $cache); return $passwordExposedChecker->passwordExposed($password) === PasswordExposed\PasswordStatus::EXPOSED; - } catch (\Exception $e) { + } catch (Exception $e) { Logger::error('Password Exposed Exception: ' . $e->getMessage(), [ 'code' => $e->getCode(), 'file' => $e->getFile(), @@ -543,20 +737,28 @@ class User * * @param string $nickname The nickname that should be checked * @return boolean True is the nickname is blocked on the node - * @throws InternalServerErrorException */ public static function isNicknameBlocked($nickname) { $forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', ''); + if (!empty($forbidden_nicknames)) { + $forbidden = explode(',', $forbidden_nicknames); + $forbidden = array_map('trim', $forbidden); + } else { + $forbidden = []; + } - // if the config variable is empty return false - if (empty($forbidden_nicknames)) { + // Add the name of the internal actor to the "forbidden" list + $actor_name = self::getActorName(); + if (!empty($actor_name)) { + $forbidden[] = $actor_name; + } + + if (empty($forbidden)) { return false; } // check if the nickname is in the list of blocked nicknames - $forbidden = explode(',', $forbidden_nicknames); - $forbidden = array_map('trim', $forbidden); if (in_array(strtolower($nickname), $forbidden)) { return true; } @@ -579,9 +781,9 @@ class User * * @param array $data * @return array - * @throws \ErrorException - * @throws InternalServerErrorException - * @throws \ImagickException + * @throws ErrorException + * @throws HTTPException\InternalServerErrorException + * @throws ImagickException * @throws Exception */ public static function create(array $data) @@ -707,7 +909,7 @@ class User $nickname = $data['nickname'] = strtolower($nickname); - if (!preg_match('/^[a-z0-9][a-z0-9\_]*$/', $nickname)) { + if (!preg_match('/^[a-z0-9][a-z0-9_]*$/', $nickname)) { throw new Exception(DI::l10n()->t('Your nickname can only contain a-z, 0-9 and _.')); } @@ -823,7 +1025,7 @@ class User $photo_failure = false; $filename = basename($photo); - $curlResult = Network::curl($photo, true); + $curlResult = DI::httpRequest()->get($photo); if ($curlResult->isSuccess()) { $img_str = $curlResult->getBody(); $type = $curlResult->getContentType(); @@ -896,7 +1098,7 @@ class User * * @return bool True, if the allow was successful * - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException * @throws Exception */ public static function allow(string $hash) @@ -957,6 +1159,9 @@ class User return false; } + // Delete the avatar + Photo::delete(['uid' => $register['uid']]); + return DBA::delete('user', ['uid' => $register['uid']]) && Register::deleteByHash($register['hash']); } @@ -970,16 +1175,16 @@ class User * @param string $lang The user's language (default is english) * * @return bool True, if the user was created successfully - * @throws InternalServerErrorException - * @throws \ErrorException - * @throws \ImagickException + * @throws HTTPException\InternalServerErrorException + * @throws ErrorException + * @throws ImagickException */ public static function createMinimal(string $name, string $email, string $nick, string $lang = L10n::DEFAULT) { if (empty($name) || empty($email) || empty($nick)) { - throw new InternalServerErrorException('Invalid arguments.'); + throw new HTTPException\InternalServerErrorException('Invalid arguments.'); } $result = self::create([ @@ -1042,7 +1247,7 @@ class User * @param string $siteurl * @param string $password Plaintext password * @return NULL|boolean from notification() and email() inherited - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public static function sendRegisterPendingEmail($user, $sitename, $siteurl, $password) { @@ -1078,16 +1283,16 @@ class User * * It's here as a function because the mail is sent from different parts * - * @param \Friendica\Core\L10n $l10n The used language - * @param array $user User record array - * @param string $sitename - * @param string $siteurl - * @param string $password Plaintext password + * @param L10n $l10n The used language + * @param array $user User record array + * @param string $sitename + * @param string $siteurl + * @param string $password Plaintext password * * @return NULL|boolean from notification() and email() inherited - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ - public static function sendRegisterOpenEmail(\Friendica\Core\L10n $l10n, $user, $sitename, $siteurl, $password) + public static function sendRegisterOpenEmail(L10n $l10n, $user, $sitename, $siteurl, $password) { $preamble = Strings::deindent($l10n->t( ' @@ -1144,7 +1349,7 @@ class User /** * @param int $uid user to remove * @return bool - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public static function remove(int $uid) { @@ -1162,7 +1367,7 @@ class User // unique), so it cannot be re-registered in the future. DBA::insert('userd', ['username' => $user['nickname']]); - // The user and related data will be deleted in "cron_expire_and_remove_users" (cronjobs.php) + // The user and related data will be deleted in Friendica\Worker\ExpireAndRemoveUsers DBA::update('user', ['account_removed' => true, 'account_expires_on' => DateTimeFormat::utc('now + 7 day')], ['uid' => $uid]); Worker::add(PRIORITY_HIGH, 'Notifier', Delivery::REMOVAL, $uid); @@ -1272,6 +1477,7 @@ class User 'total_users' => 0, 'active_users_halfyear' => 0, 'active_users_monthly' => 0, + 'active_users_weekly' => 0, ]; $userStmt = DBA::select('owner-view', ['uid', 'login_date', 'last-item'], @@ -1284,6 +1490,7 @@ class User $halfyear = time() - (180 * 24 * 60 * 60); $month = time() - (30 * 24 * 60 * 60); + $week = time() - (7 * 24 * 60 * 60); while ($user = DBA::fetch($userStmt)) { $statistics['total_users']++; @@ -1297,6 +1504,11 @@ class User ) { $statistics['active_users_monthly']++; } + + if ((strtotime($user['login_date']) > $week) || (strtotime($user['last-item']) > $week) + ) { + $statistics['active_users_weekly']++; + } } DBA::close($userStmt); @@ -1321,10 +1533,13 @@ class User $condition = []; switch ($type) { case 'active': + $condition['account_removed'] = false; $condition['blocked'] = false; break; case 'blocked': + $condition['account_removed'] = false; $condition['blocked'] = true; + $condition['verified'] = true; break; case 'removed': $condition['account_removed'] = true; diff --git a/src/Model/UserItem.php b/src/Model/UserItem.php index 89dbafed82..38e9adc6ea 100644 --- a/src/Model/UserItem.php +++ b/src/Model/UserItem.php @@ -27,6 +27,7 @@ use Friendica\Database\DBA; use Friendica\DI; use Friendica\Util\Strings; use Friendica\Model\Tag; +use Friendica\Protocol\Activity; class UserItem { @@ -50,20 +51,38 @@ class UserItem */ public static function setNotification(int $iid) { - $fields = ['id', 'uri-id', 'uid', 'body', 'parent', 'gravity', 'tag', 'contact-id', 'thr-parent', 'parent-uri', 'author-id']; + $fields = ['id', 'uri-id', 'parent-uri-id', 'uid', 'body', 'parent', 'gravity', 'tag', + 'private', 'contact-id', 'thr-parent', 'parent-uri', 'author-id', 'verb']; $item = Item::selectFirst($fields, ['id' => $iid, 'origin' => false]); if (!DBA::isResult($item)) { return; } - // fetch all users in the thread + // "Activity::FOLLOW" is an automated activity, so we ignore it here + if ($item['verb'] == Activity::FOLLOW) { + return; + } + + if ($item['uid'] == 0) { + $uids = []; + } else { + // Always include the item user + $uids = [$item['uid']]; + } + + // Add every user who participated so far in this thread + // This can only happen with participations on global items. (means: uid = 0) $users = DBA::p("SELECT DISTINCT(`contact`.`uid`) FROM `item` INNER JOIN `contact` ON `contact`.`id` = `item`.`contact-id` AND `contact`.`uid` != 0 WHERE `parent` IN (SELECT `parent` FROM `item` WHERE `id`=?)", $iid); while ($user = DBA::fetch($users)) { - self::setNotificationForUser($item, $user['uid']); + $uids[] = $user['uid']; } DBA::close($users); + + foreach (array_unique($uids) as $uid) { + self::setNotificationForUser($item, $uid); + } } /** @@ -75,7 +94,7 @@ class UserItem private static function setNotificationForUser(array $item, int $uid) { $thread = Item::selectFirstThreadForUser($uid, ['ignored'], ['iid' => $item['parent'], 'deleted' => false]); - if ($thread['ignored']) { + if (!empty($thread['ignored'])) { return; } @@ -100,32 +119,35 @@ class UserItem return; } - if (self::checkImplicitMention($item, $profiles)) { - $notification_type = $notification_type | self::NOTIF_IMPLICIT_TAGGED; - } + // Only create notifications for posts and comments, not for activities + if (in_array($item['gravity'], [GRAVITY_PARENT, GRAVITY_COMMENT])) { + if (self::checkImplicitMention($item, $profiles)) { + $notification_type = $notification_type | self::NOTIF_IMPLICIT_TAGGED; + } - if (self::checkExplicitMention($item, $profiles)) { - $notification_type = $notification_type | self::NOTIF_EXPLICIT_TAGGED; - } + if (self::checkExplicitMention($item, $profiles)) { + $notification_type = $notification_type | self::NOTIF_EXPLICIT_TAGGED; + } - if (self::checkCommentedThread($item, $contacts)) { - $notification_type = $notification_type | self::NOTIF_THREAD_COMMENT; - } + if (self::checkCommentedThread($item, $contacts)) { + $notification_type = $notification_type | self::NOTIF_THREAD_COMMENT; + } - if (self::checkDirectComment($item, $contacts)) { - $notification_type = $notification_type | self::NOTIF_DIRECT_COMMENT; - } + if (self::checkDirectComment($item, $contacts)) { + $notification_type = $notification_type | self::NOTIF_DIRECT_COMMENT; + } - if (self::checkDirectCommentedThread($item, $contacts)) { - $notification_type = $notification_type | self::NOTIF_DIRECT_THREAD_COMMENT; - } + if (self::checkDirectCommentedThread($item, $contacts)) { + $notification_type = $notification_type | self::NOTIF_DIRECT_THREAD_COMMENT; + } - if (self::checkCommentedParticipation($item, $contacts)) { - $notification_type = $notification_type | self::NOTIF_COMMENT_PARTICIPATION; - } + if (self::checkCommentedParticipation($item, $contacts)) { + $notification_type = $notification_type | self::NOTIF_COMMENT_PARTICIPATION; + } - if (self::checkActivityParticipation($item, $contacts)) { - $notification_type = $notification_type | self::NOTIF_ACTIVITY_PARTICIPATION; + if (self::checkActivityParticipation($item, $contacts)) { + $notification_type = $notification_type | self::NOTIF_ACTIVITY_PARTICIPATION; + } } if (empty($notification_type)) { @@ -134,7 +156,9 @@ class UserItem Logger::info('Set notification', ['iid' => $item['id'], 'uid' => $uid, 'notification-type' => $notification_type]); - DBA::update('user-item', ['notification-type' => $notification_type], ['iid' => $item['id'], 'uid' => $uid], true); + $fields = ['notification-type' => $notification_type]; + Post\User::update($item['uri-id'], $uid, $fields); + DBA::update('user-item', $fields, ['iid' => $item['id'], 'uid' => $uid], true); } /** @@ -197,16 +221,22 @@ class UserItem */ private static function checkShared(array $item, int $uid) { - if ($item['gravity'] != GRAVITY_PARENT) { + // Only check on original posts and reshare ("announce") activities, otherwise return + if (($item['gravity'] != GRAVITY_PARENT) && ($item['verb'] != Activity::ANNOUNCE)) { return false; } - // Either the contact had posted something directly + // Check if the contact posted or shared something directly if (DBA::exists('contact', ['id' => $item['contact-id'], 'notify_new_posts' => true])) { return true; } - // Or the contact is a mentioned forum + // The following check doesn't make sense on activities, so quit here + if ($item['verb'] == Activity::ANNOUNCE) { + return false; + } + + // Check if the contact is a mentioned forum $tags = DBA::select('tag-view', ['url'], ['uri-id' => $item['uri-id'], 'type' => [Tag::MENTION, Tag::EXCLUSIVE_MENTION]]); while ($tag = DBA::fetch($tags)) { $condition = ['nurl' => Strings::normaliseLink($tag['url']), 'uid' => $uid, 'notify_new_posts' => true, 'contact-type' => Contact::TYPE_COMMUNITY]; diff --git a/src/Model/Verb.php b/src/Model/Verb.php new file mode 100644 index 0000000000..6109691cd7 --- /dev/null +++ b/src/Model/Verb.php @@ -0,0 +1,72 @@ +. + * + */ + +namespace Friendica\Model; + +use Friendica\Database\Database; +use Friendica\Database\DBA; + +class Verb +{ + /** + * Insert a verb record and return its id + * + * @param string $verb + * + * @return integer verb id + * @throws \Exception + */ + public static function getID(string $verb) + { + if (empty($verb)) { + return 0; + } + + $verb_record = DBA::selectFirst('verb', ['id'], ['name' => $verb]); + if (DBA::isResult($verb_record)) { + return $verb_record['id']; + } + + DBA::insert('verb', ['name' => $verb], Database::INSERT_IGNORE); + + return DBA::lastInsertId(); + } + + /** + * Return verb name for the given ID + * + * @param integer $id + * @return string verb + */ + public static function getByID(int $id) + { + if (empty($id)) { + return ''; + } + + $verb_record = DBA::selectFirst('verb', ['name'], ['id' => $id]); + if (!DBA::isResult($verb_record)) { + return ''; + } + + return $verb_record['name']; + } +} diff --git a/src/Module/Acctlink.php b/src/Module/Acctlink.php index bcd5e19f84..bdcc3cf6ff 100644 --- a/src/Module/Acctlink.php +++ b/src/Module/Acctlink.php @@ -22,8 +22,8 @@ namespace Friendica\Module; use Friendica\BaseModule; -use Friendica\Network\Probe; use Friendica\Core\System; +use Friendica\Model\Contact; /** * Redirects to another URL based on the parameter 'addr' @@ -35,10 +35,9 @@ class Acctlink extends BaseModule $addr = trim($_GET['addr'] ?? ''); if ($addr) { - $url = Probe::uri($addr)['url'] ?? ''; - + $url = Contact::getByURL($addr)['url'] ?? ''; if ($url) { - System::externalRedirect($url); + System::externalRedirect($url['url']); exit(); } } diff --git a/src/Module/Admin/Addons/Details.php b/src/Module/Admin/Addons/Details.php index 8e0e14f22b..4c1fe2df92 100644 --- a/src/Module/Admin/Addons/Details.php +++ b/src/Module/Admin/Addons/Details.php @@ -32,26 +32,24 @@ class Details extends BaseAdmin { public static function post(array $parameters = []) { - parent::post($parameters); + self::checkAdminAccess(); - $a = DI::app(); + $addon = Strings::sanitizeFilePathItem($parameters['addon']); - if ($a->argc > 2) { - // @TODO: Replace with parameter from router - $addon = $a->argv[2]; - $addon = Strings::sanitizeFilePathItem($addon); - if (is_file('addon/' . $addon . '/' . $addon . '.php')) { - include_once 'addon/' . $addon . '/' . $addon . '.php'; - if (function_exists($addon . '_addon_admin_post')) { - $func = $addon . '_addon_admin_post'; - $func($a); - } + $redirect = 'admin/addons/' . $addon; - DI::baseUrl()->redirect('admin/addons/' . $addon); + if (is_file('addon/' . $addon . '/' . $addon . '.php')) { + include_once 'addon/' . $addon . '/' . $addon . '.php'; + + if (function_exists($addon . '_addon_admin_post')) { + self::checkFormSecurityTokenRedirectOnError($redirect, 'admin_addons_details'); + + $func = $addon . '_addon_admin_post'; + $func(DI::app()); } } - DI::baseUrl()->redirect('admin/addons'); + DI::baseUrl()->redirect($redirect); } public static function content(array $parameters = []) @@ -62,79 +60,73 @@ class Details extends BaseAdmin $addons_admin = Addon::getAdminList(); - if ($a->argc > 2) { - // @TODO: Replace with parameter from router - $addon = $a->argv[2]; - $addon = Strings::sanitizeFilePathItem($addon); - if (!is_file("addon/$addon/$addon.php")) { - notice(DI::l10n()->t('Addon not found.')); - Addon::uninstall($addon); - DI::baseUrl()->redirect('admin/addons'); - } - - if (($_GET['action'] ?? '') == 'toggle') { - parent::checkFormSecurityTokenRedirectOnError('/admin/addons', 'admin_themes', 't'); - - // Toggle addon status - if (Addon::isEnabled($addon)) { - Addon::uninstall($addon); - info(DI::l10n()->t('Addon %s disabled.', $addon)); - } else { - Addon::install($addon); - info(DI::l10n()->t('Addon %s enabled.', $addon)); - } - - DI::baseUrl()->redirect('admin/addons/' . $addon); - } - - // display addon details - if (Addon::isEnabled($addon)) { - $status = 'on'; - $action = DI::l10n()->t('Disable'); - } else { - $status = 'off'; - $action = DI::l10n()->t('Enable'); - } - - $readme = null; - if (is_file("addon/$addon/README.md")) { - $readme = Markdown::convert(file_get_contents("addon/$addon/README.md"), false); - } elseif (is_file("addon/$addon/README")) { - $readme = '
    ' . file_get_contents("addon/$addon/README") . '
    '; - } - - $admin_form = ''; - if (array_key_exists($addon, $addons_admin)) { - require_once "addon/$addon/$addon.php"; - $func = $addon . '_addon_admin'; - $func($a, $admin_form); - } - - $t = Renderer::getMarkupTemplate('admin/addons/details.tpl'); - - return Renderer::replaceMacros($t, [ - '$title' => DI::l10n()->t('Administration'), - '$page' => DI::l10n()->t('Addons'), - '$toggle' => DI::l10n()->t('Toggle'), - '$settings' => DI::l10n()->t('Settings'), - '$baseurl' => DI::baseUrl()->get(true), - - '$addon' => $addon, - '$status' => $status, - '$action' => $action, - '$info' => Addon::getInfo($addon), - '$str_author' => DI::l10n()->t('Author: '), - '$str_maintainer' => DI::l10n()->t('Maintainer: '), - - '$admin_form' => $admin_form, - '$function' => 'addons', - '$screenshot' => '', - '$readme' => $readme, - - '$form_security_token' => parent::getFormSecurityToken('admin_themes'), - ]); + $addon = Strings::sanitizeFilePathItem($parameters['addon']); + if (!is_file("addon/$addon/$addon.php")) { + notice(DI::l10n()->t('Addon not found.')); + Addon::uninstall($addon); + DI::baseUrl()->redirect('admin/addons'); } - DI::baseUrl()->redirect('admin/addons'); + if (($_GET['action'] ?? '') == 'toggle') { + self::checkFormSecurityTokenRedirectOnError('/admin/addons', 'admin_addons_details', 't'); + + // Toggle addon status + if (Addon::isEnabled($addon)) { + Addon::uninstall($addon); + info(DI::l10n()->t('Addon %s disabled.', $addon)); + } else { + Addon::install($addon); + info(DI::l10n()->t('Addon %s enabled.', $addon)); + } + + DI::baseUrl()->redirect('admin/addons/' . $addon); + } + + // display addon details + if (Addon::isEnabled($addon)) { + $status = 'on'; + $action = DI::l10n()->t('Disable'); + } else { + $status = 'off'; + $action = DI::l10n()->t('Enable'); + } + + $readme = null; + if (is_file("addon/$addon/README.md")) { + $readme = Markdown::convert(file_get_contents("addon/$addon/README.md"), false); + } elseif (is_file("addon/$addon/README")) { + $readme = '
    ' . file_get_contents("addon/$addon/README") . '
    '; + } + + $admin_form = ''; + if (array_key_exists($addon, $addons_admin)) { + require_once "addon/$addon/$addon.php"; + $func = $addon . '_addon_admin'; + $func($a, $admin_form); + } + + $t = Renderer::getMarkupTemplate('admin/addons/details.tpl'); + + return Renderer::replaceMacros($t, [ + '$title' => DI::l10n()->t('Administration'), + '$page' => DI::l10n()->t('Addons'), + '$toggle' => DI::l10n()->t('Toggle'), + '$settings' => DI::l10n()->t('Settings'), + '$baseurl' => DI::baseUrl()->get(true), + + '$addon' => $addon, + '$status' => $status, + '$action' => $action, + '$info' => Addon::getInfo($addon), + '$str_author' => DI::l10n()->t('Author: '), + '$str_maintainer' => DI::l10n()->t('Maintainer: '), + + '$admin_form' => $admin_form, + '$function' => 'addons', + '$screenshot' => '', + '$readme' => $readme, + + '$form_security_token' => self::getFormSecurityToken('admin_addons_details'), + ]); } } diff --git a/src/Module/Admin/Addons/Index.php b/src/Module/Admin/Addons/Index.php index 3049cdc6a7..0ffe32edb3 100644 --- a/src/Module/Admin/Addons/Index.php +++ b/src/Module/Admin/Addons/Index.php @@ -34,12 +34,12 @@ class Index extends BaseAdmin // reload active themes if (!empty($_GET['action'])) { - parent::checkFormSecurityTokenRedirectOnError('/admin/addons', 'admin_addons', 't'); + self::checkFormSecurityTokenRedirectOnError('/admin/addons', 'admin_addons', 't'); switch ($_GET['action']) { case 'reload': Addon::reload(); - info('Addons reloaded'); + info(DI::l10n()->t('Addons reloaded')); break; case 'toggle' : @@ -50,7 +50,7 @@ class Index extends BaseAdmin } elseif (Addon::install($addon)) { info(DI::l10n()->t('Addon %s enabled.', $addon)); } else { - info(DI::l10n()->t('Addon %s failed to install.', $addon)); + notice(DI::l10n()->t('Addon %s failed to install.', $addon)); } break; @@ -73,7 +73,7 @@ class Index extends BaseAdmin '$addons' => $addons, '$pcount' => count($addons), '$noplugshint' => DI::l10n()->t('There are currently no addons available on your node. You can find the official addon repository at %1$s and might find other interesting addons in the open addon registry at %2$s', 'https://github.com/friendica/friendica-addons', 'http://addons.friendi.ca'), - '$form_security_token' => parent::getFormSecurityToken('admin_addons'), + '$form_security_token' => self::getFormSecurityToken('admin_addons'), ]); } } diff --git a/src/Module/Admin/BaseUsers.php b/src/Module/Admin/BaseUsers.php new file mode 100644 index 0000000000..37f7acdf62 --- /dev/null +++ b/src/Module/Admin/BaseUsers.php @@ -0,0 +1,129 @@ +. + * + */ + +namespace Friendica\Module\Admin; + +use Friendica\Core\Renderer; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Register; +use Friendica\Model\User; +use Friendica\Module\BaseAdmin; +use Friendica\Util\Temporal; + +abstract class BaseUsers extends BaseAdmin +{ + /** + * Get the users admin tabs menu + * + * @param string $selectedTab + * @return string HTML + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + protected static function getTabsHTML(string $selectedTab) + { + $active = DBA::count('user', ['blocked' => false, 'verified' => true, 'account_removed' => false]); + $pending = Register::getPendingCount(); + $blocked = DBA::count('user', ['blocked' => true, 'verified' => true]); + $deleted = DBA::count('user', ['account_removed' => true]); + + $tabs = [ + [ + 'label' => DI::l10n()->t('All') . ' (' . DBA::count('user') . ')', + 'url' => 'admin/users', + 'sel' => !$selectedTab || $selectedTab == 'all' ? 'active' : '', + 'title' => DI::l10n()->t('List of all users'), + 'id' => 'admin-users-all', + 'accesskey' => 'a', + ], + [ + 'label' => DI::l10n()->t('Active') . ' (' . $active . ')', + 'url' => 'admin/users/active', + 'sel' => $selectedTab == 'active' ? 'active' : '', + 'title' => DI::l10n()->t('List of active accounts'), + 'id' => 'admin-users-active', + 'accesskey' => 'k', + ], + [ + 'label' => DI::l10n()->t('Pending') . ($pending ? ' (' . $pending . ')' : ''), + 'url' => 'admin/users/pending', + 'sel' => $selectedTab == 'pending' ? 'active' : '', + 'title' => DI::l10n()->t('List of pending registrations'), + 'id' => 'admin-users-pending', + 'accesskey' => 'p', + ], + [ + 'label' => DI::l10n()->t('Blocked') . ($blocked ? ' (' . $blocked . ')' : ''), + 'url' => 'admin/users/blocked', + 'sel' => $selectedTab == 'blocked' ? 'active' : '', + 'title' => DI::l10n()->t('List of blocked users'), + 'id' => 'admin-users-blocked', + 'accesskey' => 'b', + ], + [ + 'label' => DI::l10n()->t('Deleted') . ($deleted ? ' (' . $deleted . ')' : ''), + 'url' => 'admin/users/deleted', + 'sel' => $selectedTab == 'deleted' ? 'active' : '', + 'title' => DI::l10n()->t('List of pending user deletions'), + 'id' => 'admin-users-deleted', + 'accesskey' => 'd', + ], + ]; + + $tpl = Renderer::getMarkupTemplate('common_tabs.tpl'); + return Renderer::replaceMacros($tpl, ['$tabs' => $tabs]); + } + + protected static function setupUserCallback() { + $adminlist = explode(',', str_replace(' ', '', DI::config()->get('config', 'admin_email'))); + return function ($user) use ($adminlist) { + $page_types = [ + User::PAGE_FLAGS_NORMAL => DI::l10n()->t('Normal Account Page'), + User::PAGE_FLAGS_SOAPBOX => DI::l10n()->t('Soapbox Page'), + User::PAGE_FLAGS_COMMUNITY => DI::l10n()->t('Public Forum'), + User::PAGE_FLAGS_FREELOVE => DI::l10n()->t('Automatic Friend Page'), + User::PAGE_FLAGS_PRVGROUP => DI::l10n()->t('Private Forum') + ]; + $account_types = [ + User::ACCOUNT_TYPE_PERSON => DI::l10n()->t('Personal Page'), + User::ACCOUNT_TYPE_ORGANISATION => DI::l10n()->t('Organisation Page'), + User::ACCOUNT_TYPE_NEWS => DI::l10n()->t('News Page'), + User::ACCOUNT_TYPE_COMMUNITY => DI::l10n()->t('Community Forum'), + User::ACCOUNT_TYPE_RELAY => DI::l10n()->t('Relay'), + ]; + + $user['page_flags_raw'] = $user['page-flags']; + $user['page_flags'] = $page_types[$user['page-flags']]; + + $user['account_type_raw'] = ($user['page_flags_raw'] == 0) ? $user['account-type'] : -1; + $user['account_type'] = ($user['page_flags_raw'] == 0) ? $account_types[$user['account-type']] : ''; + + $user['register_date'] = Temporal::getRelativeDate($user['register_date']); + $user['login_date'] = Temporal::getRelativeDate($user['login_date']); + $user['lastitem_date'] = Temporal::getRelativeDate($user['last-item']); + $user['is_admin'] = in_array($user['email'], $adminlist); + $user['is_deletable'] = !$user['account_removed'] && intval($user['uid']) != local_user(); + $user['deleted'] = ($user['account_removed'] ? Temporal::getRelativeDate($user['account_expires_on']) : False); + + return $user; + }; + } +} diff --git a/src/Module/Admin/Blocklist/Contact.php b/src/Module/Admin/Blocklist/Contact.php index 8893623234..73cb168190 100644 --- a/src/Module/Admin/Blocklist/Contact.php +++ b/src/Module/Admin/Blocklist/Contact.php @@ -32,19 +32,19 @@ class Contact extends BaseAdmin { public static function post(array $parameters = []) { - parent::post($parameters); + self::checkAdminAccess(); + + self::checkFormSecurityTokenRedirectOnError('/admin/blocklist/contact', 'admin_contactblock'); $contact_url = $_POST['contact_url'] ?? ''; $block_reason = $_POST['contact_block_reason'] ?? ''; $contacts = $_POST['contacts'] ?? []; - parent::checkFormSecurityTokenRedirectOnError('/admin/blocklist/contact', 'admin_contactblock'); - if (!empty($_POST['page_contactblock_block'])) { $contact_id = Model\Contact::getIdForURL($contact_url); if ($contact_id) { Model\Contact::block($contact_id, $block_reason); - notice(DI::l10n()->t('The contact has been blocked from the node')); + info(DI::l10n()->t('The contact has been blocked from the node')); } else { notice(DI::l10n()->t('Could not find any contact entry for this URL (%s)', $contact_url)); } @@ -54,7 +54,7 @@ class Contact extends BaseAdmin foreach ($contacts as $uid) { Model\Contact::unblock($uid); } - notice(DI::l10n()->tt('%s contact unblocked', '%s contacts unblocked', count($contacts))); + info(DI::l10n()->tt('%s contact unblocked', '%s contacts unblocked', count($contacts))); } DI::baseUrl()->redirect('admin/blocklist/contact'); @@ -89,7 +89,7 @@ class Contact extends BaseAdmin '$h_newblock' => DI::l10n()->t('Block New Remote Contact'), '$th_contacts' => [DI::l10n()->t('Photo'), DI::l10n()->t('Name'), DI::l10n()->t('Reason')], - '$form_security_token' => parent::getFormSecurityToken('admin_contactblock'), + '$form_security_token' => self::getFormSecurityToken('admin_contactblock'), // values // '$baseurl' => DI::baseUrl()->get(true), diff --git a/src/Module/Admin/Blocklist/Server.php b/src/Module/Admin/Blocklist/Server.php index d0c632b0bf..f1c55f4124 100644 --- a/src/Module/Admin/Blocklist/Server.php +++ b/src/Module/Admin/Blocklist/Server.php @@ -30,13 +30,13 @@ class Server extends BaseAdmin { public static function post(array $parameters = []) { - parent::post($parameters); + self::checkAdminAccess(); if (empty($_POST['page_blocklist_save']) && empty($_POST['page_blocklist_edit'])) { return; } - parent::checkFormSecurityTokenRedirectOnError('/admin/blocklist/server', 'admin_blocklist'); + self::checkFormSecurityTokenRedirectOnError('/admin/blocklist/server', 'admin_blocklist'); if (!empty($_POST['page_blocklist_save'])) { // Add new item to blocklist @@ -46,7 +46,7 @@ class Server extends BaseAdmin 'reason' => Strings::escapeTags(trim($_POST['newentry_reason'])) ]; DI::config()->set('system', 'blocklist', $blocklist); - info(DI::l10n()->t('Server domain pattern added to blocklist.') . EOL); + info(DI::l10n()->t('Server domain pattern added to blocklist.')); } else { // Edit the entries from blocklist $blocklist = []; @@ -62,7 +62,6 @@ class Server extends BaseAdmin } } DI::config()->set('system', 'blocklist', $blocklist); - info(DI::l10n()->t('Site blocklist updated.') . EOL); } DI::baseUrl()->redirect('admin/blocklist/server'); @@ -77,8 +76,8 @@ class Server extends BaseAdmin if (is_array($blocklist)) { foreach ($blocklist as $id => $b) { $blocklistform[] = [ - 'domain' => ["domain[$id]", DI::l10n()->t('Blocked server domain pattern'), $b['domain'], '', 'required', '', ''], - 'reason' => ["reason[$id]", DI::l10n()->t("Reason for the block"), $b['reason'], '', 'required', '', ''], + 'domain' => ["domain[$id]", DI::l10n()->t('Blocked server domain pattern'), $b['domain'], '', DI::l10n()->t('Required'), '', ''], + 'reason' => ["reason[$id]", DI::l10n()->t("Reason for the block"), $b['reason'], '', DI::l10n()->t('Required'), '', ''], 'delete' => ["delete[$id]", DI::l10n()->t("Delete server domain pattern") . ' (' . $b['domain'] . ')', false, DI::l10n()->t("Check to delete this entry from the blocklist")] ]; } @@ -88,7 +87,7 @@ class Server extends BaseAdmin return Renderer::replaceMacros($t, [ '$title' => DI::l10n()->t('Administration'), '$page' => DI::l10n()->t('Server Domain Pattern Blocklist'), - '$intro' => DI::l10n()->t('This page can be used to define a blacklist of server domain patterns from the federated network that are not allowed to interact with your node. For each domain pattern you should also provide the reason why you block it.'), + '$intro' => DI::l10n()->t('This page can be used to define a blocklist of server domain patterns from the federated network that are not allowed to interact with your node. For each domain pattern you should also provide the reason why you block it.'), '$public' => DI::l10n()->t('The list of blocked server domain patterns will be made publically available on the /friendica page so that your users and people investigating communication problems can find the reason easily.'), '$syntax' => DI::l10n()->t('

    The server domain pattern syntax is case-insensitive shell wildcard, comprising the following special characters:

      @@ -97,8 +96,8 @@ class Server extends BaseAdmin
    • [<char1><char2>...]: char1 or char2
    '), '$addtitle' => DI::l10n()->t('Add new entry to block list'), - '$newdomain' => ['newentry_domain', DI::l10n()->t('Server Domain Pattern'), '', DI::l10n()->t('The domain pattern of the new server to add to the block list. Do not include the protocol.'), 'required', '', ''], - '$newreason' => ['newentry_reason', DI::l10n()->t('Block reason'), '', DI::l10n()->t('The reason why you blocked this server domain pattern.'), 'required', '', ''], + '$newdomain' => ['newentry_domain', DI::l10n()->t('Server Domain Pattern'), '', DI::l10n()->t('The domain pattern of the new server to add to the block list. Do not include the protocol.'), DI::l10n()->t('Required'), '', ''], + '$newreason' => ['newentry_reason', DI::l10n()->t('Block reason'), '', DI::l10n()->t('The reason why you blocked this server domain pattern.'), DI::l10n()->t('Required'), '', ''], '$submit' => DI::l10n()->t('Add Entry'), '$savechanges' => DI::l10n()->t('Save changes to the blocklist'), '$currenttitle' => DI::l10n()->t('Current Entries in the Blocklist'), @@ -108,7 +107,7 @@ class Server extends BaseAdmin '$entries' => $blocklistform, '$baseurl' => DI::baseUrl()->get(true), '$confirm_delete' => DI::l10n()->t('Delete entry from blocklist?'), - '$form_security_token' => parent::getFormSecurityToken("admin_blocklist") + '$form_security_token' => self::getFormSecurityToken("admin_blocklist") ]); } } diff --git a/src/Module/Admin/DBSync.php b/src/Module/Admin/DBSync.php index dd7febcc59..662fe08e27 100644 --- a/src/Module/Admin/DBSync.php +++ b/src/Module/Admin/DBSync.php @@ -36,91 +36,91 @@ class DBSync extends BaseAdmin $a = DI::app(); - $o = ''; + $action = $parameters['action'] ?? ''; + $update = $parameters['update'] ?? 0; - if ($a->argc > 3 && $a->argv[2] === 'mark') { - // @TODO: Replace with parameter from router - $update = intval($a->argv[3]); - if ($update) { - DI::config()->set('database', 'update_' . $update, 'success'); - $curr = DI::config()->get('system', 'build'); - if (intval($curr) == $update) { - DI::config()->set('system', 'build', intval($curr) + 1); + switch ($action) { + case 'mark': + if ($update) { + DI::config()->set('database', 'update_' . $update, 'success'); + $curr = DI::config()->get('system', 'build'); + if (intval($curr) == $update) { + DI::config()->set('system', 'build', intval($curr) + 1); + } + + info(DI::l10n()->t('Update has been marked successful')); } - info(DI::l10n()->t('Update has been marked successful') . EOL); - } - DI::baseUrl()->redirect('admin/dbsync'); - } - if ($a->argc > 2) { - if ($a->argv[2] === 'check') { + break; + case 'check': // @TODO Seems like a similar logic like Update::check() $retval = DBStructure::update($a->getBasePath(), false, true); if ($retval === '') { - $o .= DI::l10n()->t("Database structure update %s was successfully applied.", DB_UPDATE_VERSION) . "
    "; - DI::config()->set('database', 'last_successful_update', DB_UPDATE_VERSION); - DI::config()->set('database', 'last_successful_update_time', time()); + $o = DI::l10n()->t("Database structure update %s was successfully applied.", DB_UPDATE_VERSION) . "
    "; } else { - $o .= DI::l10n()->t("Executing of database structure update %s failed with error: %s", DB_UPDATE_VERSION, $retval) . "
    "; - } - if ($a->argv[2] === 'check') { - return $o; - } - } elseif (intval($a->argv[2])) { - require_once 'update.php'; - - // @TODO: Replace with parameter from router - $update = intval($a->argv[2]); - - $func = 'update_' . $update; - - if (function_exists($func)) { - $retval = $func(); - - if ($retval === Update::FAILED) { - $o .= DI::l10n()->t("Executing %s failed with error: %s", $func, $retval); - } elseif ($retval === Update::SUCCESS) { - $o .= DI::l10n()->t('Update %s was successfully applied.', $func); - DI::config()->set('database', $func, 'success'); - } else { - $o .= DI::l10n()->t('Update %s did not return a status. Unknown if it succeeded.', $func); - } - } else { - $o .= DI::l10n()->t('There was no additional update function %s that needed to be called.', $func) . "
    "; - DI::config()->set('database', $func, 'success'); + $o = DI::l10n()->t("Executing of database structure update %s failed with error: %s", DB_UPDATE_VERSION, $retval) . "
    "; + } + + return $o; + case 'update': + require_once 'update.php'; + + // @TODO: Replace with parameter from router + if ($update) { + $func = 'update_' . $update; + + if (function_exists($func)) { + $retval = $func(); + + if ($retval === Update::FAILED) { + $o = DI::l10n()->t("Executing %s failed with error: %s", $func, $retval); + } elseif ($retval === Update::SUCCESS) { + $o = DI::l10n()->t('Update %s was successfully applied.', $func); + DI::config()->set('database', $func, 'success'); + } else { + $o = DI::l10n()->t('Update %s did not return a status. Unknown if it succeeded.', $func); + } + } else { + $o = DI::l10n()->t('There was no additional update function %s that needed to be called.', $func) . "
    "; + DI::config()->set('database', $func, 'success'); + } + + return $o; + } + + break; + default: + $failed = []; + $configStmt = DBA::select('config', ['k', 'v'], ['cat' => 'database']); + while ($config = DBA::fetch($configStmt)) { + $upd = intval(substr($config['k'], 7)); + if ($upd >= 1139 && $config['v'] != 'success') { + $failed[] = $upd; + } + } + DBA::close($configStmt); + + if (!count($failed)) { + $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('admin/dbsync/structure_check.tpl'), [ + '$base' => DI::baseUrl()->get(true), + '$banner' => DI::l10n()->t('No failed updates.'), + '$check' => DI::l10n()->t('Check database structure'), + ]); + } else { + $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('admin/dbsync/failed_updates.tpl'), [ + '$base' => DI::baseUrl()->get(true), + '$banner' => DI::l10n()->t('Failed Updates'), + '$desc' => DI::l10n()->t('This does not include updates prior to 1139, which did not return a status.'), + '$mark' => DI::l10n()->t("Mark success \x28if update was manually applied\x29"), + '$apply' => DI::l10n()->t('Attempt to execute this update step automatically'), + '$failed' => $failed + ]); } return $o; - } } - $failed = []; - $configStmt = DBA::select('config', ['k', 'v'], ['cat' => 'database']); - while ($config = DBA::fetch($configStmt)) { - $upd = intval(substr($config['k'], 7)); - if ($upd >= 1139 && $config['v'] != 'success') { - $failed[] = $upd; - } - } - DBA::close($configStmt); - - if (!count($failed)) { - $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('admin/dbsync/structure_check.tpl'), [ - '$base' => DI::baseUrl()->get(true), - '$banner' => DI::l10n()->t('No failed updates.'), - '$check' => DI::l10n()->t('Check database structure'), - ]); - } else { - $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('admin/dbsync/failed_updates.tpl'), [ - '$base' => DI::baseUrl()->get(true), - '$banner' => DI::l10n()->t('Failed Updates'), - '$desc' => DI::l10n()->t('This does not include updates prior to 1139, which did not return a status.'), - '$mark' => DI::l10n()->t("Mark success \x28if update was manually applied\x29"), - '$apply' => DI::l10n()->t('Attempt to execute this update step automatically'), - '$failed' => $failed - ]); - } - - return $o; + DI::baseUrl()->redirect('admin/dbsync'); + return ''; } } diff --git a/src/Module/Admin/Features.php b/src/Module/Admin/Features.php index a97bc0e7bb..5054da3fb4 100644 --- a/src/Module/Admin/Features.php +++ b/src/Module/Admin/Features.php @@ -30,9 +30,9 @@ class Features extends BaseAdmin { public static function post(array $parameters = []) { - parent::post($parameters); + self::checkAdminAccess(); - parent::checkFormSecurityTokenRedirectOnError('/admin/features', 'admin_manage_features'); + self::checkFormSecurityTokenRedirectOnError('/admin/features', 'admin_manage_features'); $features = Feature::get(false); @@ -80,7 +80,7 @@ class Features extends BaseAdmin $tpl = Renderer::getMarkupTemplate('admin/features.tpl'); $o = Renderer::replaceMacros($tpl, [ - '$form_security_token' => parent::getFormSecurityToken("admin_manage_features"), + '$form_security_token' => self::getFormSecurityToken("admin_manage_features"), '$baseurl' => DI::baseUrl()->get(true), '$title' => DI::l10n()->t('Manage Additional Features'), '$features' => $features, diff --git a/src/Module/Admin/Federation.php b/src/Module/Admin/Federation.php index 928a286b14..f5d7cb07bc 100644 --- a/src/Module/Admin/Federation.php +++ b/src/Module/Admin/Federation.php @@ -42,6 +42,7 @@ class Federation extends BaseAdmin 'hubzilla' => ['name' => 'Hubzilla/Red Matrix', 'color' => '#43488a'], // blue from the logo 'mastodon' => ['name' => 'Mastodon', 'color' => '#1a9df9'], // blue from the Mastodon logo 'misskey' => ['name' => 'Misskey', 'color' => '#ccfefd'], // Font color of the homepage + 'nextcloud' => ['name' => 'Nextcloud', 'color' => '#1cafff'], // Logo color 'peertube' => ['name' => 'Peertube', 'color' => '#ffad5c'], // One of the logo colors 'pixelfed' => ['name' => 'Pixelfed', 'color' => '#11da47'], // One of the logo colors 'pleroma' => ['name' => 'Pleroma', 'color' => '#E46F0F'], // Orange from the text that is used on Pleroma instances @@ -64,14 +65,14 @@ class Federation extends BaseAdmin $gservers = DBA::p("SELECT COUNT(*) AS `total`, SUM(`registered-users`) AS `users`, `platform`, ANY_VALUE(`network`) AS `network`, MAX(`version`) AS `version` - FROM `gserver` WHERE `last_contact` >= `last_failure` GROUP BY `platform`"); + FROM `gserver` WHERE NOT `failed` GROUP BY `platform`"); while ($gserver = DBA::fetch($gservers)) { $total += $gserver['total']; $users += $gserver['users']; $versionCounts = []; $versions = DBA::p("SELECT COUNT(*) AS `total`, `version` FROM `gserver` - WHERE `last_contact` >= `last_failure` AND `platform` = ? + WHERE NOT `failed` AND `platform` = ? GROUP BY `version` ORDER BY `version`", $gserver['platform']); while ($version = DBA::fetch($versions)) { $version['version'] = str_replace(["\n", "\r", "\t"], " ", $version['version']); @@ -132,7 +133,6 @@ class Federation extends BaseAdmin // some helpful text $intro = DI::l10n()->t('This page offers you some numbers to the known part of the federated social network your Friendica node is part of. These numbers are not complete but only reflect the part of the network your node is aware of.'); - $hint = DI::l10n()->t('The Auto Discovered Contact Directory feature is not enabled, it will improve the data displayed here.'); // load the template, replace the macros and return the page content $t = Renderer::getMarkupTemplate('admin/federation.tpl'); @@ -140,8 +140,6 @@ class Federation extends BaseAdmin '$title' => DI::l10n()->t('Administration'), '$page' => DI::l10n()->t('Federation Statistics'), '$intro' => $intro, - '$hint' => $hint, - '$autoactive' => DI::config()->get('system', 'poco_completion'), '$counts' => $counts, '$version' => FRIENDICA_VERSION, '$legendtext' => DI::l10n()->t('Currently this node is aware of %d nodes with %d registered users from the following platforms:', $total, $users), @@ -163,8 +161,9 @@ class Federation extends BaseAdmin $newVC = $vv['total']; $newVV = $vv['version']; $lastDot = strrpos($newVV, '.'); + $firstDash = strpos($newVV, '-'); $len = strlen($newVV) - 1; - if (($lastDot == $len - 4) && (!strrpos($newVV, '-rc') == $len - 3)) { + if (($lastDot == $len - 4) && (!strrpos($newVV, '-rc') == $len - 3) && (!$firstDash == $len - 1)) { $newVV = substr($newVV, 0, $lastDot); } if (isset($newV[$newVV])) { diff --git a/src/Module/Admin/Item/Delete.php b/src/Module/Admin/Item/Delete.php index 0ad20f97c9..1ee91f4250 100644 --- a/src/Module/Admin/Item/Delete.php +++ b/src/Module/Admin/Item/Delete.php @@ -31,13 +31,13 @@ class Delete extends BaseAdmin { public static function post(array $parameters = []) { - parent::post($parameters); + self::checkAdminAccess(); if (empty($_POST['page_deleteitem_submit'])) { return; } - parent::checkFormSecurityTokenRedirectOnError('/admin/item/delete', 'admin_deleteitem'); + self::checkFormSecurityTokenRedirectOnError('/admin/item/delete', 'admin_deleteitem'); if (!empty($_POST['page_deleteitem_submit'])) { $guid = trim(Strings::escapeTags($_POST['deleteitemguid'])); @@ -51,7 +51,7 @@ class Delete extends BaseAdmin Item::markForDeletion(['guid' => $guid]); } - info(DI::l10n()->t('Item marked for deletion.') . EOL); + info(DI::l10n()->t('Item marked for deletion.')); DI::baseUrl()->redirect('admin/item/delete'); } @@ -67,8 +67,8 @@ class Delete extends BaseAdmin '$submit' => DI::l10n()->t('Delete this Item'), '$intro1' => DI::l10n()->t('On this page you can delete an item from your node. If the item is a top level posting, the entire thread will be deleted.'), '$intro2' => DI::l10n()->t('You need to know the GUID of the item. You can find it e.g. by looking at the display URL. The last part of http://example.com/display/123456 is the GUID, here 123456.'), - '$deleteitemguid' => ['deleteitemguid', DI::l10n()->t("GUID"), '', DI::l10n()->t("The GUID of the item you want to delete."), 'required', 'autofocus'], - '$form_security_token' => parent::getFormSecurityToken("admin_deleteitem") + '$deleteitemguid' => ['deleteitemguid', DI::l10n()->t("GUID"), '', DI::l10n()->t("The GUID of the item you want to delete."), DI::l10n()->t('Required'), 'autofocus'], + '$form_security_token' => self::getFormSecurityToken("admin_deleteitem") ]); } } diff --git a/src/Module/Admin/Item/Source.php b/src/Module/Admin/Item/Source.php index e35eafd2f2..6e917eb16c 100644 --- a/src/Module/Admin/Item/Source.php +++ b/src/Module/Admin/Item/Source.php @@ -33,15 +33,7 @@ class Source extends BaseAdmin { parent::content($parameters); - $a = DI::app(); - - $guid = null; - // @TODO: Replace with parameter from router - if (!empty($a->argv[3])) { - $guid = $a->argv[3]; - } - - $guid = $_REQUEST['guid'] ?? $guid; + $guid = basename($_REQUEST['guid'] ?? $parameters['guid'] ?? ''); $source = ''; $item_uri = ''; @@ -50,12 +42,14 @@ class Source extends BaseAdmin if (!empty($guid)) { $item = Model\Item::selectFirst(['id', 'uri-id', 'guid', 'uri'], ['guid' => $guid]); - $conversation = Model\Conversation::getByItemUri($item['uri']); + if ($item) { + $conversation = Model\Conversation::getByItemUri($item['uri']); - $item_id = $item['id']; - $item_uri = $item['uri']; - $source = $conversation['source']; - $terms = Model\Tag::getByURIId($item['uri-id'], [Model\Tag::HASHTAG, Model\Tag::MENTION, Model\Tag::IMPLICIT_MENTION]); + $item_id = $item['id']; + $item_uri = $item['uri']; + $source = $conversation['source']; + $terms = Model\Tag::getByURIId($item['uri-id'], [Model\Tag::HASHTAG, Model\Tag::MENTION, Model\Tag::IMPLICIT_MENTION]); + } } $tpl = Renderer::getMarkupTemplate('admin/item/source.tpl'); diff --git a/src/Module/Admin/Logs/Settings.php b/src/Module/Admin/Logs/Settings.php index 5158108e46..7730b487da 100644 --- a/src/Module/Admin/Logs/Settings.php +++ b/src/Module/Admin/Logs/Settings.php @@ -31,27 +31,28 @@ class Settings extends BaseAdmin { public static function post(array $parameters = []) { - parent::post($parameters); + self::checkAdminAccess(); - if (!empty($_POST['page_logs'])) { - parent::checkFormSecurityTokenRedirectOnError('/admin/logs', 'admin_logs'); - - $logfile = (!empty($_POST['logfile']) ? Strings::escapeTags(trim($_POST['logfile'])) : ''); - $debugging = !empty($_POST['debugging']); - $loglevel = ($_POST['loglevel'] ?? '') ?: LogLevel::ERROR; - - if (is_file($logfile) && - !is_writeable($logfile)) { - notice(DI::l10n()->t('The logfile \'%s\' is not writable. No logging possible', $logfile)); - return; - } - - DI::config()->set('system', 'logfile', $logfile); - DI::config()->set('system', 'debugging', $debugging); - DI::config()->set('system', 'loglevel', $loglevel); + if (empty($_POST['page_logs'])) { + return; } - info(DI::l10n()->t("Log settings updated.")); + self::checkFormSecurityTokenRedirectOnError('/admin/logs', 'admin_logs'); + + $logfile = (!empty($_POST['logfile']) ? Strings::escapeTags(trim($_POST['logfile'])) : ''); + $debugging = !empty($_POST['debugging']); + $loglevel = ($_POST['loglevel'] ?? '') ?: LogLevel::ERROR; + + if (is_file($logfile) && + !is_writeable($logfile)) { + notice(DI::l10n()->t('The logfile \'%s\' is not writable. No logging possible', $logfile)); + return; + } + + DI::config()->set('system', 'logfile', $logfile); + DI::config()->set('system', 'debugging', $debugging); + DI::config()->set('system', 'loglevel', $loglevel); + DI::baseUrl()->redirect('admin/logs'); } @@ -86,7 +87,7 @@ class Settings extends BaseAdmin '$debugging' => ['debugging', DI::l10n()->t("Enable Debugging"), DI::config()->get('system', 'debugging'), ""], '$logfile' => ['logfile', DI::l10n()->t("Log file"), DI::config()->get('system', 'logfile'), DI::l10n()->t("Must be writable by web server. Relative to your Friendica top-level directory.")], '$loglevel' => ['loglevel', DI::l10n()->t("Log level"), DI::config()->get('system', 'loglevel'), "", $log_choices], - '$form_security_token' => parent::getFormSecurityToken("admin_logs"), + '$form_security_token' => self::getFormSecurityToken("admin_logs"), '$phpheader' => DI::l10n()->t("PHP logging"), '$phphint' => DI::l10n()->t("To temporarily enable logging of PHP errors and warnings you can prepend the following to the index.php file of your installation. The filename set in the 'error_log' line is relative to the friendica top-level directory and must be writeable by the web server. The option '1' for 'log_errors' and 'display_errors' is to enable these options, set to '0' to disable them."), '$phplogcode' => "error_reporting(E_ERROR | E_WARNING | E_PARSE);\nini_set('error_log','php.out');\nini_set('log_errors','1');\nini_set('display_errors', '1');", diff --git a/src/Module/Admin/PhpInfo.php b/src/Module/Admin/PhpInfo.php index f282e10089..61a004618e 100644 --- a/src/Module/Admin/PhpInfo.php +++ b/src/Module/Admin/PhpInfo.php @@ -27,7 +27,7 @@ class PhpInfo extends BaseAdmin { public static function rawContent(array $parameters = []) { - parent::rawContent($parameters); + self::checkAdminAccess(); phpinfo(); exit(); diff --git a/src/Module/Admin/Queue.php b/src/Module/Admin/Queue.php index b6e7d122fb..7f5329dbfe 100644 --- a/src/Module/Admin/Queue.php +++ b/src/Module/Admin/Queue.php @@ -42,13 +42,10 @@ class Queue extends BaseAdmin { parent::content($parameters); - $a = DI::app(); - - // @TODO: Replace with parameter from router - $deferred = $a->argc > 2 && $a->argv[2] == 'deferred'; + $status = $parameters['status'] ?? ''; // get jobs from the workerqueue table - if ($deferred) { + if ($status == 'deferred') { $condition = ["NOT `done` AND `retrial` > ?", 0]; $sub_title = DI::l10n()->t('Inspect Deferred Worker Queue'); $info = DI::l10n()->t("This page lists the deferred worker jobs. This are jobs that couldn't be executed at the first time."); diff --git a/src/Module/Admin/Site.php b/src/Module/Admin/Site.php index f3086856b3..0e0753a401 100644 --- a/src/Module/Admin/Site.php +++ b/src/Module/Admin/Site.php @@ -28,10 +28,10 @@ use Friendica\Core\Theme; use Friendica\Core\Worker; use Friendica\Database\DBA; use Friendica\DI; -use Friendica\Model\GContact; +use Friendica\Model\Contact; +use Friendica\Model\User; use Friendica\Module\BaseAdmin; use Friendica\Module\Register; -use Friendica\Protocol\PortableContact; use Friendica\Util\BasePath; use Friendica\Util\EMailer\MailBuilder; use Friendica\Util\Strings; @@ -43,7 +43,7 @@ class Site extends BaseAdmin { public static function post(array $parameters = []) { - parent::post($parameters); + self::checkAdminAccess(); self::checkFormSecurityTokenRedirectOnError('/admin/site', 'admin_site'); @@ -103,14 +103,11 @@ class Site extends BaseAdmin // update tables // update profile links in the format "http://server.tld" update_table($a, "profile", ['photo', 'thumb'], $old_url, $new_url); - update_table($a, "term", ['url'], $old_url, $new_url); update_table($a, "contact", ['photo', 'thumb', 'micro', 'url', 'nurl', 'alias', 'request', 'notify', 'poll', 'confirm', 'poco', 'avatar'], $old_url, $new_url); - update_table($a, "gcontact", ['url', 'nurl', 'photo', 'server_url', 'notify', 'alias'], $old_url, $new_url); update_table($a, "item", ['owner-link', 'author-link', 'body', 'plink', 'tag'], $old_url, $new_url); // update profile addresses in the format "user@server.tld" update_table($a, "contact", ['addr'], $old_host, $new_host); - update_table($a, "gcontact", ['connect', 'addr'], $old_host, $new_host); // update config DI::config()->set('system', 'url', $new_url); @@ -123,7 +120,7 @@ class Site extends BaseAdmin } DBA::close($usersStmt); - info("Relocation started. Could take a while to complete."); + info(DI::l10n()->t("Relocation started. Could take a while to complete.")); DI::baseUrl()->redirect('admin/site'); } @@ -152,6 +149,7 @@ class Site extends BaseAdmin $allowed_sites = (!empty($_POST['allowed_sites']) ? Strings::escapeTags(trim($_POST['allowed_sites'])) : ''); $allowed_email = (!empty($_POST['allowed_email']) ? Strings::escapeTags(trim($_POST['allowed_email'])) : ''); $forbidden_nicknames = (!empty($_POST['forbidden_nicknames']) ? strtolower(Strings::escapeTags(trim($_POST['forbidden_nicknames']))) : ''); + $system_actor_name = (!empty($_POST['system_actor_name']) ? Strings::escapeTags(trim($_POST['system_actor_name'])) : ''); $no_oembed_rich_content = !empty($_POST['no_oembed_rich_content']); $allowed_oembed = (!empty($_POST['allowed_oembed']) ? Strings::escapeTags(trim($_POST['allowed_oembed'])) : ''); $block_public = !empty($_POST['block_public']); @@ -177,13 +175,11 @@ class Site extends BaseAdmin $maxloadavg = (!empty($_POST['maxloadavg']) ? intval(trim($_POST['maxloadavg'])) : 20); $maxloadavg_frontend = (!empty($_POST['maxloadavg_frontend']) ? intval(trim($_POST['maxloadavg_frontend'])) : 50); $min_memory = (!empty($_POST['min_memory']) ? intval(trim($_POST['min_memory'])) : 0); - $optimize_max_tablesize = (!empty($_POST['optimize_max_tablesize']) ? intval(trim($_POST['optimize_max_tablesize'])) : 100); - $optimize_fragmentation = (!empty($_POST['optimize_fragmentation']) ? intval(trim($_POST['optimize_fragmentation'])) : 30); - $poco_completion = (!empty($_POST['poco_completion']) ? intval(trim($_POST['poco_completion'])) : false); - $gcontact_discovery = (!empty($_POST['gcontact_discovery']) ? intval(trim($_POST['gcontact_discovery'])) : GContact::DISCOVERY_NONE); + $optimize_tables = (!empty($_POST['optimize_tables']) ? intval(trim($_POST['optimize_tables'])) : false); + $contact_discovery = (!empty($_POST['contact_discovery']) ? intval(trim($_POST['contact_discovery'])) : Contact\Relation::DISCOVERY_NONE); + $synchronize_directory = (!empty($_POST['synchronize_directory']) ? intval(trim($_POST['synchronize_directory'])) : false); $poco_requery_days = (!empty($_POST['poco_requery_days']) ? intval(trim($_POST['poco_requery_days'])) : 7); - $poco_discovery = (!empty($_POST['poco_discovery']) ? intval(trim($_POST['poco_discovery'])) : PortableContact::DISABLED); - $poco_discovery_since = (!empty($_POST['poco_discovery_since']) ? intval(trim($_POST['poco_discovery_since'])) : 30); + $poco_discovery = (!empty($_POST['poco_discovery']) ? intval(trim($_POST['poco_discovery'])) : false); $poco_local_search = !empty($_POST['poco_local_search']); $nodeinfo = !empty($_POST['nodeinfo']); $dfrn_only = !empty($_POST['dfrn_only']); @@ -200,6 +196,7 @@ class Site extends BaseAdmin $itemcache = (!empty($_POST['itemcache']) ? Strings::escapeTags(trim($_POST['itemcache'])) : ''); $itemcache_duration = (!empty($_POST['itemcache_duration']) ? intval($_POST['itemcache_duration']) : 0); $max_comments = (!empty($_POST['max_comments']) ? intval($_POST['max_comments']) : 0); + $max_display_comments = (!empty($_POST['max_display_comments']) ? intval($_POST['max_display_comments']) : 0); $temppath = (!empty($_POST['temppath']) ? Strings::escapeTags(trim($_POST['temppath'])) : ''); $singleuser = (!empty($_POST['singleuser']) ? Strings::escapeTags(trim($_POST['singleuser'])) : ''); $proxy_disabled = !empty($_POST['proxy_disabled']); @@ -208,15 +205,14 @@ class Site extends BaseAdmin $check_new_version_url = (!empty($_POST['check_new_version_url']) ? Strings::escapeTags(trim($_POST['check_new_version_url'])) : 'none'); $worker_queues = (!empty($_POST['worker_queues']) ? intval($_POST['worker_queues']) : 10); - $worker_dont_fork = !empty($_POST['worker_dont_fork']); $worker_fastlane = !empty($_POST['worker_fastlane']); - $worker_frontend = !empty($_POST['worker_frontend']); $relay_directly = !empty($_POST['relay_directly']); $relay_server = (!empty($_POST['relay_server']) ? Strings::escapeTags(trim($_POST['relay_server'])) : ''); $relay_subscribe = !empty($_POST['relay_subscribe']); $relay_scope = (!empty($_POST['relay_scope']) ? Strings::escapeTags(trim($_POST['relay_scope'])) : ''); $relay_server_tags = (!empty($_POST['relay_server_tags']) ? Strings::escapeTags(trim($_POST['relay_server_tags'])) : ''); + $relay_deny_tags = (!empty($_POST['relay_deny_tags']) ? Strings::escapeTags(trim($_POST['relay_deny_tags'])) : ''); $relay_user_tags = !empty($_POST['relay_user_tags']); $active_panel = (!empty($_POST['active_panel']) ? "#" . Strings::escapeTags(trim($_POST['active_panel'])) : ''); @@ -249,8 +245,8 @@ class Site extends BaseAdmin } DI::baseUrl()->redirect('admin/site' . $active_panel); } - } else { - info(DI::l10n()->t('Invalid storage backend setting value.')); + } elseif (!empty($storagebackend)) { + notice(DI::l10n()->t('Invalid storage backend setting value.')); } // Has the directory url changed? If yes, then resubmit the existing profiles there @@ -305,13 +301,11 @@ class Site extends BaseAdmin DI::config()->set('system', 'maxloadavg' , $maxloadavg); DI::config()->set('system', 'maxloadavg_frontend' , $maxloadavg_frontend); DI::config()->set('system', 'min_memory' , $min_memory); - DI::config()->set('system', 'optimize_max_tablesize', $optimize_max_tablesize); - DI::config()->set('system', 'optimize_fragmentation', $optimize_fragmentation); - DI::config()->set('system', 'poco_completion' , $poco_completion); - DI::config()->set('system', 'gcontact_discovery' , $gcontact_discovery); + DI::config()->set('system', 'optimize_tables' , $optimize_tables); + DI::config()->set('system', 'contact_discovery' , $contact_discovery); + DI::config()->set('system', 'synchronize_directory' , $synchronize_directory); DI::config()->set('system', 'poco_requery_days' , $poco_requery_days); DI::config()->set('system', 'poco_discovery' , $poco_discovery); - DI::config()->set('system', 'poco_discovery_since' , $poco_discovery_since); DI::config()->set('system', 'poco_local_search' , $poco_local_search); DI::config()->set('system', 'nodeinfo' , $nodeinfo); DI::config()->set('config', 'sitename' , $sitename); @@ -362,6 +356,7 @@ class Site extends BaseAdmin DI::config()->set('system', 'allowed_sites' , $allowed_sites); DI::config()->set('system', 'allowed_email' , $allowed_email); DI::config()->set('system', 'forbidden_nicknames' , $forbidden_nicknames); + DI::config()->set('system', 'system_actor_name' , $system_actor_name); DI::config()->set('system', 'no_oembed_rich_content' , $no_oembed_rich_content); DI::config()->set('system', 'allowed_oembed' , $allowed_oembed); DI::config()->set('system', 'block_public' , $block_public); @@ -408,6 +403,7 @@ class Site extends BaseAdmin DI::config()->set('system', 'itemcache', $itemcache); DI::config()->set('system', 'itemcache_duration', $itemcache_duration); DI::config()->set('system', 'max_comments', $max_comments); + DI::config()->set('system', 'max_display_comments', $max_display_comments); if ($temppath != '') { $temppath = BasePath::getRealPath($temppath); @@ -419,21 +415,18 @@ class Site extends BaseAdmin DI::config()->set('system', 'only_tag_search' , $only_tag_search); DI::config()->set('system', 'worker_queues' , $worker_queues); - DI::config()->set('system', 'worker_dont_fork' , $worker_dont_fork); DI::config()->set('system', 'worker_fastlane' , $worker_fastlane); - DI::config()->set('system', 'frontend_worker' , $worker_frontend); DI::config()->set('system', 'relay_directly' , $relay_directly); DI::config()->set('system', 'relay_server' , $relay_server); DI::config()->set('system', 'relay_subscribe' , $relay_subscribe); DI::config()->set('system', 'relay_scope' , $relay_scope); DI::config()->set('system', 'relay_server_tags', $relay_server_tags); + DI::config()->set('system', 'relay_deny_tags' , $relay_deny_tags); DI::config()->set('system', 'relay_user_tags' , $relay_user_tags); DI::config()->set('system', 'rino_encrypt' , $rino); - info(DI::l10n()->t('Site settings updated.') . EOL); - DI::baseUrl()->redirect('admin/site' . $active_panel); } @@ -489,20 +482,6 @@ class Site extends BaseAdmin CP_USERS_AND_GLOBAL => DI::l10n()->t('Public postings from local users and the federated network') ]; - $poco_discovery_choices = [ - PortableContact::DISABLED => DI::l10n()->t('Disabled'), - PortableContact::USERS => DI::l10n()->t('Users'), - PortableContact::USERS_GCONTACTS => DI::l10n()->t('Users, Global Contacts'), - PortableContact::USERS_GCONTACTS_FALLBACK => DI::l10n()->t('Users, Global Contacts/fallback'), - ]; - - $poco_discovery_since_choices = [ - '30' => DI::l10n()->t('One month'), - '91' => DI::l10n()->t('Three months'), - '182' => DI::l10n()->t('Half a year'), - '365' => DI::l10n()->t('One year'), - ]; - /* get user names to make the install a personal install of X */ // @TODO Move to Model\User::getNames() $user_names = []; @@ -547,24 +526,20 @@ class Site extends BaseAdmin $check_git_version_choices = [ 'none' => DI::l10n()->t('Don\'t check'), - 'master' => DI::l10n()->t('check the stable version'), + 'stable' => DI::l10n()->t('check the stable version'), 'develop' => DI::l10n()->t('check the development version') ]; $discovery_choices = [ - GContact::DISCOVERY_NONE => DI::l10n()->t('none'), - GContact::DISCOVERY_DIRECT => DI::l10n()->t('Direct contacts'), - GContact::DISCOVERY_RECURSIVE => DI::l10n()->t('Contacts of contacts') + Contact\Relation::DISCOVERY_NONE => DI::l10n()->t('none'), + Contact\Relation::DISCOVERY_LOCAL => DI::l10n()->t('Local contacts'), + Contact\Relation::DISCOVERY_INTERACTOR => DI::l10n()->t('Interactors'), + // "All" is deactivated until we are sure not to put too much stress on the fediverse with this + // ContactRelation::DISCOVERY_ALL => DI::l10n()->t('All'), ]; $diaspora_able = (DI::baseUrl()->getUrlPath() == ''); - $optimize_max_tablesize = DI::config()->get('system', 'optimize_max_tablesize', -1); - - if ($optimize_max_tablesize <= 0) { - $optimize_max_tablesize = -1; - } - $current_storage_backend = DI::storage(); $available_storage_backends = []; @@ -603,6 +578,7 @@ class Site extends BaseAdmin return Renderer::replaceMacros($t, [ '$title' => DI::l10n()->t('Administration'), '$page' => DI::l10n()->t('Site'), + '$general_info' => DI::l10n()->t('General Information'), '$submit' => DI::l10n()->t('Save Settings'), '$republish' => DI::l10n()->t('Republish users to directory'), '$registration' => DI::l10n()->t('Registration'), @@ -620,6 +596,7 @@ class Site extends BaseAdmin // name, label, value, help string, extra data... '$sitename' => ['sitename', DI::l10n()->t('Site name'), DI::config()->get('config', 'sitename'), ''], '$sender_email' => ['sender_email', DI::l10n()->t('Sender Email'), DI::config()->get('config', 'sender_email'), DI::l10n()->t('The email address your server shall use to send notification emails from.'), '', '', 'email'], + '$system_actor_name' => ['system_actor_name', DI::l10n()->t('Name of the system actor'), User::getActorName(), DI::l10n()->t("Name of the internal system account that is used to perform ActivityPub requests. This must be an unused username. If set, this can't be changed again.")], '$banner' => ['banner', DI::l10n()->t('Banner/Logo'), $banner, ''], '$email_banner' => ['email_banner', DI::l10n()->t('Email Banner/Logo'), $email_banner, ''], '$shortcut_icon' => ['shortcut_icon', DI::l10n()->t('Shortcut icon'), DI::config()->get('system', 'shortcut_icon'), DI::l10n()->t('Link to an icon that will be used for browsers.')], @@ -650,12 +627,12 @@ class Site extends BaseAdmin '$allowed_oembed' => ['allowed_oembed', DI::l10n()->t('Allowed OEmbed domains'), DI::config()->get('system', 'allowed_oembed'), DI::l10n()->t('Comma separated list of domains which oembed content is allowed to be displayed. Wildcards are accepted.')], '$block_public' => ['block_public', DI::l10n()->t('Block public'), DI::config()->get('system', 'block_public'), DI::l10n()->t('Check to block public access to all otherwise public personal pages on this site unless you are currently logged in.')], '$force_publish' => ['publish_all', DI::l10n()->t('Force publish'), DI::config()->get('system', 'publish_all'), DI::l10n()->t('Check to force all profiles on this site to be listed in the site directory.') . '' . DI::l10n()->t('Enabling this may violate privacy laws like the GDPR') . ''], - '$global_directory' => ['directory', DI::l10n()->t('Global directory URL'), DI::config()->get('system', 'directory', 'https://dir.friendica.social'), DI::l10n()->t('URL to the global directory. If this is not set, the global directory is completely unavailable to the application.')], + '$global_directory' => ['directory', DI::l10n()->t('Global directory URL'), DI::config()->get('system', 'directory'), DI::l10n()->t('URL to the global directory. If this is not set, the global directory is completely unavailable to the application.')], '$newuser_private' => ['newuser_private', DI::l10n()->t('Private posts by default for new users'), DI::config()->get('system', 'newuser_private'), DI::l10n()->t('Set default post permissions for all new members to the default privacy group rather than public.')], '$enotify_no_content' => ['enotify_no_content', DI::l10n()->t('Don\'t include post content in email notifications'), DI::config()->get('system', 'enotify_no_content'), DI::l10n()->t('Don\'t include the content of a post/comment/private message/etc. in the email notifications that are sent out from this site, as a privacy measure.')], '$private_addons' => ['private_addons', DI::l10n()->t('Disallow public access to addons listed in the apps menu.'), DI::config()->get('config', 'private_addons'), DI::l10n()->t('Checking this box will restrict addons listed in the apps menu to members only.')], '$disable_embedded' => ['disable_embedded', DI::l10n()->t('Don\'t embed private images in posts'), DI::config()->get('system', 'disable_embedded'), DI::l10n()->t('Don\'t replace locally-hosted private photos in posts with an embedded copy of the image. This means that contacts who receive posts containing private photos will have to authenticate and load each image, which may take a while.')], - '$explicit_content' => ['explicit_content', DI::l10n()->t('Explicit Content'), DI::config()->get('system', 'explicit_content', false), DI::l10n()->t('Set this to announce that your node is used mostly for explicit content that might not be suited for minors. This information will be published in the node information and might be used, e.g. by the global directory, to filter your node from listings of nodes to join. Additionally a note about this will be shown at the user registration page.')], + '$explicit_content' => ['explicit_content', DI::l10n()->t('Explicit Content'), DI::config()->get('system', 'explicit_content'), DI::l10n()->t('Set this to announce that your node is used mostly for explicit content that might not be suited for minors. This information will be published in the node information and might be used, e.g. by the global directory, to filter your node from listings of nodes to join. Additionally a note about this will be shown at the user registration page.')], '$allow_users_remote_self'=> ['allow_users_remote_self', DI::l10n()->t('Allow Users to set remote_self'), DI::config()->get('system', 'allow_users_remote_self'), DI::l10n()->t('With checking this, every user is allowed to mark every contact as a remote_self in the repair contact dialog. Setting this flag on a contact causes mirroring every posting of that contact in the users stream.')], '$no_multi_reg' => ['no_multi_reg', DI::l10n()->t('Block multiple registrations'), DI::config()->get('system', 'block_extended_register'), DI::l10n()->t('Disallow users to register additional accounts for use as pages.')], '$no_openid' => ['no_openid', DI::l10n()->t('Disable OpenID'), DI::config()->get('system', 'no_openid'), DI::l10n()->t('Disable OpenID support for registration and logins.')], @@ -671,31 +648,35 @@ class Site extends BaseAdmin '$verifyssl' => ['verifyssl', DI::l10n()->t('Verify SSL'), DI::config()->get('system', 'verifyssl'), DI::l10n()->t('If you wish, you can turn on strict certificate checking. This will mean you cannot connect (at all) to self-signed SSL sites.')], '$proxyuser' => ['proxyuser', DI::l10n()->t('Proxy user'), DI::config()->get('system', 'proxyuser'), ''], '$proxy' => ['proxy', DI::l10n()->t('Proxy URL'), DI::config()->get('system', 'proxy'), ''], - '$timeout' => ['timeout', DI::l10n()->t('Network timeout'), DI::config()->get('system', 'curl_timeout', 60), DI::l10n()->t('Value is in seconds. Set to 0 for unlimited (not recommended).')], - '$maxloadavg' => ['maxloadavg', DI::l10n()->t('Maximum Load Average'), DI::config()->get('system', 'maxloadavg', 20), DI::l10n()->t('Maximum system load before delivery and poll processes are deferred - default %d.', 20)], - '$maxloadavg_frontend' => ['maxloadavg_frontend', DI::l10n()->t('Maximum Load Average (Frontend)'), DI::config()->get('system', 'maxloadavg_frontend', 50), DI::l10n()->t('Maximum system load before the frontend quits service - default 50.')], - '$min_memory' => ['min_memory', DI::l10n()->t('Minimal Memory'), DI::config()->get('system', 'min_memory', 0), DI::l10n()->t('Minimal free memory in MB for the worker. Needs access to /proc/meminfo - default 0 (deactivated).')], - '$optimize_max_tablesize' => ['optimize_max_tablesize', DI::l10n()->t('Maximum table size for optimization'), $optimize_max_tablesize, DI::l10n()->t('Maximum table size (in MB) for the automatic optimization. Enter -1 to disable it.')], - '$optimize_fragmentation' => ['optimize_fragmentation', DI::l10n()->t('Minimum level of fragmentation'), DI::config()->get('system', 'optimize_fragmentation', 30), DI::l10n()->t('Minimum fragmenation level to start the automatic optimization - default value is 30%.')], + '$timeout' => ['timeout', DI::l10n()->t('Network timeout'), DI::config()->get('system', 'curl_timeout'), DI::l10n()->t('Value is in seconds. Set to 0 for unlimited (not recommended).')], + '$maxloadavg' => ['maxloadavg', DI::l10n()->t('Maximum Load Average'), DI::config()->get('system', 'maxloadavg'), DI::l10n()->t('Maximum system load before delivery and poll processes are deferred - default %d.', 20)], + '$maxloadavg_frontend' => ['maxloadavg_frontend', DI::l10n()->t('Maximum Load Average (Frontend)'), DI::config()->get('system', 'maxloadavg_frontend'), DI::l10n()->t('Maximum system load before the frontend quits service - default 50.')], + '$min_memory' => ['min_memory', DI::l10n()->t('Minimal Memory'), DI::config()->get('system', 'min_memory'), DI::l10n()->t('Minimal free memory in MB for the worker. Needs access to /proc/meminfo - default 0 (deactivated).')], + '$optimize_tables' => ['optimize_tables', DI::l10n()->t('Periodically optimize tables'), DI::config()->get('system', 'optimize_tables'), DI::l10n()->t('Periodically optimize tables like the cache and the workerqueue')], + + '$contact_discovery' => ['contact_discovery', DI::l10n()->t('Discover followers/followings from contacts'), DI::config()->get('system', 'contact_discovery'), DI::l10n()->t('If enabled, contacts are checked for their followers and following contacts.') . '
      ' . + '
    • ' . DI::l10n()->t('None - deactivated') . '
    • ' . + '
    • ' . DI::l10n()->t('Local contacts - contacts of our local contacts are discovered for their followers/followings.') . '
    • ' . + '
    • ' . DI::l10n()->t('Interactors - contacts of our local contacts and contacts who interacted on locally visible postings are discovered for their followers/followings.') . '
    ', + $discovery_choices], + '$synchronize_directory' => ['synchronize_directory', DI::l10n()->t('Synchronize the contacts with the directory server'), DI::config()->get('system', 'synchronize_directory'), DI::l10n()->t('if enabled, the system will check periodically for new contacts on the defined directory server.')], - '$poco_completion' => ['poco_completion', DI::l10n()->t('Periodical check of global contacts'), DI::config()->get('system', 'poco_completion'), DI::l10n()->t('If enabled, the global contacts are checked periodically for missing or outdated data and the vitality of the contacts and servers.')], - '$gcontact_discovery' => ['gcontact_discovery', DI::l10n()->t('Discover followers/followings from global contacts'), DI::config()->get('system', 'gcontact_discovery'), DI::l10n()->t('If enabled, the global contacts are checked for new contacts among their followers and following contacts. This option will create huge masses of jobs, so it should only be activated on powerful machines.'), $discovery_choices], '$poco_requery_days' => ['poco_requery_days', DI::l10n()->t('Days between requery'), DI::config()->get('system', 'poco_requery_days'), DI::l10n()->t('Number of days after which a server is requeried for his contacts.')], - '$poco_discovery' => ['poco_discovery', DI::l10n()->t('Discover contacts from other servers'), DI::config()->get('system', 'poco_discovery'), DI::l10n()->t('Periodically query other servers for contacts. You can choose between "Users": the users on the remote system, "Global Contacts": active contacts that are known on the system. The fallback is meant for Redmatrix servers and older friendica servers, where global contacts weren\'t available. The fallback increases the server load, so the recommended setting is "Users, Global Contacts".'), $poco_discovery_choices], - '$poco_discovery_since' => ['poco_discovery_since', DI::l10n()->t('Timeframe for fetching global contacts'), DI::config()->get('system', 'poco_discovery_since'), DI::l10n()->t('When the discovery is activated, this value defines the timeframe for the activity of the global contacts that are fetched from other servers.'), $poco_discovery_since_choices], + '$poco_discovery' => ['poco_discovery', DI::l10n()->t('Discover contacts from other servers'), DI::config()->get('system', 'poco_discovery'), DI::l10n()->t('Periodically query other servers for contacts. The system queries Friendica, Mastodon and Hubzilla servers.')], '$poco_local_search' => ['poco_local_search', DI::l10n()->t('Search the local directory'), DI::config()->get('system', 'poco_local_search'), DI::l10n()->t('Search the local directory instead of the global directory. When searching locally, every search will be executed on the global directory in the background. This improves the search results when the search is repeated.')], '$nodeinfo' => ['nodeinfo', DI::l10n()->t('Publish server information'), DI::config()->get('system', 'nodeinfo'), DI::l10n()->t('If enabled, general server and usage data will be published. The data contains the name and version of the server, number of users with public profiles, number of posts and the activated protocols and connectors. See the-federation.info for details.')], '$check_new_version_url' => ['check_new_version_url', DI::l10n()->t('Check upstream version'), DI::config()->get('system', 'check_new_version_url'), DI::l10n()->t('Enables checking for new Friendica versions at github. If there is a new version, you will be informed in the admin panel overview.'), $check_git_version_choices], '$suppress_tags' => ['suppress_tags', DI::l10n()->t('Suppress Tags'), DI::config()->get('system', 'suppress_tags'), DI::l10n()->t('Suppress showing a list of hashtags at the end of the posting.')], - '$dbclean' => ['dbclean', DI::l10n()->t('Clean database'), DI::config()->get('system', 'dbclean', false), DI::l10n()->t('Remove old remote items, orphaned database records and old content from some other helper tables.')], - '$dbclean_expire_days' => ['dbclean_expire_days', DI::l10n()->t('Lifespan of remote items'), DI::config()->get('system', 'dbclean-expire-days', 0), DI::l10n()->t('When the database cleanup is enabled, this defines the days after which remote items will be deleted. Own items, and marked or filed items are always kept. 0 disables this behaviour.')], - '$dbclean_unclaimed' => ['dbclean_unclaimed', DI::l10n()->t('Lifespan of unclaimed items'), DI::config()->get('system', 'dbclean-expire-unclaimed', 90), DI::l10n()->t('When the database cleanup is enabled, this defines the days after which unclaimed remote items (mostly content from the relay) will be deleted. Default value is 90 days. Defaults to the general lifespan value of remote items if set to 0.')], - '$dbclean_expire_conv' => ['dbclean_expire_conv', DI::l10n()->t('Lifespan of raw conversation data'), DI::config()->get('system', 'dbclean_expire_conversation', 90), DI::l10n()->t('The conversation data is used for ActivityPub and OStatus, as well as for debug purposes. It should be safe to remove it after 14 days, default is 90 days.')], + '$dbclean' => ['dbclean', DI::l10n()->t('Clean database'), DI::config()->get('system', 'dbclean'), DI::l10n()->t('Remove old remote items, orphaned database records and old content from some other helper tables.')], + '$dbclean_expire_days' => ['dbclean_expire_days', DI::l10n()->t('Lifespan of remote items'), DI::config()->get('system', 'dbclean-expire-days'), DI::l10n()->t('When the database cleanup is enabled, this defines the days after which remote items will be deleted. Own items, and marked or filed items are always kept. 0 disables this behaviour.')], + '$dbclean_unclaimed' => ['dbclean_unclaimed', DI::l10n()->t('Lifespan of unclaimed items'), DI::config()->get('system', 'dbclean-expire-unclaimed'), DI::l10n()->t('When the database cleanup is enabled, this defines the days after which unclaimed remote items (mostly content from the relay) will be deleted. Default value is 90 days. Defaults to the general lifespan value of remote items if set to 0.')], + '$dbclean_expire_conv' => ['dbclean_expire_conv', DI::l10n()->t('Lifespan of raw conversation data'), DI::config()->get('system', 'dbclean_expire_conversation'), DI::l10n()->t('The conversation data is used for ActivityPub and OStatus, as well as for debug purposes. It should be safe to remove it after 14 days, default is 90 days.')], '$itemcache' => ['itemcache', DI::l10n()->t('Path to item cache'), DI::config()->get('system', 'itemcache'), DI::l10n()->t('The item caches buffers generated bbcode and external images.')], '$itemcache_duration' => ['itemcache_duration', DI::l10n()->t('Cache duration in seconds'), DI::config()->get('system', 'itemcache_duration'), DI::l10n()->t('How long should the cache files be hold? Default value is 86400 seconds (One day). To disable the item cache, set the value to -1.')], '$max_comments' => ['max_comments', DI::l10n()->t('Maximum numbers of comments per post'), DI::config()->get('system', 'max_comments'), DI::l10n()->t('How much comments should be shown for each post? Default value is 100.')], + '$max_display_comments' => ['max_display_comments', DI::l10n()->t('Maximum numbers of comments per post on the display page'), DI::config()->get('system', 'max_display_comments'), DI::l10n()->t('How many comments should be shown on the single view for each post? Default value is 1000.')], '$temppath' => ['temppath', DI::l10n()->t('Temp path'), DI::config()->get('system', 'temppath'), DI::l10n()->t('If you have a restricted system where the webserver can\'t access the system temp path, enter another path here.')], '$proxy_disabled' => ['proxy_disabled', DI::l10n()->t('Disable picture proxy'), DI::config()->get('system', 'proxy_disabled'), DI::l10n()->t('The picture proxy increases performance and privacy. It shouldn\'t be used on systems with very low bandwidth.')], '$only_tag_search' => ['only_tag_search', DI::l10n()->t('Only search in tags'), DI::config()->get('system', 'only_tag_search'), DI::l10n()->t('On large systems the text search can slow down the system extremely.')], @@ -705,18 +686,17 @@ class Site extends BaseAdmin '$rino' => ['rino', DI::l10n()->t('RINO Encryption'), intval(DI::config()->get('system', 'rino_encrypt')), DI::l10n()->t('Encryption layer between nodes.'), [0 => DI::l10n()->t('Disabled'), 1 => DI::l10n()->t('Enabled')]], '$worker_queues' => ['worker_queues', DI::l10n()->t('Maximum number of parallel workers'), DI::config()->get('system', 'worker_queues'), DI::l10n()->t('On shared hosters set this to %d. On larger systems, values of %d are great. Default value is %d.', 5, 20, 10)], - '$worker_dont_fork' => ['worker_dont_fork', DI::l10n()->t('Don\'t use "proc_open" with the worker'), DI::config()->get('system', 'worker_dont_fork'), DI::l10n()->t('Enable this if your system doesn\'t allow the use of "proc_open". This can happen on shared hosters. If this is enabled you should increase the frequency of worker calls in your crontab.')], '$worker_fastlane' => ['worker_fastlane', DI::l10n()->t('Enable fastlane'), DI::config()->get('system', 'worker_fastlane'), DI::l10n()->t('When enabed, the fastlane mechanism starts an additional worker if processes with higher priority are blocked by processes of lower priority.')], - '$worker_frontend' => ['worker_frontend', DI::l10n()->t('Enable frontend worker'), DI::config()->get('system', 'frontend_worker'), DI::l10n()->t('When enabled the Worker process is triggered when backend access is performed (e.g. messages being delivered). On smaller sites you might want to call %s/worker on a regular basis via an external cron job. You should only enable this option if you cannot utilize cron/scheduled jobs on your server.', DI::baseUrl()->get())], - '$relay_subscribe' => ['relay_subscribe', DI::l10n()->t('Subscribe to relay'), DI::config()->get('system', 'relay_subscribe'), DI::l10n()->t('Enables the receiving of public posts from the relay. They will be included in the search, subscribed tags and on the global community page.')], - '$relay_server' => ['relay_server', DI::l10n()->t('Relay server'), DI::config()->get('system', 'relay_server', 'https://relay.diasp.org'), DI::l10n()->t('Address of the relay server where public posts should be send to. For example https://relay.diasp.org')], + '$relay_subscribe' => ['relay_subscribe', DI::l10n()->t('Use relay servers'), DI::config()->get('system', 'relay_subscribe'), DI::l10n()->t('Enables the receiving of public posts from relay servers. They will be included in the search, subscribed tags and on the global community page.')], + '$relay_server' => ['relay_server', DI::l10n()->t('"Social Relay" server'), DI::config()->get('system', 'relay_server'), DI::l10n()->t('Address of the "Social Relay" server where public posts should be send to. For example %s. ActivityRelay servers are administrated via the "console relay" command line command.', 'https://social-relay.isurf.ca')], '$relay_directly' => ['relay_directly', DI::l10n()->t('Direct relay transfer'), DI::config()->get('system', 'relay_directly'), DI::l10n()->t('Enables the direct transfer to other servers without using the relay servers')], '$relay_scope' => ['relay_scope', DI::l10n()->t('Relay scope'), DI::config()->get('system', 'relay_scope'), DI::l10n()->t('Can be "all" or "tags". "all" means that every public post should be received. "tags" means that only posts with selected tags should be received.'), ['' => DI::l10n()->t('Disabled'), 'all' => DI::l10n()->t('all'), 'tags' => DI::l10n()->t('tags')]], '$relay_server_tags' => ['relay_server_tags', DI::l10n()->t('Server tags'), DI::config()->get('system', 'relay_server_tags'), DI::l10n()->t('Comma separated list of tags for the "tags" subscription.')], - '$relay_user_tags' => ['relay_user_tags', DI::l10n()->t('Allow user tags'), DI::config()->get('system', 'relay_user_tags', true), DI::l10n()->t('If enabled, the tags from the saved searches will used for the "tags" subscription in addition to the "relay_server_tags".')], + '$relay_deny_tags' => ['relay_deny_tags', DI::l10n()->t('Deny Server tags'), DI::config()->get('system', 'relay_deny_tags'), DI::l10n()->t('Comma separated list of tags that are rejected.')], + '$relay_user_tags' => ['relay_user_tags', DI::l10n()->t('Allow user tags'), DI::config()->get('system', 'relay_user_tags'), DI::l10n()->t('If enabled, the tags from the saved searches will used for the "tags" subscription in addition to the "relay_server_tags".')], - '$form_security_token' => parent::getFormSecurityToken('admin_site'), + '$form_security_token' => self::getFormSecurityToken('admin_site'), '$relocate_button' => DI::l10n()->t('Start Relocation'), ]); } diff --git a/src/Module/Admin/Summary.php b/src/Module/Admin/Summary.php index 4aaeaaec00..a130c48393 100644 --- a/src/Module/Admin/Summary.php +++ b/src/Module/Admin/Summary.php @@ -34,7 +34,6 @@ use Friendica\Module\BaseAdmin; use Friendica\Network\HTTPException\InternalServerErrorException; use Friendica\Util\ConfigFileLoader; use Friendica\Util\DateTimeFormat; -use Friendica\Util\Network; class Summary extends BaseAdmin { @@ -46,6 +45,14 @@ class Summary extends BaseAdmin // are there MyISAM tables in the DB? If so, trigger a warning message $warningtext = []; + + $templateEngine = Renderer::getTemplateEngine(); + $errors = []; + $templateEngine->testInstall($errors); + foreach ($errors as $error) { + $warningtext[] = DI::l10n()->t('Template engine (%s) error: %s', $templateEngine::$name, $error); + } + if (DBA::count(['information_schema' => 'tables'], ['engine' => 'myisam', 'table_schema' => DBA::databaseName()])) { $warningtext[] = DI::l10n()->t('Your DB still runs with MyISAM tables. You should change the engine type to InnoDB. As Friendica will use InnoDB only features in the future, you should change this! See here for a guide that may be helpful converting the table engines. You may also use the command php bin/console.php dbstructure toinnodb of your Friendica installation for an automatic conversion.
    ', 'https://dev.mysql.com/doc/refman/5.7/en/converting-tables-to-innodb.html'); } @@ -65,8 +72,8 @@ class Summary extends BaseAdmin } } - // Check if github.com/friendica/master/VERSION is higher then - // the local version of Friendica. Check is opt-in, source may be master or devel branch + // Check if github.com/friendica/stable/VERSION is higher then + // the local version of Friendica. Check is opt-in, source may be stable or develop branch if (DI::config()->get('system', 'check_new_version_url', 'none') != 'none') { $gitversion = DI::config()->get('system', 'git_friendica_version'); if (version_compare(FRIENDICA_VERSION, $gitversion) < 0) { @@ -136,7 +143,6 @@ class Summary extends BaseAdmin throw new InternalServerErrorException('Stream is null.'); } } - } catch (\Throwable $exception) { $warningtext[] = DI::l10n()->t('The debug logfile \'%s\' is not usable. No logging possible (error: \'%s\')', $file, $exception->getMessage()); } @@ -193,7 +199,7 @@ class Summary extends BaseAdmin } DBA::close($pageFlagsCountStmt); - Logger::log('accounts: ' . print_r($accounts, true), Logger::DATA); + Logger::debug('accounts', ['accounts' => $accounts]); $pending = Register::getPendingCount(); @@ -240,7 +246,7 @@ class Summary extends BaseAdmin private static function checkSelfHostMeta() { // Fetch the host-meta to check if this really is a vital server - return Network::curl(DI::baseUrl()->get() . '/.well-known/host-meta')->isSuccess(); + return DI::httpRequest()->get(DI::baseUrl()->get() . '/.well-known/host-meta')->isSuccess(); } } diff --git a/src/Module/Admin/Themes/Details.php b/src/Module/Admin/Themes/Details.php index c8d0578382..5c00c8d9d3 100644 --- a/src/Module/Admin/Themes/Details.php +++ b/src/Module/Admin/Themes/Details.php @@ -30,116 +30,80 @@ use Friendica\Util\Strings; class Details extends BaseAdmin { - public static function post(array $parameters = []) - { - parent::post($parameters); - - $a = DI::app(); - - if ($a->argc > 2) { - // @TODO: Replace with parameter from router - $theme = $a->argv[2]; - $theme = Strings::sanitizeFilePathItem($theme); - if (is_file("view/theme/$theme/config.php")) { - require_once "view/theme/$theme/config.php"; - - if (function_exists('theme_admin_post')) { - theme_admin_post($a); - } - } - - info(DI::l10n()->t('Theme settings updated.')); - - if (DI::mode()->isAjax()) { - return; - } - - DI::baseUrl()->redirect('admin/themes/' . $theme); - } - } - public static function content(array $parameters = []) { parent::content($parameters); - $a = DI::app(); - - if ($a->argc > 2) { - // @TODO: Replace with parameter from router - $theme = $a->argv[2]; - $theme = Strings::sanitizeFilePathItem($theme); - if (!is_dir("view/theme/$theme")) { - notice(DI::l10n()->t("Item not found.")); - return ''; - } - - $isEnabled = in_array($theme, Theme::getAllowedList()); - if ($isEnabled) { - $status = "on"; - $action = DI::l10n()->t("Disable"); - } else { - $status = "off"; - $action = DI::l10n()->t("Enable"); - } - - if (!empty($_GET['action']) && $_GET['action'] == 'toggle') { - parent::checkFormSecurityTokenRedirectOnError('/admin/themes', 'admin_themes', 't'); - - if ($isEnabled) { - Theme::uninstall($theme); - info(DI::l10n()->t('Theme %s disabled.', $theme)); - } elseif (Theme::install($theme)) { - info(DI::l10n()->t('Theme %s successfully enabled.', $theme)); - } else { - info(DI::l10n()->t('Theme %s failed to install.', $theme)); - } - - DI::baseUrl()->redirect('admin/themes/' . $theme); - } - - $readme = null; - if (is_file("view/theme/$theme/README.md")) { - $readme = Markdown::convert(file_get_contents("view/theme/$theme/README.md"), false); - } elseif (is_file("view/theme/$theme/README")) { - $readme = "
    " . file_get_contents("view/theme/$theme/README") . "
    "; - } - - $admin_form = ''; - if (is_file("view/theme/$theme/config.php")) { - require_once "view/theme/$theme/config.php"; - - if (function_exists('theme_admin')) { - $admin_form = ''; - } - } - - $screenshot = [Theme::getScreenshot($theme), DI::l10n()->t('Screenshot')]; - if (!stristr($screenshot[0], $theme)) { - $screenshot = null; - } - - $t = Renderer::getMarkupTemplate('admin/addons/details.tpl'); - return Renderer::replaceMacros($t, [ - '$title' => DI::l10n()->t('Administration'), - '$page' => DI::l10n()->t('Themes'), - '$toggle' => DI::l10n()->t('Toggle'), - '$settings' => DI::l10n()->t('Settings'), - '$baseurl' => DI::baseUrl()->get(true), - '$addon' => $theme, - '$status' => $status, - '$action' => $action, - '$info' => Theme::getInfo($theme), - '$function' => 'themes', - '$admin_form' => $admin_form, - '$str_author' => DI::l10n()->t('Author: '), - '$str_maintainer' => DI::l10n()->t('Maintainer: '), - '$screenshot' => $screenshot, - '$readme' => $readme, - - '$form_security_token' => parent::getFormSecurityToken("admin_themes"), - ]); + $theme = Strings::sanitizeFilePathItem($parameters['theme']); + if (!is_dir("view/theme/$theme")) { + notice(DI::l10n()->t("Item not found.")); + return ''; } - DI::baseUrl()->redirect('admin/themes'); + $isEnabled = in_array($theme, Theme::getAllowedList()); + if ($isEnabled) { + $status = "on"; + $action = DI::l10n()->t("Disable"); + } else { + $status = "off"; + $action = DI::l10n()->t("Enable"); + } + + if (!empty($_GET['action']) && $_GET['action'] == 'toggle') { + self::checkFormSecurityTokenRedirectOnError('/admin/themes', 'admin_themes', 't'); + + if ($isEnabled) { + Theme::uninstall($theme); + info(DI::l10n()->t('Theme %s disabled.', $theme)); + } elseif (Theme::install($theme)) { + info(DI::l10n()->t('Theme %s successfully enabled.', $theme)); + } else { + notice(DI::l10n()->t('Theme %s failed to install.', $theme)); + } + + DI::baseUrl()->redirect('admin/themes/' . $theme); + } + + $readme = null; + if (is_file("view/theme/$theme/README.md")) { + $readme = Markdown::convert(file_get_contents("view/theme/$theme/README.md"), false); + } elseif (is_file("view/theme/$theme/README")) { + $readme = "
    " . file_get_contents("view/theme/$theme/README") . "
    "; + } + + $admin_form = ''; + if (is_file("view/theme/$theme/config.php")) { + require_once "view/theme/$theme/config.php"; + + if (function_exists('theme_admin')) { + $admin_form = ''; + } + } + + $screenshot = [Theme::getScreenshot($theme), DI::l10n()->t('Screenshot')]; + if (!stristr($screenshot[0], $theme)) { + $screenshot = null; + } + + $t = Renderer::getMarkupTemplate('admin/addons/details.tpl'); + return Renderer::replaceMacros($t, [ + '$title' => DI::l10n()->t('Administration'), + '$page' => DI::l10n()->t('Themes'), + '$toggle' => DI::l10n()->t('Toggle'), + '$settings' => DI::l10n()->t('Settings'), + '$baseurl' => DI::baseUrl()->get(true), + '$addon' => $theme, + '$status' => $status, + '$action' => $action, + '$info' => Theme::getInfo($theme), + '$function' => 'themes', + '$admin_form' => $admin_form, + '$str_author' => DI::l10n()->t('Author: '), + '$str_maintainer' => DI::l10n()->t('Maintainer: '), + '$screenshot' => $screenshot, + '$readme' => $readme, + + '$form_security_token' => self::getFormSecurityToken("admin_themes"), + ]); } } diff --git a/src/Module/Admin/Themes/Embed.php b/src/Module/Admin/Themes/Embed.php index 675e33c846..a308b43cb5 100644 --- a/src/Module/Admin/Themes/Embed.php +++ b/src/Module/Admin/Themes/Embed.php @@ -30,83 +30,59 @@ class Embed extends BaseAdmin { public static function init(array $parameters = []) { - $a = DI::app(); - - if ($a->argc > 2) { - // @TODO: Replace with parameter from router - $theme = $a->argv[2]; - $theme = Strings::sanitizeFilePathItem($theme); - if (is_file("view/theme/$theme/config.php")) { - $a->setCurrentTheme($theme); - } + $theme = Strings::sanitizeFilePathItem($parameters['theme']); + if (is_file("view/theme/$theme/config.php")) { + DI::app()->setCurrentTheme($theme); } } public static function post(array $parameters = []) { - parent::post($parameters); + self::checkAdminAccess(); - $a = DI::app(); - - if ($a->argc > 2) { - // @TODO: Replace with parameter from router - $theme = $a->argv[2]; - $theme = Strings::sanitizeFilePathItem($theme); - if (is_file("view/theme/$theme/config.php")) { + $theme = Strings::sanitizeFilePathItem($parameters['theme']); + if (is_file("view/theme/$theme/config.php")) { + require_once "view/theme/$theme/config.php"; + if (function_exists('theme_admin_post')) { self::checkFormSecurityTokenRedirectOnError('/admin/themes/' . $theme . '/embed?mode=minimal', 'admin_theme_settings'); - - require_once "view/theme/$theme/config.php"; - - if (function_exists('theme_admin_post')) { - theme_admin_post($a); - } + theme_admin_post(DI::app()); } - - info(DI::l10n()->t('Theme settings updated.')); - - if (DI::mode()->isAjax()) { - return; - } - - DI::baseUrl()->redirect('admin/themes/' . $theme . '/embed?mode=minimal'); } + + if (DI::mode()->isAjax()) { + return; + } + + DI::baseUrl()->redirect('admin/themes/' . $theme . '/embed?mode=minimal'); } public static function content(array $parameters = []) { parent::content($parameters); - $a = DI::app(); - - if ($a->argc > 2) { - // @TODO: Replace with parameter from router - $theme = $a->argv[2]; - $theme = Strings::sanitizeFilePathItem($theme); - if (!is_dir("view/theme/$theme")) { - notice(DI::l10n()->t('Unknown theme.')); - return ''; - } - - $admin_form = ''; - if (is_file("view/theme/$theme/config.php")) { - require_once "view/theme/$theme/config.php"; - - if (function_exists('theme_admin')) { - $admin_form = theme_admin($a); - } - } - - // Overrides normal theme style include to strip user param to show embedded theme settings - Renderer::$theme['stylesheet'] = 'view/theme/' . $theme . '/style.pcss'; - - $t = Renderer::getMarkupTemplate('admin/addons/embed.tpl'); - return Renderer::replaceMacros($t, [ - '$action' => '/admin/themes/' . $theme . '/embed?mode=minimal', - '$form' => $admin_form, - '$form_security_token' => parent::getFormSecurityToken("admin_theme_settings"), - ]); + $theme = Strings::sanitizeFilePathItem($parameters['theme']); + if (!is_dir("view/theme/$theme")) { + notice(DI::l10n()->t('Unknown theme.')); + return ''; } - return ''; + $admin_form = ''; + if (is_file("view/theme/$theme/config.php")) { + require_once "view/theme/$theme/config.php"; + + if (function_exists('theme_admin')) { + $admin_form = theme_admin(DI::app()); + } + } + + // Overrides normal theme style include to strip user param to show embedded theme settings + Renderer::$theme['stylesheet'] = 'view/theme/' . $theme . '/style.pcss'; + + $t = Renderer::getMarkupTemplate('admin/addons/embed.tpl'); + return Renderer::replaceMacros($t, [ + '$action' => '/admin/themes/' . $theme . '/embed?mode=minimal', + '$form' => $admin_form, + '$form_security_token' => self::getFormSecurityToken("admin_theme_settings"), + ]); } } diff --git a/src/Module/Admin/Themes/Index.php b/src/Module/Admin/Themes/Index.php index 955ddadc70..e703a87c44 100644 --- a/src/Module/Admin/Themes/Index.php +++ b/src/Module/Admin/Themes/Index.php @@ -37,7 +37,7 @@ class Index extends BaseAdmin // reload active themes if (!empty($_GET['action'])) { - parent::checkFormSecurityTokenRedirectOnError(DI::baseUrl()->get() . '/admin/themes', 'admin_themes', 't'); + self::checkFormSecurityTokenRedirectOnError(DI::baseUrl()->get() . '/admin/themes', 'admin_themes', 't'); switch ($_GET['action']) { case 'reload': @@ -48,7 +48,7 @@ class Index extends BaseAdmin } Theme::setAllowedList($allowed_themes); - info('Themes reloaded'); + info(DI::l10n()->t('Themes reloaded')); break; case 'toggle' : @@ -66,7 +66,7 @@ class Index extends BaseAdmin } elseif (Theme::install($theme)) { info(DI::l10n()->t('Theme %s successfully enabled.', $theme)); } else { - info(DI::l10n()->t('Theme %s failed to install.', $theme)); + notice(DI::l10n()->t('Theme %s failed to install.', $theme)); } } @@ -119,7 +119,7 @@ class Index extends BaseAdmin '$noplugshint' => DI::l10n()->t('No themes found on the system. They should be placed in %1$s', '/view/themes'), '$experimental' => DI::l10n()->t('[Experimental]'), '$unsupported' => DI::l10n()->t('[Unsupported]'), - '$form_security_token' => parent::getFormSecurityToken('admin_themes'), + '$form_security_token' => self::getFormSecurityToken('admin_themes'), ]); } } diff --git a/src/Module/Admin/Tos.php b/src/Module/Admin/Tos.php index 811a0eb25c..5ad3a72ddc 100644 --- a/src/Module/Admin/Tos.php +++ b/src/Module/Admin/Tos.php @@ -29,14 +29,14 @@ class Tos extends BaseAdmin { public static function post(array $parameters = []) { - parent::post($parameters); - - parent::checkFormSecurityTokenRedirectOnError('/admin/tos', 'admin_tos'); + self::checkAdminAccess(); if (empty($_POST['page_tos'])) { return; } + self::checkFormSecurityTokenRedirectOnError('/admin/tos', 'admin_tos'); + $displaytos = !empty($_POST['displaytos']); $displayprivstatement = !empty($_POST['displayprivstatement']); $tostext = (!empty($_POST['tostext']) ? strip_tags(trim($_POST['tostext'])) : ''); @@ -45,8 +45,6 @@ class Tos extends BaseAdmin DI::config()->set('system', 'tosprivstatement', $displayprivstatement); DI::config()->set('system', 'tostext', $tostext); - info(DI::l10n()->t('The Terms of Service settings have been updated.')); - DI::baseUrl()->redirect('admin/tos'); } @@ -64,7 +62,7 @@ class Tos extends BaseAdmin '$preview' => DI::l10n()->t('Privacy Statement Preview'), '$privtext' => $tos->privacy_complete, '$tostext' => ['tostext', DI::l10n()->t('The Terms of Service'), DI::config()->get('system', 'tostext'), DI::l10n()->t('Enter the Terms of Service for your node here. You can use BBCode. Headers of sections should be [h2] and below.')], - '$form_security_token' => parent::getFormSecurityToken('admin_tos'), + '$form_security_token' => self::getFormSecurityToken('admin_tos'), '$submit' => DI::l10n()->t('Save Settings'), ]); } diff --git a/src/Module/Admin/Users.php b/src/Module/Admin/Users.php deleted file mode 100644 index dca8c9c2e6..0000000000 --- a/src/Module/Admin/Users.php +++ /dev/null @@ -1,287 +0,0 @@ -. - * - */ - -namespace Friendica\Module\Admin; - -use Friendica\Content\Pager; -use Friendica\Core\Renderer; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Model\Register; -use Friendica\Model\User; -use Friendica\Module\BaseAdmin; -use Friendica\Util\Temporal; - -class Users extends BaseAdmin -{ - public static function post(array $parameters = []) - { - parent::post($parameters); - - $pending = $_POST['pending'] ?? []; - $users = $_POST['user'] ?? []; - $nu_name = $_POST['new_user_name'] ?? ''; - $nu_nickname = $_POST['new_user_nickname'] ?? ''; - $nu_email = $_POST['new_user_email'] ?? ''; - $nu_language = DI::config()->get('system', 'language'); - - parent::checkFormSecurityTokenRedirectOnError('/admin/users', 'admin_users'); - - if ($nu_name !== '' && $nu_email !== '' && $nu_nickname !== '') { - try { - User::createMinimal($nu_name, $nu_email, $nu_nickname, $nu_language); - } catch (\Exception $ex) { - notice($ex->getMessage()); - return; - } - } - - if (!empty($_POST['page_users_block'])) { - foreach ($users as $uid) { - User::block($uid); - } - notice(DI::l10n()->tt('%s user blocked', '%s users blocked', count($users))); - } - - if (!empty($_POST['page_users_unblock'])) { - foreach ($users as $uid) { - User::block($uid, false); - } - notice(DI::l10n()->tt('%s user unblocked', '%s users unblocked', count($users))); - } - - if (!empty($_POST['page_users_delete'])) { - foreach ($users as $uid) { - if (local_user() != $uid) { - User::remove($uid); - } else { - notice(DI::l10n()->t('You can\'t remove yourself')); - } - } - - notice(DI::l10n()->tt('%s user deleted', '%s users deleted', count($users))); - } - - if (!empty($_POST['page_users_approve'])) { - foreach ($pending as $hash) { - User::allow($hash); - } - notice(DI::l10n()->tt('%s user approved', '%s users approved', count($pending))); - } - - if (!empty($_POST['page_users_deny'])) { - foreach ($pending as $hash) { - User::deny($hash); - } - notice(DI::l10n()->tt('%s registration revoked', '%s registrations revoked', count($pending))); - } - - DI::baseUrl()->redirect('admin/users'); - } - - public static function content(array $parameters = []) - { - parent::content($parameters); - - $a = DI::app(); - - if ($a->argc > 3) { - // @TODO: Replace with parameter from router - $action = $a->argv[2]; - $uid = $a->argv[3]; - $user = User::getById($uid, ['username', 'blocked']); - if (!DBA::isResult($user)) { - notice('User not found' . EOL); - DI::baseUrl()->redirect('admin/users'); - return ''; // NOTREACHED - } - - switch ($action) { - case 'delete': - if (local_user() != $uid) { - parent::checkFormSecurityTokenRedirectOnError('/admin/users', 'admin_users', 't'); - // delete user - User::remove($uid); - - notice(DI::l10n()->t('User "%s" deleted', $user['username'])); - } else { - notice(DI::l10n()->t('You can\'t remove yourself')); - } - break; - case 'block': - parent::checkFormSecurityTokenRedirectOnError('/admin/users', 'admin_users', 't'); - User::block($uid); - notice(DI::l10n()->t('User "%s" blocked', $user['username'])); - break; - case 'unblock': - parent::checkFormSecurityTokenRedirectOnError('/admin/users', 'admin_users', 't'); - User::block($uid, false); - notice(DI::l10n()->t('User "%s" unblocked', $user['username'])); - break; - case 'allow': - parent::checkFormSecurityTokenRedirectOnError('/admin/users', 'admin_users', 't'); - User::allow(Register::getPendingForUser($uid)['hash'] ?? ''); - notice(DI::l10n()->t('Account approved.')); - break; - case 'deny': - parent::checkFormSecurityTokenRedirectOnError('/admin/users', 'admin_users', 't'); - User::deny(Register::getPendingForUser($uid)['hash'] ?? ''); - notice(DI::l10n()->t('Registration revoked')); - break; - } - - DI::baseUrl()->redirect('admin/users'); - } - - /* get pending */ - $pending = Register::getPending(); - - $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), 100); - - $valid_orders = [ - 'name', - 'email', - 'register_date', - 'login_date', - 'last-item', - 'page-flags' - ]; - - $order = 'name'; - $order_direction = '+'; - if (!empty($_GET['o'])) { - $new_order = $_GET['o']; - if ($new_order[0] === '-') { - $order_direction = '-'; - $new_order = substr($new_order, 1); - } - - if (in_array($new_order, $valid_orders)) { - $order = $new_order; - } - } - - $users = User::getList($pager->getStart(), $pager->getItemsPerPage(), 'all', $order, ($order_direction == '-')); - - $adminlist = explode(',', str_replace(' ', '', DI::config()->get('config', 'admin_email'))); - $_setup_users = function ($e) use ($adminlist) { - $page_types = [ - User::PAGE_FLAGS_NORMAL => DI::l10n()->t('Normal Account Page'), - User::PAGE_FLAGS_SOAPBOX => DI::l10n()->t('Soapbox Page'), - User::PAGE_FLAGS_COMMUNITY => DI::l10n()->t('Public Forum'), - User::PAGE_FLAGS_FREELOVE => DI::l10n()->t('Automatic Friend Page'), - User::PAGE_FLAGS_PRVGROUP => DI::l10n()->t('Private Forum') - ]; - $account_types = [ - User::ACCOUNT_TYPE_PERSON => DI::l10n()->t('Personal Page'), - User::ACCOUNT_TYPE_ORGANISATION => DI::l10n()->t('Organisation Page'), - User::ACCOUNT_TYPE_NEWS => DI::l10n()->t('News Page'), - User::ACCOUNT_TYPE_COMMUNITY => DI::l10n()->t('Community Forum'), - User::ACCOUNT_TYPE_RELAY => DI::l10n()->t('Relay'), - ]; - - $e['page_flags_raw'] = $e['page-flags']; - $e['page-flags'] = $page_types[$e['page-flags']]; - - $e['account_type_raw'] = ($e['page_flags_raw'] == 0) ? $e['account-type'] : -1; - $e['account-type'] = ($e['page_flags_raw'] == 0) ? $account_types[$e['account-type']] : ''; - - $e['register_date'] = Temporal::getRelativeDate($e['register_date']); - $e['login_date'] = Temporal::getRelativeDate($e['login_date']); - $e['lastitem_date'] = Temporal::getRelativeDate($e['last-item']); - $e['is_admin'] = in_array($e['email'], $adminlist); - $e['is_deletable'] = (intval($e['uid']) != local_user()); - $e['deleted'] = ($e['account_removed'] ? Temporal::getRelativeDate($e['account_expires_on']) : False); - - return $e; - }; - - $tmp_users = array_map($_setup_users, $users); - - // Get rid of dashes in key names, Smarty3 can't handle them - // and extracting deleted users - - $deleted = []; - $users = []; - foreach ($tmp_users as $user) { - foreach ($user as $k => $v) { - $newkey = str_replace('-', '_', $k); - $user[$newkey] = $v; - } - - if ($user['deleted']) { - $deleted[] = $user; - } else { - $users[] = $user; - } - } - - $th_users = array_map(null, [DI::l10n()->t('Name'), DI::l10n()->t('Email'), DI::l10n()->t('Register date'), DI::l10n()->t('Last login'), DI::l10n()->t('Last public item'), DI::l10n()->t('Type')], $valid_orders); - - $t = Renderer::getMarkupTemplate('admin/users.tpl'); - $o = Renderer::replaceMacros($t, [ - // strings // - '$title' => DI::l10n()->t('Administration'), - '$page' => DI::l10n()->t('Users'), - '$submit' => DI::l10n()->t('Add User'), - '$select_all' => DI::l10n()->t('select all'), - '$h_pending' => DI::l10n()->t('User registrations waiting for confirm'), - '$h_deleted' => DI::l10n()->t('User waiting for permanent deletion'), - '$th_pending' => [DI::l10n()->t('Request date'), DI::l10n()->t('Name'), DI::l10n()->t('Email')], - '$no_pending' => DI::l10n()->t('No registrations.'), - '$pendingnotetext' => DI::l10n()->t('Note from the user'), - '$approve' => DI::l10n()->t('Approve'), - '$deny' => DI::l10n()->t('Deny'), - '$delete' => DI::l10n()->t('Delete'), - '$block' => DI::l10n()->t('Block'), - '$blocked' => DI::l10n()->t('User blocked'), - '$unblock' => DI::l10n()->t('Unblock'), - '$siteadmin' => DI::l10n()->t('Site admin'), - '$accountexpired' => DI::l10n()->t('Account expired'), - - '$h_users' => DI::l10n()->t('Users'), - '$h_newuser' => DI::l10n()->t('New User'), - '$th_deleted' => [DI::l10n()->t('Name'), DI::l10n()->t('Email'), DI::l10n()->t('Register date'), DI::l10n()->t('Last login'), DI::l10n()->t('Last public item'), DI::l10n()->t('Permanent deletion')], - '$th_users' => $th_users, - '$order_users' => $order, - '$order_direction_users' => $order_direction, - - '$confirm_delete_multi' => DI::l10n()->t('Selected users will be deleted!\n\nEverything these users had posted on this site will be permanently deleted!\n\nAre you sure?'), - '$confirm_delete' => DI::l10n()->t('The user {0} will be deleted!\n\nEverything this user has posted on this site will be permanently deleted!\n\nAre you sure?'), - - '$form_security_token' => parent::getFormSecurityToken('admin_users'), - - // values // - '$baseurl' => DI::baseUrl()->get(true), - - '$pending' => $pending, - 'deleted' => $deleted, - '$users' => $users, - '$newusername' => ['new_user_name', DI::l10n()->t('Name'), '', DI::l10n()->t('Name of the new user.')], - '$newusernickname' => ['new_user_nickname', DI::l10n()->t('Nickname'), '', DI::l10n()->t('Nickname of the new user.')], - '$newuseremail' => ['new_user_email', DI::l10n()->t('Email'), '', DI::l10n()->t('Email address of the new user.'), '', '', 'email'], - ]); - - $o .= $pager->renderFull(DBA::count('user')); - - return $o; - } -} diff --git a/src/Module/Admin/Users/Active.php b/src/Module/Admin/Users/Active.php new file mode 100644 index 0000000000..4a7f056748 --- /dev/null +++ b/src/Module/Admin/Users/Active.php @@ -0,0 +1,164 @@ +. + * + */ + +namespace Friendica\Module\Admin\Users; + +use Friendica\Content\Pager; +use Friendica\Core\Renderer; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\User; +use Friendica\Module\Admin\BaseUsers; + +class Active extends BaseUsers +{ + public static function post(array $parameters = []) + { + self::checkAdminAccess(); + + self::checkFormSecurityTokenRedirectOnError(DI::baseUrl()->get(true), 'admin_users_active'); + + $users = $_POST['user'] ?? []; + + if (!empty($_POST['page_users_block'])) { + foreach ($users as $uid) { + User::block($uid); + } + info(DI::l10n()->tt('%s user blocked', '%s users blocked', count($users))); + } + + if (!empty($_POST['page_users_delete'])) { + foreach ($users as $uid) { + if (local_user() != $uid) { + User::remove($uid); + } else { + notice(DI::l10n()->t('You can\'t remove yourself')); + } + } + + info(DI::l10n()->tt('%s user deleted', '%s users deleted', count($users))); + } + + DI::baseUrl()->redirect(DI::args()->getQueryString()); + } + + public static function content(array $parameters = []) + { + parent::content($parameters); + + $action = $parameters['action'] ?? ''; + $uid = $parameters['uid'] ?? 0; + + if ($uid) { + $user = User::getById($uid, ['username', 'blocked']); + if (!DBA::isResult($user)) { + notice(DI::l10n()->t('User not found')); + DI::baseUrl()->redirect('admin/users'); + return ''; // NOTREACHED + } + } + + switch ($action) { + case 'delete': + if (local_user() != $uid) { + self::checkFormSecurityTokenRedirectOnError('admin/users/active', 'admin_users_active', 't'); + // delete user + User::remove($uid); + + notice(DI::l10n()->t('User "%s" deleted', $user['username'])); + } else { + notice(DI::l10n()->t('You can\'t remove yourself')); + } + + DI::baseUrl()->redirect('admin/users/active'); + break; + case 'block': + self::checkFormSecurityTokenRedirectOnError('admin/users/active', 'admin_users_active', 't'); + User::block($uid); + notice(DI::l10n()->t('User "%s" blocked', $user['username'])); + DI::baseUrl()->redirect('admin/users/active'); + break; + } + $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), 100); + + $valid_orders = [ + 'name', + 'email', + 'register_date', + 'login_date', + 'last-item', + 'page-flags' + ]; + + $order = 'name'; + $order_direction = '+'; + if (!empty($_GET['o'])) { + $new_order = $_GET['o']; + if ($new_order[0] === '-') { + $order_direction = '-'; + $new_order = substr($new_order, 1); + } + + if (in_array($new_order, $valid_orders)) { + $order = $new_order; + } + } + + $users = User::getList($pager->getStart(), $pager->getItemsPerPage(), 'active', $order, ($order_direction == '-')); + + $users = array_map(self::setupUserCallback(), $users); + + $th_users = array_map(null, [DI::l10n()->t('Name'), DI::l10n()->t('Email'), DI::l10n()->t('Register date'), DI::l10n()->t('Last login'), DI::l10n()->t('Last public item'), DI::l10n()->t('Type')], $valid_orders); + + $count = DBA::count('user', ['blocked' => false, 'account_removed' => false]); + + $t = Renderer::getMarkupTemplate('admin/users/active.tpl'); + return self::getTabsHTML('active') . Renderer::replaceMacros($t, [ + // strings // + '$title' => DI::l10n()->t('Administration'), + '$page' => DI::l10n()->t('Active Accounts'), + '$select_all' => DI::l10n()->t('select all'), + '$delete' => DI::l10n()->t('Delete'), + '$block' => DI::l10n()->t('Block'), + '$blocked' => DI::l10n()->t('User blocked'), + '$siteadmin' => DI::l10n()->t('Site admin'), + '$accountexpired' => DI::l10n()->t('Account expired'), + '$h_newuser' => DI::l10n()->t('Create a new user'), + + '$th_users' => $th_users, + '$order_users' => $order, + '$order_direction_users' => $order_direction, + + '$confirm_delete_multi' => DI::l10n()->t('Selected users will be deleted!\n\nEverything these users had posted on this site will be permanently deleted!\n\nAre you sure?'), + '$confirm_delete' => DI::l10n()->t('The user {0} will be deleted!\n\nEverything this user has posted on this site will be permanently deleted!\n\nAre you sure?'), + + '$form_security_token' => self::getFormSecurityToken('admin_users_active'), + + // values // + '$baseurl' => DI::baseUrl()->get(true), + '$query_string' => DI::args()->getQueryString(), + + '$users' => $users, + '$count' => $count, + '$pager' => $pager->renderFull($count), + ]); + } +} diff --git a/src/Module/Admin/Users/Blocked.php b/src/Module/Admin/Users/Blocked.php new file mode 100644 index 0000000000..872ac0fa9e --- /dev/null +++ b/src/Module/Admin/Users/Blocked.php @@ -0,0 +1,164 @@ +. + * + */ + +namespace Friendica\Module\Admin\Users; + +use Friendica\Content\Pager; +use Friendica\Core\Renderer; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\User; +use Friendica\Module\Admin\BaseUsers; +use Friendica\Util\Temporal; + +class Blocked extends BaseUsers +{ + public static function post(array $parameters = []) + { + self::checkAdminAccess(); + + self::checkFormSecurityTokenRedirectOnError('/admin/users/blocked', 'admin_users_blocked'); + + $users = $_POST['user'] ?? []; + + if (!empty($_POST['page_users_unblock'])) { + foreach ($users as $uid) { + User::block($uid, false); + } + info(DI::l10n()->tt('%s user unblocked', '%s users unblocked', count($users))); + } + + if (!empty($_POST['page_users_delete'])) { + foreach ($users as $uid) { + if (local_user() != $uid) { + User::remove($uid); + } else { + notice(DI::l10n()->t('You can\'t remove yourself')); + } + } + + info(DI::l10n()->tt('%s user deleted', '%s users deleted', count($users))); + } + + DI::baseUrl()->redirect('admin/users/blocked'); + } + + public static function content(array $parameters = []) + { + parent::content($parameters); + + $action = $parameters['action'] ?? ''; + $uid = $parameters['uid'] ?? 0; + + if ($uid) { + $user = User::getById($uid, ['username', 'blocked']); + if (!DBA::isResult($user)) { + notice(DI::l10n()->t('User not found')); + DI::baseUrl()->redirect('admin/users'); + return ''; // NOTREACHED + } + } + + switch ($action) { + case 'delete': + if (local_user() != $uid) { + self::checkFormSecurityTokenRedirectOnError('/admin/users/blocked', 'admin_users_blocked', 't'); + // delete user + User::remove($uid); + + notice(DI::l10n()->t('User "%s" deleted', $user['username'])); + } else { + notice(DI::l10n()->t('You can\'t remove yourself')); + } + DI::baseUrl()->redirect('admin/users/blocked'); + break; + case 'unblock': + self::checkFormSecurityTokenRedirectOnError('/admin/users/blocked', 'admin_users_blocked', 't'); + User::block($uid, false); + notice(DI::l10n()->t('User "%s" unblocked', $user['username'])); + DI::baseUrl()->redirect('admin/users/blocked'); + break; + } + + $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), 100); + + $valid_orders = [ + 'name', + 'email', + 'register_date', + 'login_date', + 'last-item', + 'page-flags' + ]; + + $order = 'name'; + $order_direction = '+'; + if (!empty($_GET['o'])) { + $new_order = $_GET['o']; + if ($new_order[0] === '-') { + $order_direction = '-'; + $new_order = substr($new_order, 1); + } + + if (in_array($new_order, $valid_orders)) { + $order = $new_order; + } + } + + $users = User::getList($pager->getStart(), $pager->getItemsPerPage(), 'blocked', $order, ($order_direction == '-')); + + $users = array_map(self::setupUserCallback(), $users); + + $th_users = array_map(null, [DI::l10n()->t('Name'), DI::l10n()->t('Email'), DI::l10n()->t('Register date'), DI::l10n()->t('Last login'), DI::l10n()->t('Last public item'), DI::l10n()->t('Type')], $valid_orders); + + $count = DBA::count('user', ['blocked' => true, 'verified' => true]); + + $t = Renderer::getMarkupTemplate('admin/users/blocked.tpl'); + return self::getTabsHTML('blocked') . Renderer::replaceMacros($t, [ + // strings // + '$title' => DI::l10n()->t('Administration'), + '$page' => DI::l10n()->t('Blocked Users'), + '$select_all' => DI::l10n()->t('select all'), + '$delete' => DI::l10n()->t('Delete'), + '$blocked' => DI::l10n()->t('User blocked'), + '$unblock' => DI::l10n()->t('Unblock'), + '$siteadmin' => DI::l10n()->t('Site admin'), + '$accountexpired' => DI::l10n()->t('Account expired'), + + '$th_users' => $th_users, + '$order_users' => $order, + '$order_direction_users' => $order_direction, + + '$confirm_delete_multi' => DI::l10n()->t('Selected users will be deleted!\n\nEverything these users had posted on this site will be permanently deleted!\n\nAre you sure?'), + '$confirm_delete' => DI::l10n()->t('The user {0} will be deleted!\n\nEverything this user has posted on this site will be permanently deleted!\n\nAre you sure?'), + + '$form_security_token' => self::getFormSecurityToken('admin_users_blocked'), + + // values // + '$baseurl' => DI::baseUrl()->get(true), + '$query_string' => DI::args()->getQueryString(), + + '$users' => $users, + '$count' => $count, + '$pager' => $pager->renderFull($count) + ]); + } +} diff --git a/src/Module/Admin/Users/Create.php b/src/Module/Admin/Users/Create.php new file mode 100644 index 0000000000..f5c9b3a4ad --- /dev/null +++ b/src/Module/Admin/Users/Create.php @@ -0,0 +1,76 @@ +. + * + */ + +namespace Friendica\Module\Admin\Users; + +use Friendica\Core\Renderer; +use Friendica\DI; +use Friendica\Model\User; +use Friendica\Module\Admin\BaseUsers; + +class Create extends BaseUsers +{ + public static function post(array $parameters = []) + { + self::checkAdminAccess(); + + self::checkFormSecurityTokenRedirectOnError('/admin/users/create', 'admin_users_create'); + + $nu_name = $_POST['new_user_name'] ?? ''; + $nu_nickname = $_POST['new_user_nickname'] ?? ''; + $nu_email = $_POST['new_user_email'] ?? ''; + $nu_language = DI::config()->get('system', 'language'); + + if ($nu_name !== '' && $nu_email !== '' && $nu_nickname !== '') { + try { + User::createMinimal($nu_name, $nu_email, $nu_nickname, $nu_language); + DI::baseUrl()->redirect('admin/users'); + } catch (\Exception $ex) { + notice($ex->getMessage()); + } + } + + DI::baseUrl()->redirect('admin/users/create'); + } + + public static function content(array $parameters = []) + { + parent::content($parameters); + + $t = Renderer::getMarkupTemplate('admin/users/create.tpl'); + return self::getTabsHTML('all') . Renderer::replaceMacros($t, [ + // strings // + '$title' => DI::l10n()->t('Administration'), + '$page' => DI::l10n()->t('New User'), + '$submit' => DI::l10n()->t('Add User'), + + '$form_security_token' => self::getFormSecurityToken('admin_users_create'), + + // values // + '$baseurl' => DI::baseUrl()->get(true), + '$query_string' => DI::args()->getQueryString(), + + '$newusername' => ['new_user_name', DI::l10n()->t('Name'), '', DI::l10n()->t('Name of the new user.')], + '$newusernickname' => ['new_user_nickname', DI::l10n()->t('Nickname'), '', DI::l10n()->t('Nickname of the new user.')], + '$newuseremail' => ['new_user_email', DI::l10n()->t('Email'), '', DI::l10n()->t('Email address of the new user.'), '', '', 'email'], + ]); + } +} diff --git a/src/Module/Admin/Users/Deleted.php b/src/Module/Admin/Users/Deleted.php new file mode 100644 index 0000000000..1615848ce7 --- /dev/null +++ b/src/Module/Admin/Users/Deleted.php @@ -0,0 +1,101 @@ +. + * + */ + +namespace Friendica\Module\Admin\Users; + +use Friendica\Content\Pager; +use Friendica\Core\Renderer; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Register; +use Friendica\Model\User; +use Friendica\Module\Admin\BaseUsers; +use Friendica\Module\BaseAdmin; +use Friendica\Util\Temporal; + +class Deleted extends BaseUsers +{ + public static function post(array $parameters = []) + { + self::checkAdminAccess(); + + self::checkFormSecurityTokenRedirectOnError('/admin/users/deleted', 'admin_users_deleted'); + + // @TODO: Implement user deletion cancellation + + DI::baseUrl()->redirect('admin/users/deleted'); + } + + public static function content(array $parameters = []) + { + parent::content($parameters); + + $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), 100); + + $valid_orders = [ + 'name', + 'email', + 'register_date', + 'login_date', + 'last-item', + 'page-flags' + ]; + + $order = 'name'; + $order_direction = '+'; + if (!empty($_GET['o'])) { + $new_order = $_GET['o']; + if ($new_order[0] === '-') { + $order_direction = '-'; + $new_order = substr($new_order, 1); + } + + if (in_array($new_order, $valid_orders)) { + $order = $new_order; + } + } + + $users = User::getList($pager->getStart(), $pager->getItemsPerPage(), 'removed', $order, ($order_direction == '-')); + + $users = array_map(self::setupUserCallback(), $users); + + $count = DBA::count('user', ['account_removed' => true]); + + $t = Renderer::getMarkupTemplate('admin/users/deleted.tpl'); + return self::getTabsHTML('deleted') . Renderer::replaceMacros($t, [ + // strings // + '$title' => DI::l10n()->t('Administration'), + '$page' => DI::l10n()->t('Users awaiting permanent deletion'), + + '$th_deleted' => [DI::l10n()->t('Name'), DI::l10n()->t('Email'), DI::l10n()->t('Register date'), DI::l10n()->t('Last login'), DI::l10n()->t('Last public item'), DI::l10n()->t('Permanent deletion')], + + '$form_security_token' => self::getFormSecurityToken('admin_users_deleted'), + + // values // + '$baseurl' => DI::baseUrl()->get(true), + '$query_string' => DI::args()->getQueryString(), + + '$users' => $users, + '$count' => $count, + '$pager' => $pager->renderFull($count), + ]); + } +} diff --git a/src/Module/Admin/Users/Index.php b/src/Module/Admin/Users/Index.php new file mode 100644 index 0000000000..8b8cf2f7fd --- /dev/null +++ b/src/Module/Admin/Users/Index.php @@ -0,0 +1,181 @@ +. + * + */ + +namespace Friendica\Module\Admin\Users; + +use Friendica\Content\Pager; +use Friendica\Core\Renderer; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\User; +use Friendica\Module\Admin\BaseUsers; + +class Index extends BaseUsers +{ + public static function post(array $parameters = []) + { + self::checkAdminAccess(); + + self::checkFormSecurityTokenRedirectOnError('admin/users', 'admin_users'); + + $users = $_POST['user'] ?? []; + + if (!empty($_POST['page_users_block'])) { + foreach ($users as $uid) { + User::block($uid); + } + info(DI::l10n()->tt('%s user blocked', '%s users blocked', count($users))); + } + + if (!empty($_POST['page_users_unblock'])) { + foreach ($users as $uid) { + User::block($uid, false); + } + info(DI::l10n()->tt('%s user unblocked', '%s users unblocked', count($users))); + } + + if (!empty($_POST['page_users_delete'])) { + foreach ($users as $uid) { + if (local_user() != $uid) { + User::remove($uid); + } else { + notice(DI::l10n()->t('You can\'t remove yourself')); + } + } + + info(DI::l10n()->tt('%s user deleted', '%s users deleted', count($users))); + } + + DI::baseUrl()->redirect(DI::args()->getQueryString()); + } + + public static function content(array $parameters = []) + { + parent::content($parameters); + + $action = $parameters['action'] ?? ''; + $uid = $parameters['uid'] ?? 0; + + if ($uid) { + $user = User::getById($uid, ['username', 'blocked']); + if (!DBA::isResult($user)) { + notice(DI::l10n()->t('User not found')); + DI::baseUrl()->redirect('admin/users'); + return ''; // NOTREACHED + } + } + + switch ($action) { + case 'delete': + if (local_user() != $uid) { + self::checkFormSecurityTokenRedirectOnError(DI::baseUrl()->get(true), 'admin_users', 't'); + // delete user + User::remove($uid); + + notice(DI::l10n()->t('User "%s" deleted', $user['username'])); + } else { + notice(DI::l10n()->t('You can\'t remove yourself')); + } + + DI::baseUrl()->redirect('admin/users'); + break; + case 'block': + self::checkFormSecurityTokenRedirectOnError('admin/users', 'admin_users', 't'); + User::block($uid); + notice(DI::l10n()->t('User "%s" blocked', $user['username'])); + DI::baseUrl()->redirect('admin/users'); + break; + case 'unblock': + self::checkFormSecurityTokenRedirectOnError('admin/users', 'admin_users', 't'); + User::block($uid, false); + notice(DI::l10n()->t('User "%s" unblocked', $user['username'])); + DI::baseUrl()->redirect('admin/users'); + break; + } + $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), 100); + + $valid_orders = [ + 'name', + 'email', + 'register_date', + 'login_date', + 'last-item', + 'page-flags' + ]; + + $order = 'name'; + $order_direction = '+'; + if (!empty($_GET['o'])) { + $new_order = $_GET['o']; + if ($new_order[0] === '-') { + $order_direction = '-'; + $new_order = substr($new_order, 1); + } + + if (in_array($new_order, $valid_orders)) { + $order = $new_order; + } + } + + $users = User::getList($pager->getStart(), $pager->getItemsPerPage(), 'all', $order, ($order_direction == '-')); + + $users = array_map(self::setupUserCallback(), $users); + + $th_users = array_map(null, [DI::l10n()->t('Name'), DI::l10n()->t('Email'), DI::l10n()->t('Register date'), DI::l10n()->t('Last login'), DI::l10n()->t('Last public item'), DI::l10n()->t('Type')], $valid_orders); + + $count = DBA::count('user'); + + $t = Renderer::getMarkupTemplate('admin/users/index.tpl'); + return self::getTabsHTML('all') . Renderer::replaceMacros($t, [ + // strings // + '$title' => DI::l10n()->t('Administration'), + '$page' => DI::l10n()->t('Users'), + '$select_all' => DI::l10n()->t('select all'), + '$h_deleted' => DI::l10n()->t('User waiting for permanent deletion'), + '$delete' => DI::l10n()->t('Delete'), + '$block' => DI::l10n()->t('Block'), + '$blocked' => DI::l10n()->t('User blocked'), + '$unblock' => DI::l10n()->t('Unblock'), + '$siteadmin' => DI::l10n()->t('Site admin'), + '$accountexpired' => DI::l10n()->t('Account expired'), + + '$h_users' => DI::l10n()->t('Users'), + '$h_newuser' => DI::l10n()->t('Create a new user'), + '$th_deleted' => [DI::l10n()->t('Name'), DI::l10n()->t('Email'), DI::l10n()->t('Register date'), DI::l10n()->t('Last login'), DI::l10n()->t('Last public item'), DI::l10n()->t('Permanent deletion')], + '$th_users' => $th_users, + '$order_users' => $order, + '$order_direction_users' => $order_direction, + + '$confirm_delete_multi' => DI::l10n()->t('Selected users will be deleted!\n\nEverything these users had posted on this site will be permanently deleted!\n\nAre you sure?'), + '$confirm_delete' => DI::l10n()->t('The user {0} will be deleted!\n\nEverything this user has posted on this site will be permanently deleted!\n\nAre you sure?'), + + '$form_security_token' => self::getFormSecurityToken('admin_users'), + + // values // + '$baseurl' => DI::baseUrl()->get(true), + '$query_string' => DI::args()->getQueryString(), + + '$users' => $users, + '$count' => $count, + '$pager' => $pager->renderFull($count), + ]); + } +} diff --git a/src/Module/Admin/Users/Pending.php b/src/Module/Admin/Users/Pending.php new file mode 100644 index 0000000000..1a1efe3ef6 --- /dev/null +++ b/src/Module/Admin/Users/Pending.php @@ -0,0 +1,121 @@ +. + * + */ + +namespace Friendica\Module\Admin\Users; + +use Friendica\Content\Pager; +use Friendica\Core\Renderer; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Register; +use Friendica\Model\User; +use Friendica\Module\Admin\BaseUsers; +use Friendica\Module\BaseAdmin; +use Friendica\Util\Temporal; + +class Pending extends BaseUsers +{ + public static function post(array $parameters = []) + { + self::checkAdminAccess(); + + self::checkFormSecurityTokenRedirectOnError('/admin/users/pending', 'admin_users_pending'); + + $pending = $_POST['pending'] ?? []; + + if (!empty($_POST['page_users_approve'])) { + foreach ($pending as $hash) { + User::allow($hash); + } + info(DI::l10n()->tt('%s user approved', '%s users approved', count($pending))); + } + + if (!empty($_POST['page_users_deny'])) { + foreach ($pending as $hash) { + User::deny($hash); + } + info(DI::l10n()->tt('%s registration revoked', '%s registrations revoked', count($pending))); + } + + DI::baseUrl()->redirect('admin/users/pending'); + } + + public static function content(array $parameters = []) + { + parent::content($parameters); + + $action = $parameters['action'] ?? ''; + $uid = $parameters['uid'] ?? 0; + + if ($uid) { + $user = User::getById($uid, ['username', 'blocked']); + if (!DBA::isResult($user)) { + notice(DI::l10n()->t('User not found')); + DI::baseUrl()->redirect('admin/users'); + return ''; // NOTREACHED + } + } + + switch ($action) { + case 'allow': + self::checkFormSecurityTokenRedirectOnError('/admin/users/pending', 'admin_users_pending', 't'); + User::allow(Register::getPendingForUser($uid)['hash'] ?? ''); + notice(DI::l10n()->t('Account approved.')); + DI::baseUrl()->redirect('admin/users/pending'); + break; + case 'deny': + self::checkFormSecurityTokenRedirectOnError('/admin/users/pending', 'admin_users_pending', 't'); + User::deny(Register::getPendingForUser($uid)['hash'] ?? ''); + notice(DI::l10n()->t('Registration revoked')); + DI::baseUrl()->redirect('admin/users/pending'); + break; + } + + $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), 100); + + $pending = Register::getPending($pager->getStart(), $pager->getItemsPerPage()); + + $count = Register::getPendingCount(); + + $t = Renderer::getMarkupTemplate('admin/users/pending.tpl'); + return self::getTabsHTML('pending') . Renderer::replaceMacros($t, [ + // strings // + '$title' => DI::l10n()->t('Administration'), + '$page' => DI::l10n()->t('User registrations awaiting review'), + '$select_all' => DI::l10n()->t('select all'), + '$th_pending' => [DI::l10n()->t('Request date'), DI::l10n()->t('Name'), DI::l10n()->t('Email')], + '$no_pending' => DI::l10n()->t('No registrations.'), + '$pendingnotetext' => DI::l10n()->t('Note from the user'), + '$approve' => DI::l10n()->t('Approve'), + '$deny' => DI::l10n()->t('Deny'), + + '$form_security_token' => self::getFormSecurityToken('admin_users_pending'), + + // values // + '$baseurl' => DI::baseUrl()->get(true), + '$query_string' => DI::args()->getQueryString(), + + '$pending' => $pending, + '$count' => $count, + '$pager' => $pager->renderFull($count), + ]); + } +} diff --git a/src/Module/AllFriends.php b/src/Module/AllFriends.php deleted file mode 100644 index 5d73f53cb5..0000000000 --- a/src/Module/AllFriends.php +++ /dev/null @@ -1,126 +0,0 @@ -. - * - */ - -namespace Friendica\Module; - -use Friendica\BaseModule; -use Friendica\Content\ContactSelector; -use Friendica\Content\Pager; -use Friendica\Core\Renderer; -use Friendica\DI; -use Friendica\Model; -use Friendica\Network\HTTPException; -use Friendica\Util\Proxy as ProxyUtils; - -/** - * This module shows all public friends of the selected contact - */ -class AllFriends extends BaseModule -{ - public static function content(array $parameters = []) - { - $app = DI::app(); - - if (!local_user()) { - throw new HTTPException\ForbiddenException(); - } - - $cid = 0; - - // @TODO: Replace with parameter from router - if ($app->argc > 1) { - $cid = intval($app->argv[1]); - } - - if (!$cid) { - throw new HTTPException\BadRequestException(DI::l10n()->t('Invalid contact.')); - } - - $uid = $app->user['uid']; - - $contact = Model\Contact::getContactForUser($cid, local_user(), ['name', 'url', 'photo', 'uid', 'id']); - - if (empty($contact)) { - throw new HTTPException\BadRequestException(DI::l10n()->t('Invalid contact.')); - } - - DI::page()['aside'] = ""; - Model\Profile::load($app, "", Model\Contact::getDetailsByURL($contact["url"])); - - $total = Model\GContact::countAllFriends(local_user(), $cid); - - $pager = new Pager(DI::l10n(), DI::args()->getQueryString()); - - $friends = Model\GContact::allFriends(local_user(), $cid, $pager->getStart(), $pager->getItemsPerPage()); - if (empty($friends)) { - return DI::l10n()->t('No friends to display.'); - } - - $id = 0; - - $entries = []; - foreach ($friends as $friend) { - //get further details of the contact - $contactDetails = Model\Contact::getDetailsByURL($friend['url'], $uid, $friend); - - $connlnk = ''; - // $friend[cid] is only available for common contacts. So if the contact is a common one, use contact_photo_menu to generate the photoMenu - // If the contact is not common to the user, Connect/Follow' will be added to the photo menu - if ($friend['cid']) { - $friend['id'] = $friend['cid']; - $photoMenu = Model\Contact::photoMenu($friend); - } else { - $connlnk = DI::baseUrl()->get() . '/follow/?url=' . $friend['url']; - $photoMenu = [ - 'profile' => [DI::l10n()->t('View Profile'), Model\Contact::magicLinkbyId($friend['id'], $friend['url'])], - 'follow' => [DI::l10n()->t('Connect/Follow'), $connlnk] - ]; - } - - $entry = [ - 'url' => Model\Contact::magicLinkbyId($friend['id'], $friend['url']), - 'itemurl' => ($contactDetails['addr'] ?? '') ?: $friend['url'], - 'name' => $contactDetails['name'], - 'thumb' => ProxyUtils::proxifyUrl($contactDetails['thumb'], false, ProxyUtils::SIZE_THUMB), - 'img_hover' => $contactDetails['name'], - 'details' => $contactDetails['location'], - 'tags' => $contactDetails['keywords'], - 'about' => $contactDetails['about'], - 'account_type' => Model\Contact::getAccountType($contactDetails), - 'network' => ContactSelector::networkToName($contactDetails['network'], $contactDetails['url']), - 'photoMenu' => $photoMenu, - 'conntxt' => DI::l10n()->t('Connect'), - 'connlnk' => $connlnk, - 'id' => ++$id, - ]; - $entries[] = $entry; - } - - $tab_str = Contact::getTabsHTML($app, $contact, 4); - - $tpl = Renderer::getMarkupTemplate('viewcontact_template.tpl'); - return Renderer::replaceMacros($tpl, [ - '$tab_str' => $tab_str, - '$contacts' => $entries, - '$paginate' => $pager->renderFull($total), - ]); - } -} diff --git a/src/Module/Api/Friendica/Profile/Show.php b/src/Module/Api/Friendica/Profile/Show.php index 316072d9b9..a6bf25f2eb 100644 --- a/src/Module/Api/Friendica/Profile/Show.php +++ b/src/Module/Api/Friendica/Profile/Show.php @@ -83,7 +83,7 @@ class Show extends BaseApi foreach ($profileFields as $profileField) { $custom_fields[] = [ 'label' => $profileField->label, - 'value' => BBCode::convert($profileField->value, false, 2), + 'value' => BBCode::convert($profileField->value, false, BBCode::API), ]; } diff --git a/src/Module/Api/Mastodon/Accounts.php b/src/Module/Api/Mastodon/Accounts.php new file mode 100644 index 0000000000..7b11e75271 --- /dev/null +++ b/src/Module/Api/Mastodon/Accounts.php @@ -0,0 +1,52 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +use Friendica\Core\System; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Module\BaseApi; + +/** + * @see https://docs.joinmastodon.org/methods/accounts/ + */ +class Accounts extends BaseApi +{ + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + if (empty($parameters['id'])) { + DI::mstdnError()->RecordNotFound(); + } + + $id = $parameters['id']; + if (!DBA::exists('contact', ['id' => $id, 'uid' => 0])) { + DI::mstdnError()->RecordNotFound(); + } + + $account = DI::mstdnAccount()->createFromContactId($id); + System::jsonExit($account); + } +} diff --git a/src/Module/Api/Mastodon/Accounts/Statuses.php b/src/Module/Api/Mastodon/Accounts/Statuses.php new file mode 100644 index 0000000000..9e29e5f24d --- /dev/null +++ b/src/Module/Api/Mastodon/Accounts/Statuses.php @@ -0,0 +1,99 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon\Accounts; + +use Friendica\Core\Protocol; +use Friendica\Core\System; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Item; +use Friendica\Model\Verb; +use Friendica\Module\BaseApi; +use Friendica\Protocol\Activity; + +/** + * @see https://docs.joinmastodon.org/methods/accounts/ + */ +class Statuses extends BaseApi +{ + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + if (empty($parameters['id'])) { + DI::mstdnError()->RecordNotFound(); + } + + $id = $parameters['id']; + if (!DBA::exists('contact', ['id' => $id, 'uid' => 0])) { + DI::mstdnError()->RecordNotFound(); + } + + // Show only statuses with media attached? Defaults to false. + $only_media = (bool)!isset($_REQUEST['only_media']) ? false : ($_REQUEST['only_media'] == 'true'); // Currently not supported + // Return results older than this id + $max_id = (int)!isset($_REQUEST['max_id']) ? 0 : $_REQUEST['max_id']; + // Return results newer than this id + $since_id = (int)!isset($_REQUEST['since_id']) ? 0 : $_REQUEST['since_id']; + // Return results immediately newer than this id + $min_id = (int)!isset($_REQUEST['min_id']) ? 0 : $_REQUEST['min_id']; + // Maximum number of results to return. Defaults to 20. + $limit = (int)!isset($_REQUEST['limit']) ? 20 : $_REQUEST['limit']; + + $params = ['order' => ['uri-id' => true], 'limit' => $limit]; + + $condition = ['author-id' => $id, 'private' => [Item::PUBLIC, Item::UNLISTED], + 'uid' => 0, 'network' => Protocol::FEDERATED]; + + $condition = DBA::mergeConditions($condition, ["(`gravity` IN (?, ?) OR (`gravity` = ? AND `vid` = ?))", + GRAVITY_PARENT, GRAVITY_COMMENT, GRAVITY_ACTIVITY, Verb::getID(Activity::ANNOUNCE)]); + + if (!empty($max_id)) { + $condition = DBA::mergeConditions($condition, ["`uri-id` < ?", $max_id]); + } + + if (!empty($since_id)) { + $condition = DBA::mergeConditions($condition, ["`uri-id` > ?", $since_id]); + } + + if (!empty($min_id)) { + $condition = DBA::mergeConditions($condition, ["`uri-id` > ?", $min_id]); + $params['order'] = ['uri-id']; + } + + $items = Item::selectForUser(0, ['uri-id', 'uid'], $condition, $params); + + $statuses = []; + while ($item = Item::fetch($items)) { + $statuses[] = DI::mstdnStatus()->createFromUriId($item['uri-id'], $item['uid']); + } + DBA::close($items); + + if (!empty($min_id)) { + array_reverse($statuses); + } + + System::jsonExit($statuses); + } +} diff --git a/src/Module/Api/Mastodon/Directory.php b/src/Module/Api/Mastodon/Directory.php new file mode 100644 index 0000000000..d950d2e2b7 --- /dev/null +++ b/src/Module/Api/Mastodon/Directory.php @@ -0,0 +1,72 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +use Friendica\Core\Logger; +use Friendica\Core\Protocol; +use Friendica\Core\System; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Module\BaseApi; +use Friendica\Network\HTTPException; + +/** + * @see https://docs.joinmastodon.org/methods/instance/directory/ + */ +class Directory extends BaseApi +{ + /** + * @param array $parameters + * @throws HTTPException\InternalServerErrorException + * @throws \ImagickException + * @see https://docs.joinmastodon.org/methods/instance/directory/ + */ + public static function rawContent(array $parameters = []) + { + $offset = (int)!isset($_REQUEST['offset']) ? 0 : $_REQUEST['offset']; + $limit = (int)!isset($_REQUEST['limit']) ? 40 : $_REQUEST['limit']; + $order = !isset($_REQUEST['order']) ? 'active' : $_REQUEST['order']; + $local = (bool)!isset($_REQUEST['local']) ? false : ($_REQUEST['local'] == 'true'); + + Logger::info('directory', ['offset' => $offset, 'limit' => $limit, 'order' => $order, 'local' => $local]); + + if ($local) { + $table = 'owner-view'; + $condition = ['net-publish' => true]; + } else { + $table = 'contact'; + $condition = ['uid' => 0, 'hidden' => false, 'network' => Protocol::FEDERATED]; + } + + $params = ['limit' => [$offset, $limit], + 'order' => [($order == 'active') ? 'last-item' : 'created' => true]]; + + $accounts = []; + $contacts = DBA::select($table, ['id', 'uid'], $condition, $params); + while ($contact = DBA::fetch($contacts)) { + $accounts[] = DI::mstdnAccount()->createFromContactId($contact['id'], $contact['uid']); + } + DBA::close($contacts); + + System::jsonExit($accounts); + } +} diff --git a/src/Module/Api/Mastodon/FollowRequests.php b/src/Module/Api/Mastodon/FollowRequests.php index 3e3ffec58e..e746c135c6 100644 --- a/src/Module/Api/Mastodon/FollowRequests.php +++ b/src/Module/Api/Mastodon/FollowRequests.php @@ -91,7 +91,7 @@ class FollowRequests extends BaseApi */ public static function rawContent(array $parameters = []) { - $since_id = $_GET['since_id'] ?? null; + $min_id = $_GET['min_id'] ?? null; $max_id = $_GET['max_id'] ?? null; $limit = intval($_GET['limit'] ?? 40); @@ -100,7 +100,7 @@ class FollowRequests extends BaseApi $introductions = DI::intro()->selectByBoundaries( ['`uid` = ? AND NOT `ignore`', self::$current_user_id], ['order' => ['id' => 'DESC']], - $since_id, + $min_id, $max_id, $limit ); @@ -127,7 +127,7 @@ class FollowRequests extends BaseApi } if (count($introductions)) { - $links[] = '<' . $baseUrl->get() . '/api/v1/follow_requests?' . http_build_query($base_query + ['since_id' => $introductions[0]->id]) . '>; rel="prev"'; + $links[] = '<' . $baseUrl->get() . '/api/v1/follow_requests?' . http_build_query($base_query + ['min_id' => $introductions[0]->id]) . '>; rel="prev"'; } header('Link: ' . implode(', ', $links)); diff --git a/src/Module/Api/Mastodon/Instance/Peers.php b/src/Module/Api/Mastodon/Instance/Peers.php index 82f08cbad0..537d25e28c 100644 --- a/src/Module/Api/Mastodon/Instance/Peers.php +++ b/src/Module/Api/Mastodon/Instance/Peers.php @@ -42,7 +42,7 @@ class Peers extends BaseApi $return = []; // We only select for Friendica and ActivityPub servers, since it is expected to only deliver AP compatible systems here. - $instances = DBA::select('gserver', ['url'], ["`network` in (?, ?) AND `last_contact` >= `last_failure`", Protocol::DFRN, Protocol::ACTIVITYPUB]); + $instances = DBA::select('gserver', ['url'], ["`network` in (?, ?) AND NOT `failed`", Protocol::DFRN, Protocol::ACTIVITYPUB]); while ($instance = DBA::fetch($instances)) { $urldata = parse_url($instance['url']); unset($urldata['scheme']); diff --git a/src/Module/Api/Mastodon/Timelines/PublicTimeline.php b/src/Module/Api/Mastodon/Timelines/PublicTimeline.php new file mode 100644 index 0000000000..8ed5ecd5d1 --- /dev/null +++ b/src/Module/Api/Mastodon/Timelines/PublicTimeline.php @@ -0,0 +1,98 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon\Timelines; + +use Friendica\Core\Protocol; +use Friendica\Core\System; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Item; +use Friendica\Module\BaseApi; +use Friendica\Network\HTTPException; + +/** + * @see https://docs.joinmastodon.org/methods/timelines/ + */ +class PublicTimeline extends BaseApi +{ + /** + * @param array $parameters + * @throws HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + // Show only local statuses? Defaults to false. + $local = (bool)!isset($_REQUEST['local']) ? false : ($_REQUEST['local'] == 'true'); + // Show only remote statuses? Defaults to false. + $remote = (bool)!isset($_REQUEST['remote']) ? false : ($_REQUEST['remote'] == 'true'); + // Show only statuses with media attached? Defaults to false. + $only_media = (bool)!isset($_REQUEST['only_media']) ? false : ($_REQUEST['only_media'] == 'true'); // Currently not supported + // Return results older than this id + $max_id = (int)!isset($_REQUEST['max_id']) ? 0 : $_REQUEST['max_id']; + // Return results newer than this id + $since_id = (int)!isset($_REQUEST['since_id']) ? 0 : $_REQUEST['since_id']; + // Return results immediately newer than this id + $min_id = (int)!isset($_REQUEST['min_id']) ? 0 : $_REQUEST['min_id']; + // Maximum number of results to return. Defaults to 20. + $limit = (int)!isset($_REQUEST['limit']) ? 20 : $_REQUEST['limit']; + + $params = ['order' => ['uri-id' => true], 'limit' => $limit]; + + $condition = ['gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT], 'private' => Item::PUBLIC, + 'uid' => 0, 'network' => Protocol::FEDERATED]; + + if ($local) { + $condition = DBA::mergeConditions($condition, ["`uri-id` IN (SELECT `uri-id` FROM `item` WHERE `origin`)"]); + } + + if ($remote) { + $condition = DBA::mergeConditions($condition, ["NOT `uri-id` IN (SELECT `uri-id` FROM `item` WHERE `origin`)"]); + } + + if (!empty($max_id)) { + $condition = DBA::mergeConditions($condition, ["`uri-id` < ?", $max_id]); + } + + if (!empty($since_id)) { + $condition = DBA::mergeConditions($condition, ["`uri-id` > ?", $since_id]); + } + + if (!empty($min_id)) { + $condition = DBA::mergeConditions($condition, ["`uri-id` > ?", $min_id]); + $params['order'] = ['uri-id']; + } + + $items = Item::selectForUser(0, ['uri-id', 'uid'], $condition, $params); + + $statuses = []; + while ($item = Item::fetch($items)) { + $statuses[] = DI::mstdnStatus()->createFromUriId($item['uri-id'], $item['uid']); + } + DBA::close($items); + + if (!empty($min_id)) { + array_reverse($statuses); + } + + System::jsonExit($statuses); + } +} diff --git a/src/Module/Api/Mastodon/Trends.php b/src/Module/Api/Mastodon/Trends.php new file mode 100644 index 0000000000..5fe01f8f1d --- /dev/null +++ b/src/Module/Api/Mastodon/Trends.php @@ -0,0 +1,53 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +use Friendica\Core\System; +use Friendica\DI; +use Friendica\Model\Tag; +use Friendica\Module\BaseApi; + +/** + * @see https://docs.joinmastodon.org/methods/instance/trends/ + */ +class Trends extends BaseApi +{ + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + // Maximum number of results to return. Defaults to 10. + $limit = (int)!isset($_REQUEST['limit']) ? 10 : $_REQUEST['limit']; + + $trending = []; + $tags = Tag::getGlobalTrendingHashtags(24, 20); + foreach ($tags as $tag) { + $tag['name'] = $tag['term']; + $hashtag = new \Friendica\Object\Api\Mastodon\Tag(DI::baseUrl(), $tag); + $trending[] = $hashtag->toArray(); + } + + System::jsonExit(array_slice($trending, 0, $limit)); + } +} diff --git a/src/Module/Api/Mastodon/Unimplemented.php b/src/Module/Api/Mastodon/Unimplemented.php new file mode 100644 index 0000000000..c5adbe5d92 --- /dev/null +++ b/src/Module/Api/Mastodon/Unimplemented.php @@ -0,0 +1,47 @@ +. + * + */ + +namespace Friendica\Module\Api\Mastodon; + +use Friendica\Core\Logger; +use Friendica\Core\System; +use Friendica\DI; +use Friendica\Module\BaseApi; + +/** + * Dummy class for all currently unimplemented endpoints + */ +class Unimplemented extends BaseApi +{ + /** + * @param array $parameters + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(array $parameters = []) + { + $path = DI::args()->getQueryString(); + Logger::info('Unimplemented API call', ['path' => $path]); + $error = DI::l10n()->t('API endpoint "%s" is not implemented', $path); + $error_description = DI::l10n()->t('The API endpoint is currently not implemented but might be in the future.');; + $errorobj = new \Friendica\Object\Api\Mastodon\Error($error, $error_description); + System::jsonError(501, $errorobj->toArray()); + } +} diff --git a/src/Module/Api/Twitter/ContactEndpoint.php b/src/Module/Api/Twitter/ContactEndpoint.php new file mode 100644 index 0000000000..4ab9cc9742 --- /dev/null +++ b/src/Module/Api/Twitter/ContactEndpoint.php @@ -0,0 +1,230 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\Profile; +use Friendica\Model\User; +use Friendica\Module\BaseApi; +use Friendica\Model\Contact; +use Friendica\Network\HTTPException; +use Friendica\Util\Strings; + +abstract class ContactEndpoint extends BaseApi +{ + const DEFAULT_COUNT = 20; + const MAX_COUNT = 200; + + public static function init(array $parameters = []) + { + parent::init($parameters); + + if (!self::login()) { + throw new HTTPException\UnauthorizedException(); + } + } + + /** + * Computes the uid from the contact_id + screen_name parameters + * + * @param int|null $contact_id + * @param string $screen_name + * @return int + * @throws HTTPException\NotFoundException + */ + protected static function getUid(int $contact_id = null, string $screen_name = null) + { + $uid = self::$current_user_id; + + if ($contact_id || $screen_name) { + // screen_name trumps user_id when both are provided + if (!$screen_name) { + $contact = Contact::getById($contact_id, ['nick', 'url']); + // We don't have the followers of remote accounts so we check for locality + if (empty($contact) || !Strings::startsWith($contact['url'], DI::baseUrl()->get())) { + throw new HTTPException\NotFoundException(DI::l10n()->t('Contact not found')); + } + + $screen_name = $contact['nick']; + } + + $user = User::getByNickname($screen_name, ['uid']); + if (empty($user)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found')); + } + + $uid = (int)$user['uid']; + } + + return $uid; + } + + /** + * This methods expands the contact ids into full user objects in an existing result set. + * + * @param mixed $rel A relationship constant or a list of them + * @param int $uid The local user id we query the contacts from + * @param int $cursor + * @param int $count + * @param bool $skip_status + * @param bool $include_user_entities + * @return array + * @throws HTTPException\InternalServerErrorException + * @throws HTTPException\NotFoundException + * @throws \ImagickException + */ + protected static function list($rel, int $uid, int $cursor = -1, int $count = self::DEFAULT_COUNT, bool $skip_status = false, bool $include_user_entities = true) + { + $return = self::ids($rel, $uid, $cursor, $count); + + $users = []; + foreach ($return['ids'] as $contactId) { + $users[] = DI::twitterUser()->createFromContactId($contactId, $uid, $skip_status, $include_user_entities); + } + + unset($return['ids']); + $return['users'] = $users; + + $return = [ + 'users' => $users, + 'next_cursor' => $return['next_cursor'], + 'next_cursor_str' => $return['next_cursor_str'], + 'previous_cursor' => $return['previous_cursor'], + 'previous_cursor_str' => $return['previous_cursor_str'], + 'total_count' => (int)$return['total_count'], + ]; + + return $return; + } + + /** + * @param mixed $rel A relationship constant or a list of them + * @param int $uid The local user id we query the contacts from + * @param int $cursor + * @param int $count + * @param bool $stringify_ids + * @return array + * @throws HTTPException\NotFoundException + */ + protected static function ids($rel, int $uid, int $cursor = -1, int $count = self::DEFAULT_COUNT, bool $stringify_ids = false) + { + $hide_friends = false; + if ($uid != self::$current_user_id) { + $profile = Profile::getByUID($uid); + if (empty($profile)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('Profile not found')); + } + + $hide_friends = (bool)$profile['hide-friends']; + } + + $ids = []; + $next_cursor = 0; + $previous_cursor = 0; + $total_count = 0; + if (!$hide_friends) { + $condition = [ + 'rel' => $rel, + 'uid' => $uid, + 'self' => false, + 'deleted' => false, + 'hidden' => false, + 'archive' => false, + 'pending' => false + ]; + + $total_count = (int)DBA::count('contact', $condition); + + $params = ['limit' => $count, 'order' => ['id' => 'ASC']]; + + if ($cursor !== -1) { + if ($cursor > 0) { + $condition = DBA::mergeConditions($condition, ['`id` > ?', $cursor]); + } else { + $condition = DBA::mergeConditions($condition, ['`id` < ?', -$cursor]); + // Previous page case: we want the items closest to cursor but for that we need to reverse the query order + $params['order']['id'] = 'DESC'; + } + } + + $contacts = Contact::selectToArray(['id'], $condition, $params); + + // Previous page case: once we get the relevant items closest to cursor, we need to restore the expected display order + if ($cursor !== -1 && $cursor <= 0) { + $contacts = array_reverse($contacts); + } + + // Contains user-specific contact ids + $ids = array_column($contacts, 'id'); + + // Cursor is on the user-specific contact id since it's the sort field + if (count($ids)) { + $previous_cursor = -$ids[0]; + $next_cursor = (int)$ids[count($ids) -1]; + } + + // No next page + if ($total_count <= count($contacts) || count($contacts) < $count) { + $next_cursor = 0; + } + // End of results + if ($cursor < 0 && count($contacts) === 0) { + $next_cursor = -1; + } + + // No previous page + if ($cursor === -1) { + $previous_cursor = 0; + } + + if ($cursor > 0 && count($contacts) === 0) { + $previous_cursor = -$cursor; + } + + if ($cursor < 0 && count($contacts) === 0) { + $next_cursor = -1; + } + + // Conversion to public contact ids + array_walk($ids, function (&$contactId) use ($uid, $stringify_ids) { + $cdata = Contact::getPublicAndUserContacID($contactId, $uid); + if ($stringify_ids) { + $contactId = (string)$cdata['public']; + } else { + $contactId = (int)$cdata['public']; + } + }); + } + + $return = [ + 'ids' => $ids, + 'next_cursor' => $next_cursor, + 'next_cursor_str' => (string)$next_cursor, + 'previous_cursor' => $previous_cursor, + 'previous_cursor_str' => (string)$previous_cursor, + 'total_count' => $total_count, + ]; + + return $return; + } +} diff --git a/src/Module/Api/Twitter/FollowersIds.php b/src/Module/Api/Twitter/FollowersIds.php new file mode 100644 index 0000000000..7b0bc84e07 --- /dev/null +++ b/src/Module/Api/Twitter/FollowersIds.php @@ -0,0 +1,58 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids + */ +class FollowersIds extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT); + $screen_name = filter_input(INPUT_GET, 'screen_name'); + $cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT); + $stringify_ids = filter_input(INPUT_GET, 'stringify_ids', FILTER_VALIDATE_BOOLEAN); + $count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => self::DEFAULT_COUNT, + 'min_range' => 1, + 'max_range' => self::MAX_COUNT, + ]]); + // Friendica-specific + $since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT); + $max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => 1, + ]]); + + System::jsonExit(self::ids( + [Contact::FOLLOWER, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor ?? $since_id ?? - $max_id, + $count, + $stringify_ids + )); + } +} diff --git a/src/Module/Api/Twitter/FollowersList.php b/src/Module/Api/Twitter/FollowersList.php new file mode 100644 index 0000000000..7559d8327a --- /dev/null +++ b/src/Module/Api/Twitter/FollowersList.php @@ -0,0 +1,62 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list + */ +class FollowersList extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT); + $screen_name = filter_input(INPUT_GET, 'screen_name'); + $cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT); + $count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => self::DEFAULT_COUNT, + 'min_range' => 1, + 'max_range' => self::MAX_COUNT, + ]]); + $skip_status = filter_input(INPUT_GET, 'skip_status' , FILTER_VALIDATE_BOOLEAN); + $include_user_entities = filter_input(INPUT_GET, 'include_user_entities', FILTER_VALIDATE_BOOLEAN); + + // Friendica-specific + $since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT); + $max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => 1, + ]]); + + + System::jsonExit(self::list( + [Contact::FOLLOWER, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor ?? $since_id ?? - $max_id, + $count, + $skip_status, + $include_user_entities + )); + } +} diff --git a/src/Module/Api/Twitter/FriendsIds.php b/src/Module/Api/Twitter/FriendsIds.php new file mode 100644 index 0000000000..1a303bfa75 --- /dev/null +++ b/src/Module/Api/Twitter/FriendsIds.php @@ -0,0 +1,58 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-ids + */ +class FriendsIds extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT); + $screen_name = filter_input(INPUT_GET, 'screen_name'); + $cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT); + $stringify_ids = filter_input(INPUT_GET, 'stringify_ids', FILTER_VALIDATE_BOOLEAN); + $count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => self::DEFAULT_COUNT, + 'min_range' => 1, + 'max_range' => self::MAX_COUNT, + ]]); + // Friendica-specific + $since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT); + $max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => 1, + ]]); + + System::jsonExit(self::ids( + [Contact::SHARING, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor ?? $since_id ?? - $max_id, + $count, + $stringify_ids + )); + } +} diff --git a/src/Module/Api/Twitter/FriendsList.php b/src/Module/Api/Twitter/FriendsList.php new file mode 100644 index 0000000000..1a45f07917 --- /dev/null +++ b/src/Module/Api/Twitter/FriendsList.php @@ -0,0 +1,61 @@ +. + * + */ + +namespace Friendica\Module\Api\Twitter; + +use Friendica\Core\System; +use Friendica\Model\Contact; + +/** + * @see https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list + */ +class FriendsList extends ContactEndpoint +{ + public static function rawContent(array $parameters = []) + { + // Expected value for user_id parameter: public/user contact id + $contact_id = filter_input(INPUT_GET, 'user_id' , FILTER_VALIDATE_INT); + $screen_name = filter_input(INPUT_GET, 'screen_name'); + $cursor = filter_input(INPUT_GET, 'cursor' , FILTER_VALIDATE_INT); + $count = filter_input(INPUT_GET, 'count' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => self::DEFAULT_COUNT, + 'min_range' => 1, + 'max_range' => self::MAX_COUNT, + ]]); + $skip_status = filter_input(INPUT_GET, 'skip_status' , FILTER_VALIDATE_BOOLEAN); + $include_user_entities = filter_input(INPUT_GET, 'include_user_entities', FILTER_VALIDATE_BOOLEAN); + + // Friendica-specific + $since_id = filter_input(INPUT_GET, 'since_id' , FILTER_VALIDATE_INT); + $max_id = filter_input(INPUT_GET, 'max_id' , FILTER_VALIDATE_INT, ['options' => [ + 'default' => 1, + ]]); + + System::jsonExit(self::list( + [Contact::SHARING, Contact::FRIEND], + self::getUid($contact_id, $screen_name), + $cursor ?? $since_id ?? - $max_id, + $count, + $skip_status, + $include_user_entities + )); + } +} diff --git a/src/Module/Apps.php b/src/Module/Apps.php index 04c7d7b6ac..29a735121d 100644 --- a/src/Module/Apps.php +++ b/src/Module/Apps.php @@ -44,7 +44,7 @@ class Apps extends BaseModule $apps = Nav::getAppMenu(); if (count($apps) == 0) { - notice(DI::l10n()->t('No installed applications.') . EOL); + notice(DI::l10n()->t('No installed applications.')); } $tpl = Renderer::getMarkupTemplate('apps.tpl'); diff --git a/src/Module/BaseAdmin.php b/src/Module/BaseAdmin.php index 300aeb45b4..feb61f0e15 100644 --- a/src/Module/BaseAdmin.php +++ b/src/Module/BaseAdmin.php @@ -26,7 +26,7 @@ use Friendica\Core\Addon; use Friendica\Core\Renderer; use Friendica\Core\Session; use Friendica\DI; -use Friendica\Network\HTTPException\ForbiddenException; +use Friendica\Network\HTTPException; require_once 'boot.php'; @@ -42,42 +42,35 @@ require_once 'boot.php'; */ abstract class BaseAdmin extends BaseModule { - public static function post(array $parameters = []) + /** + * @param bool $interactive + * @throws HTTPException\ForbiddenException + * @throws HTTPException\InternalServerErrorException + */ + public static function checkAdminAccess(bool $interactive = false) { - if (!is_site_admin()) { - return; + if (!local_user()) { + if ($interactive) { + notice(DI::l10n()->t('Please login to continue.')); + Session::set('return_path', DI::args()->getQueryString()); + DI::baseUrl()->redirect('login'); + } else { + throw new HTTPException\UnauthorizedException(DI::l10n()->t('Please login to continue.')); + } } - // do not allow a page manager to access the admin panel at all. - if (!empty($_SESSION['submanage'])) { - return; - } - } - - public static function rawContent(array $parameters = []) - { if (!is_site_admin()) { - return ''; + throw new HTTPException\ForbiddenException(DI::l10n()->t('You don\'t have access to administration pages.')); } if (!empty($_SESSION['submanage'])) { - return ''; + throw new HTTPException\ForbiddenException(DI::l10n()->t('Submanaged account can\'t access the administration pages. Please log back in as the main account.')); } - - return ''; } public static function content(array $parameters = []) { - if (!is_site_admin()) { - notice(DI::l10n()->t('Please login to continue.')); - Session::set('return_path', DI::args()->getQueryString()); - DI::baseUrl()->redirect('login'); - } - - if (!empty($_SESSION['submanage'])) { - throw new ForbiddenException(DI::l10n()->t('Submanaged account can\'t access the administation pages. Please log back in as the master account.')); - } + self::checkAdminAccess(true); // Header stuff DI::page()['htmlhead'] .= Renderer::replaceMacros(Renderer::getMarkupTemplate('admin/settings_head.tpl'), []); @@ -121,6 +114,7 @@ abstract class BaseAdmin extends BaseModule 'webfinger' => ['webfinger' , DI::l10n()->t('check webfinger') , 'webfinger'], 'itemsource' => ['admin/item/source' , DI::l10n()->t('Item Source') , 'itemsource'], 'babel' => ['babel' , DI::l10n()->t('Babel') , 'babel'], + 'debug/ap' => ['debug/ap' , DI::l10n()->t('ActivityPub Conversion') , 'debug/ap'], ]], ]; diff --git a/src/Module/BaseApi.php b/src/Module/BaseApi.php index 5a5326756d..75791e0eae 100644 --- a/src/Module/BaseApi.php +++ b/src/Module/BaseApi.php @@ -42,13 +42,13 @@ class BaseApi extends BaseModule { $arguments = DI::args(); - if (substr($arguments->getQueryString(), -4) === '.xml') { + if (substr($arguments->getCommand(), -4) === '.xml') { self::$format = 'xml'; } - if (substr($arguments->getQueryString(), -4) === '.rss') { + if (substr($arguments->getCommand(), -4) === '.rss') { self::$format = 'rss'; } - if (substr($arguments->getQueryString(), -4) === '.atom') { + if (substr($arguments->getCommand(), -4) === '.atom') { self::$format = 'atom'; } } diff --git a/src/Module/BaseSearch.php b/src/Module/BaseSearch.php index e67d3c3c93..77abae007a 100644 --- a/src/Module/BaseSearch.php +++ b/src/Module/BaseSearch.php @@ -22,7 +22,6 @@ namespace Friendica\Module; use Friendica\BaseModule; -use Friendica\Content\ContactSelector; use Friendica\Content\Pager; use Friendica\Core\Renderer; use Friendica\Core\Search; @@ -31,7 +30,6 @@ use Friendica\Model; use Friendica\Network\HTTPException; use Friendica\Object\Search\ContactResult; use Friendica\Object\Search\ResultList; -use Friendica\Util\Proxy as ProxyUtils; /** * Base class for search modules @@ -116,69 +114,19 @@ class BaseSearch extends BaseModule protected static function printResult(ResultList $results, Pager $pager, $header = '') { if ($results->getTotal() == 0) { - info(DI::l10n()->t('No matches')); + notice(DI::l10n()->t('No matches')); return ''; } - $id = 0; $entries = []; foreach ($results->getResults() as $result) { // in case the result is a contact result, add a contact-specific entry if ($result instanceof ContactResult) { - - $alt_text = ''; - $location = ''; - $about = ''; - $accountType = ''; - $photo_menu = []; - - // If We already know this contact then don't show the "connect" button - if ($result->getCid() > 0 || $result->getPCid() > 0) { - $connLink = ""; - $connTxt = ""; - $contact = Model\Contact::getById( - ($result->getCid() > 0) ? $result->getCid() : $result->getPCid() - ); - - if (!empty($contact)) { - $photo_menu = Model\Contact::photoMenu($contact); - $details = Contact::getContactTemplateVars($contact); - $alt_text = $details['alt_text']; - $location = $contact['location']; - $about = $contact['about']; - $accountType = Model\Contact::getAccountType($contact); - } else { - $photo_menu = []; - } - } else { - $connLink = DI::baseUrl()->get() . '/follow/?url=' . $result->getUrl(); - $connTxt = DI::l10n()->t('Connect'); - - $photo_menu['profile'] = [DI::l10n()->t("View Profile"), Model\Contact::magicLink($result->getUrl())]; - $photo_menu['follow'] = [DI::l10n()->t("Connect/Follow"), $connLink]; + $contact = Model\Contact::getByURLForUser($result->getUrl(), local_user()); + if (!empty($contact)) { + $entries[] = Contact::getContactTemplateVars($contact); } - - $photo = str_replace("http:///photo/", Search::getGlobalDirectory() . "/photo/", $result->getPhoto()); - - $entry = [ - 'alt_text' => $alt_text, - 'url' => Model\Contact::magicLink($result->getUrl()), - 'itemurl' => $result->getItem(), - 'name' => $result->getName(), - 'thumb' => ProxyUtils::proxifyUrl($photo, false, ProxyUtils::SIZE_THUMB), - 'img_hover' => $result->getTags(), - 'conntxt' => $connTxt, - 'connlnk' => $connLink, - 'photo_menu' => $photo_menu, - 'details' => $location, - 'tags' => $result->getTags(), - 'about' => $about, - 'account_type' => $accountType, - 'network' => ContactSelector::networkToName($result->getNetwork(), $result->getUrl()), - 'id' => ++$id, - ]; - $entries[] = $entry; } } diff --git a/src/Module/Bookmarklet.php b/src/Module/Bookmarklet.php index 9ecce8ade1..e5b3ee4ade 100644 --- a/src/Module/Bookmarklet.php +++ b/src/Module/Bookmarklet.php @@ -22,6 +22,7 @@ namespace Friendica\Module; use Friendica\BaseModule; +use Friendica\Content\PageInfo; use Friendica\Core\ACL; use Friendica\DI; use Friendica\Module\Security\Login; @@ -55,7 +56,7 @@ class Bookmarklet extends BaseModule throw new HTTPException\BadRequestException(DI::l10n()->t('This page is missing a url parameter.')); } - $content = add_page_info($_REQUEST["url"]); + $content = "\n" . PageInfo::getFooterFromUrl($_REQUEST['url']); $x = [ 'is_owner' => true, diff --git a/src/Module/Contact.php b/src/Module/Contact.php index 84420afaad..f82b7d3cc1 100644 --- a/src/Module/Contact.php +++ b/src/Module/Contact.php @@ -21,7 +21,6 @@ namespace Friendica\Module; -use Friendica\App; use Friendica\BaseModule; use Friendica\Content\ContactSelector; use Friendica\Content\Nav; @@ -32,15 +31,16 @@ use Friendica\Core\ACL; use Friendica\Core\Hook; use Friendica\Core\Protocol; use Friendica\Core\Renderer; +use Friendica\Core\Theme; use Friendica\Core\Worker; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model; +use Friendica\Model\User; use Friendica\Module\Security\Login; use Friendica\Network\HTTPException\BadRequestException; use Friendica\Network\HTTPException\NotFoundException; use Friendica\Util\DateTimeFormat; -use Friendica\Util\Proxy as ProxyUtils; use Friendica\Util\Strings; /** @@ -48,6 +48,12 @@ use Friendica\Util\Strings; */ class Contact extends BaseModule { + const TAB_CONVERSATIONS = 1; + const TAB_POSTS = 2; + const TAB_PROFILE = 3; + const TAB_CONTACTS = 4; + const TAB_ADVANCED = 5; + private static function batchActions() { if (empty($_POST['contact_batch']) || !is_array($_POST['contact_batch'])) { @@ -112,7 +118,7 @@ class Contact extends BaseModule } if (!DBA::exists('contact', ['id' => $contact_id, 'uid' => local_user(), 'deleted' => false])) { - notice(DI::l10n()->t('Could not access contact record.') . EOL); + notice(DI::l10n()->t('Could not access contact record.')); DI::baseUrl()->redirect('contact'); return; // NOTREACHED } @@ -125,7 +131,9 @@ class Contact extends BaseModule $fetch_further_information = intval($_POST['fetch_further_information'] ?? 0); - $ffi_keyword_blacklist = Strings::escapeHtml(trim($_POST['ffi_keyword_blacklist'] ?? '')); + $remote_self = $_POST['remote_self'] ?? false; + + $ffi_keyword_denylist = Strings::escapeHtml(trim($_POST['ffi_keyword_denylist'] ?? '')); $priority = intval($_POST['poll'] ?? 0); if ($priority > 5 || $priority < 0) { @@ -140,14 +148,13 @@ class Contact extends BaseModule 'hidden' => $hidden, 'notify_new_posts' => $notify, 'fetch_further_information' => $fetch_further_information, - 'ffi_keyword_blacklist' => $ffi_keyword_blacklist], + 'remote_self' => $remote_self, + 'ffi_keyword_denylist' => $ffi_keyword_denylist], ['id' => $contact_id, 'uid' => local_user()] ); - if (DBA::isResult($r)) { - info(DI::l10n()->t('Contact updated.') . EOL); - } else { - notice(DI::l10n()->t('Failed to update contact record.') . EOL); + if (!DBA::isResult($r)) { + notice(DI::l10n()->t('Failed to update contact record.')); } $contact = DBA::selectFirst('contact', [], ['id' => $contact_id, 'uid' => local_user(), 'deleted' => false]); @@ -167,32 +174,30 @@ class Contact extends BaseModule return; } - $uid = $contact['uid']; - if ($contact['network'] == Protocol::OSTATUS) { - $result = Model\Contact::createFromProbe($uid, $contact['url'], false, $contact['network']); + $user = Model\User::getById($contact['uid']); + $result = Model\Contact::createFromProbe($user, $contact['url'], false, $contact['network']); if ($result['success']) { DBA::update('contact', ['subhub' => 1], ['id' => $contact_id]); } - } else { + // pull feed and consume it, which should subscribe to the hub. Worker::add(PRIORITY_HIGH, 'OnePoll', $contact_id, 'force'); + } else { + Worker::add(PRIORITY_HIGH, 'UpdateContact', $contact_id); } } private static function updateContactFromProbe($contact_id) { - $contact = DBA::selectFirst('contact', ['url'], ['id' => $contact_id, 'uid' => local_user(), 'deleted' => false]); + $contact = DBA::selectFirst('contact', ['url'], ['id' => $contact_id, 'uid' => [0, local_user()], 'deleted' => false]); if (!DBA::isResult($contact)) { return; } // Update the entry in the contact table - Model\Contact::updateFromProbe($contact_id, '', true); - - // Update the entry in the gcontact table - Model\GContact::updateFromProbe($contact['url']); + Model\Contact::updateFromProbe($contact_id); } /** @@ -203,8 +208,8 @@ class Contact extends BaseModule */ private static function blockContact($contact_id) { - $blocked = !Model\Contact::isBlockedByUser($contact_id, local_user()); - Model\Contact::setBlockedForUser($contact_id, local_user(), $blocked); + $blocked = !Model\Contact\User::isBlocked($contact_id, local_user()); + Model\Contact\User::setBlocked($contact_id, local_user(), $blocked); } /** @@ -215,8 +220,8 @@ class Contact extends BaseModule */ private static function ignoreContact($contact_id) { - $ignored = !Model\Contact::isIgnoredByUser($contact_id, local_user()); - Model\Contact::setIgnoredForUser($contact_id, local_user(), $ignored); + $ignored = !Model\Contact\User::isIgnored($contact_id, local_user()); + Model\Contact\User::setIgnored($contact_id, local_user(), $ignored); } /** @@ -260,23 +265,31 @@ class Contact extends BaseModule $rel = Strings::escapeTags(trim($_GET['rel'] ?? '')); $group = Strings::escapeTags(trim($_GET['group'] ?? '')); - if (empty(DI::page()['aside'])) { - DI::page()['aside'] = ''; - } + $accounttype = $_GET['accounttype'] ?? ''; + $accounttypeid = User::getAccountTypeByString($accounttype); + + $page = DI::page(); + + $page->registerFooterScript(Theme::getPathForFile('asset/typeahead.js/dist/typeahead.bundle.js')); + $page->registerFooterScript(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput.js')); + $page->registerStylesheet(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput.css')); + $page->registerStylesheet(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput-typeahead.css')); - $contact_id = null; $contact = null; // @TODO: Replace with parameter from router if ($a->argc == 2 && intval($a->argv[1]) || $a->argc == 3 && intval($a->argv[1]) && in_array($a->argv[2], ['posts', 'conversations']) ) { $contact_id = intval($a->argv[1]); - $contact = DBA::selectFirst('contact', [], ['id' => $contact_id, 'uid' => local_user(), 'deleted' => false]); - if (!DBA::isResult($contact)) { - $contact = DBA::selectFirst('contact', [], ['id' => $contact_id, 'uid' => 0, 'deleted' => false]); + // Ensure to use the user contact when the public contact was provided + $data = Model\Contact::getPublicAndUserContacID($contact_id, local_user()); + if (!empty($data['user']) && ($contact_id == $data['public'])) { + $contact_id = $data['user']; } + $contact = DBA::selectFirst('contact', [], ['id' => $contact_id, 'uid' => [0, local_user()], 'deleted' => false]); + // Don't display contacts that are about to be deleted if ($contact['network'] == Protocol::PHANTOM) { $contact = false; @@ -305,9 +318,9 @@ class Contact extends BaseModule $unfollow_link = ''; if (in_array($contact['network'], Protocol::NATIVE_SUPPORT)) { if ($contact['uid'] && in_array($contact['rel'], [Model\Contact::SHARING, Model\Contact::FRIEND])) { - $unfollow_link = 'unfollow?url=' . urlencode($contact['url']); + $unfollow_link = 'unfollow?url=' . urlencode($contact['url']) . '&auto=1'; } elseif(!$contact['pending']) { - $follow_link = 'follow?url=' . urlencode($contact['url']); + $follow_link = 'follow?url=' . urlencode($contact['url']) . '&auto=1'; } } @@ -318,7 +331,7 @@ class Contact extends BaseModule $vcard_widget = Renderer::replaceMacros(Renderer::getMarkupTemplate('widget/vcard.tpl'), [ '$name' => $contact['name'], - '$photo' => $contact['photo'], + '$photo' => Model\Contact::getPhoto($contact), '$url' => Model\Contact::magicLinkByContact($contact, $contact['url']), '$addr' => $contact['addr'] ?? '', '$network_link' => $network_link, @@ -334,6 +347,7 @@ class Contact extends BaseModule $findpeople_widget = ''; $follow_widget = ''; + $account_widget = ''; $networks_widget = ''; $rel_widget = ''; @@ -351,12 +365,13 @@ class Contact extends BaseModule $follow_widget = Widget::follow(); } + $account_widget = Widget::accounttypes($_SERVER['REQUEST_URI'], $accounttype); $networks_widget = Widget::networks($_SERVER['REQUEST_URI'], $nets); $rel_widget = Widget::contactRels($_SERVER['REQUEST_URI'], $rel); $groups_widget = Widget::groups($_SERVER['REQUEST_URI'], $group); } - DI::page()['aside'] .= $vcard_widget . $findpeople_widget . $follow_widget . $groups_widget . $networks_widget . $rel_widget; + DI::page()['aside'] .= $vcard_widget . $findpeople_widget . $follow_widget . $account_widget . $groups_widget . $networks_widget . $rel_widget; $tpl = Renderer::getMarkupTemplate('contacts-head.tpl'); DI::page()['htmlhead'] .= Renderer::replaceMacros($tpl, [ @@ -367,7 +382,7 @@ class Contact extends BaseModule Nav::setSelected('contact'); if (!local_user()) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return Login::form(); } @@ -391,17 +406,17 @@ class Contact extends BaseModule // NOTREACHED } - if ($cmd === 'updateprofile' && ($orig_record['uid'] != 0)) { + if ($cmd === 'updateprofile') { self::updateContactFromProbe($contact_id); - DI::baseUrl()->redirect('contact/' . $contact_id . '/advanced/'); + DI::baseUrl()->redirect('contact/' . $contact_id); // NOTREACHED } if ($cmd === 'block') { self::blockContact($contact_id); - $blocked = Model\Contact::isBlockedByUser($contact_id, local_user()); - info(($blocked ? DI::l10n()->t('Contact has been blocked') : DI::l10n()->t('Contact has been unblocked')) . EOL); + $blocked = Model\Contact\User::isBlocked($contact_id, local_user()); + info(($blocked ? DI::l10n()->t('Contact has been blocked') : DI::l10n()->t('Contact has been unblocked'))); DI::baseUrl()->redirect('contact/' . $contact_id); // NOTREACHED @@ -410,8 +425,8 @@ class Contact extends BaseModule if ($cmd === 'ignore') { self::ignoreContact($contact_id); - $ignored = Model\Contact::isIgnoredByUser($contact_id, local_user()); - info(($ignored ? DI::l10n()->t('Contact has been ignored') : DI::l10n()->t('Contact has been unignored')) . EOL); + $ignored = Model\Contact\User::isIgnored($contact_id, local_user()); + info(($ignored ? DI::l10n()->t('Contact has been ignored') : DI::l10n()->t('Contact has been unignored'))); DI::baseUrl()->redirect('contact/' . $contact_id); // NOTREACHED @@ -421,7 +436,7 @@ class Contact extends BaseModule $r = self::archiveContact($contact_id, $orig_record); if ($r) { $archived = (($orig_record['archive']) ? 0 : 1); - info((($archived) ? DI::l10n()->t('Contact has been archived') : DI::l10n()->t('Contact has been unarchived')) . EOL); + info((($archived) ? DI::l10n()->t('Contact has been archived') : DI::l10n()->t('Contact has been unarchived'))); } DI::baseUrl()->redirect('contact/' . $contact_id); @@ -431,17 +446,6 @@ class Contact extends BaseModule if ($cmd === 'drop' && ($orig_record['uid'] != 0)) { // Check if we should do HTML-based delete confirmation if (!empty($_REQUEST['confirm'])) { - // can't take arguments in its 'action' parameter - // so add any arguments as hidden inputs - $query = explode_querystring(DI::args()->getQueryString()); - $inputs = []; - foreach ($query['args'] as $arg) { - if (strpos($arg, 'confirm=') === false) { - $arg_parts = explode('=', $arg); - $inputs[] = ['name' => $arg_parts[0], 'value' => $arg_parts[1]]; - } - } - DI::page()['aside'] = ''; return Renderer::replaceMacros(Renderer::getMarkupTemplate('contact_drop_confirm.tpl'), [ @@ -449,9 +453,8 @@ class Contact extends BaseModule '$contact' => self::getContactTemplateVars($orig_record), '$method' => 'get', '$message' => DI::l10n()->t('Do you really want to delete this contact?'), - '$extra_inputs' => $inputs, '$confirm' => DI::l10n()->t('Yes'), - '$confirm_url' => $query['base'], + '$confirm_url' => DI::args()->getCommand(), '$confirm_name' => 'confirmed', '$cancel' => DI::l10n()->t('Cancel'), ]); @@ -462,7 +465,7 @@ class Contact extends BaseModule } self::dropContact($orig_record); - info(DI::l10n()->t('Contact has been removed.') . EOL); + info(DI::l10n()->t('Contact has been removed.')); DI::baseUrl()->redirect('contact'); // NOTREACHED @@ -484,24 +487,20 @@ class Contact extends BaseModule '$baseurl' => DI::baseUrl()->get(true), ]); - $contact['blocked'] = Model\Contact::isBlockedByUser($contact['id'], local_user()); - $contact['readonly'] = Model\Contact::isIgnoredByUser($contact['id'], local_user()); + $contact['blocked'] = Model\Contact\User::isBlocked($contact['id'], local_user()); + $contact['readonly'] = Model\Contact\User::isIgnored($contact['id'], local_user()); - $dir_icon = ''; $relation_text = ''; switch ($contact['rel']) { case Model\Contact::FRIEND: - $dir_icon = 'images/lrarrow.gif'; $relation_text = DI::l10n()->t('You are mutual friends with %s'); break; case Model\Contact::FOLLOWER; - $dir_icon = 'images/larrow.gif'; $relation_text = DI::l10n()->t('You are sharing with %s'); break; case Model\Contact::SHARING; - $dir_icon = 'images/rarrow.gif'; $relation_text = DI::l10n()->t('%s is sharing with you'); break; @@ -531,7 +530,7 @@ class Contact extends BaseModule $last_update = (($contact['last-update'] <= DBA::NULL_DATETIME) ? DI::l10n()->t('Never') : DateTimeFormat::local($contact['last-update'], 'D, j M Y, g:i A')); if ($contact['last-update'] > DBA::NULL_DATETIME) { - $last_update .= ' ' . (($contact['last-update'] <= $contact['success_update']) ? DI::l10n()->t('(Update was successful)') : DI::l10n()->t('(Update was not successful)')); + $last_update .= ' ' . ($contact['failed'] ? DI::l10n()->t('(Update was not successful)') : DI::l10n()->t('(Update was successful)')); } $lblsuggest = (($contact['network'] === Protocol::DFRN) ? DI::l10n()->t('Suggest friends') : ''); @@ -540,7 +539,7 @@ class Contact extends BaseModule $nettype = DI::l10n()->t('Network type: %s', ContactSelector::networkToName($contact['network'], $contact['url'], $contact['protocol'])); // tabs - $tab_str = self::getTabsHTML($a, $contact, 3); + $tab_str = self::getTabsHTML($contact, self::TAB_PROFILE); $lost_contact = (($contact['archive'] && $contact['term-date'] > DBA::NULL_DATETIME && $contact['term-date'] < DateTimeFormat::utcNow()) ? DI::l10n()->t('Communications lost with this contact!') : ''); @@ -560,8 +559,30 @@ class Contact extends BaseModule ]; } + // Disable remote self for everything except feeds. + // There is an issue when you repeat an item from maybe twitter and you got comments from friendica and twitter + // Problem is, you couldn't reply to both networks. + $allow_remote_self = in_array($contact['network'], [Protocol::ACTIVITYPUB, Protocol::FEED, Protocol::DFRN, Protocol::DIASPORA, Protocol::TWITTER]) + && DI::config()->get('system', 'allow_users_remote_self'); + + if ($contact['network'] == Protocol::FEED) { + $remote_self_options = [Model\Contact::MIRROR_DEACTIVATED => DI::l10n()->t('No mirroring'), + Model\Contact::MIRROR_FORWARDED => DI::l10n()->t('Mirror as forwarded posting'), + Model\Contact::MIRROR_OWN_POST => DI::l10n()->t('Mirror as my own posting')]; + } elseif (in_array($contact['network'], [Protocol::ACTIVITYPUB])) { + $remote_self_options = [Model\Contact::MIRROR_DEACTIVATED => DI::l10n()->t('No mirroring'), + Model\Contact::MIRROR_NATIVE_RESHARE => DI::l10n()->t('Native reshare')]; + } elseif (in_array($contact['network'], [Protocol::DFRN])) { + $remote_self_options = [Model\Contact::MIRROR_DEACTIVATED => DI::l10n()->t('No mirroring'), + Model\Contact::MIRROR_OWN_POST => DI::l10n()->t('Mirror as my own posting'), + Model\Contact::MIRROR_NATIVE_RESHARE => DI::l10n()->t('Native reshare')]; + } else { + $remote_self_options = [Model\Contact::MIRROR_DEACTIVATED => DI::l10n()->t('No mirroring'), + Model\Contact::MIRROR_OWN_POST => DI::l10n()->t('Mirror as my own posting')]; + } + $poll_interval = null; - if (in_array($contact['network'], [Protocol::FEED, Protocol::MAIL])) { + if ((($contact['network'] == Protocol::FEED) && !DI::config()->get('system', 'adjust_poll_frequency')) || ($contact['network']== Protocol::MAIL)) { $poll_interval = ContactSelector::pollInterval($contact['priority'], !$poll_enabled); } @@ -585,7 +606,7 @@ class Contact extends BaseModule '$lbl_info2' => DI::l10n()->t('Their personal note'), '$reason' => trim(Strings::escapeTags($contact['reason'])), '$infedit' => DI::l10n()->t('Edit contact notes'), - '$common_link' => 'common/loc/' . local_user() . '/' . $contact['id'], + '$common_link' => 'contact/' . $contact['id'] . '/contacts/common', '$relation_text' => $relation_text, '$visit' => DI::l10n()->t('Visit %s\'s profile [%s]', $contact['name'], $contact['url']), '$blockunblock' => DI::l10n()->t('Block/Unblock contact'), @@ -613,10 +634,9 @@ class Contact extends BaseModule '$hidden' => ['hidden', DI::l10n()->t('Hide this contact from others'), ($contact['hidden'] == 1), DI::l10n()->t('Replies/likes to your public posts may still be visible')], '$notify' => ['notify', DI::l10n()->t('Notification for new posts'), ($contact['notify_new_posts'] == 1), DI::l10n()->t('Send a notification of every new post of this contact')], '$fetch_further_information' => $fetch_further_information, - '$ffi_keyword_blacklist' => ['ffi_keyword_blacklist', DI::l10n()->t('Blacklisted keywords'), $contact['ffi_keyword_blacklist'], DI::l10n()->t('Comma separated list of keywords that should not be converted to hashtags, when "Fetch information and keywords" is selected')], - '$photo' => $contact['photo'], + '$ffi_keyword_denylist' => ['ffi_keyword_denylist', DI::l10n()->t('Keyword Deny List'), $contact['ffi_keyword_denylist'], DI::l10n()->t('Comma separated list of keywords that should not be converted to hashtags, when "Fetch information and keywords" is selected')], + '$photo' => Model\Contact::getPhoto($contact), '$name' => $contact['name'], - '$dir_icon' => $dir_icon, '$sparkle' => $sparkle, '$url' => $url, '$profileurllabel'=> DI::l10n()->t('Profile URL'), @@ -635,6 +655,13 @@ class Contact extends BaseModule '$contact_status' => DI::l10n()->t('Status'), '$contact_settings_label' => $contact_settings_label, '$contact_profile_label' => DI::l10n()->t('Profile'), + '$allow_remote_self' => $allow_remote_self, + '$remote_self' => ['remote_self', + DI::l10n()->t('Mirror postings from this contact'), + $contact['remote_self'], + DI::l10n()->t('Mark this contact as remote_self, this will cause friendica to repost new entries from this contact.'), + $remote_self_options + ], ]); $arr = ['contact' => $contact, 'output' => $o]; @@ -667,7 +694,7 @@ class Contact extends BaseModule $sql_extra = " AND `archive` AND NOT `blocked` AND NOT `pending`"; break; case 'pending': - $sql_extra = " AND `pending` AND NOT `archive` AND ((`rel` = ?) + $sql_extra = " AND `pending` AND NOT `archive` AND NOT `failed` AND ((`rel` = ?) OR EXISTS (SELECT `id` FROM `intro` WHERE `contact-id` = `contact`.`id` AND NOT `ignore`))"; $sql_values[] = Model\Contact::SHARING; break; @@ -676,6 +703,11 @@ class Contact extends BaseModule break; } + if (isset($accounttypeid)) { + $sql_extra .= " AND `contact-type` = ?"; + $sql_values[] = $accounttypeid; + } + $searching = false; $search_hdr = null; if ($search) { @@ -715,15 +747,14 @@ class Contact extends BaseModule $sql_values[] = $group; } - $sql_extra .= Widget::unavailableNetworks(); - $total = 0; $stmt = DBA::p("SELECT COUNT(*) AS `total` FROM `contact` WHERE `uid` = ? AND `self` = 0 AND NOT `deleted` - $sql_extra", + $sql_extra + " . Widget::unavailableNetworks(), $sql_values ); if (DBA::isResult($stmt)) { @@ -749,8 +780,8 @@ class Contact extends BaseModule $sql_values ); while ($contact = DBA::fetch($stmt)) { - $contact['blocked'] = Model\Contact::isBlockedByUser($contact['id'], local_user()); - $contact['readonly'] = Model\Contact::isIgnoredByUser($contact['id'], local_user()); + $contact['blocked'] = Model\Contact\User::isBlocked($contact['id'], local_user()); + $contact['readonly'] = Model\Contact\User::isIgnored($contact['id'], local_user()); $contacts[] = self::getContactTemplateVars($contact); } DBA::close($stmt); @@ -866,70 +897,62 @@ class Contact extends BaseModule * * Available Pages are 'Status', 'Profile', 'Contacts' and 'Common Friends' * - * @param App $a * @param array $contact The contact array * @param int $active_tab 1 if tab should be marked as active * * @return string HTML string of the contact page tabs buttons. * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException */ - public static function getTabsHTML($a, $contact, $active_tab) + public static function getTabsHTML(array $contact, int $active_tab) { + $cid = $pcid = $contact['id']; + $data = Model\Contact::getPublicAndUserContacID($contact['id'], local_user()); + if (!empty($data['user']) && ($contact['id'] == $data['public'])) { + $cid = $data['user']; + } elseif (!empty($data['public'])) { + $pcid = $data['public']; + } + // tabs $tabs = [ [ 'label' => DI::l10n()->t('Status'), - 'url' => "contact/" . $contact['id'] . "/conversations", - 'sel' => (($active_tab == 1) ? 'active' : ''), + 'url' => 'contact/' . $pcid . '/conversations', + 'sel' => (($active_tab == self::TAB_CONVERSATIONS) ? 'active' : ''), 'title' => DI::l10n()->t('Conversations started by this contact'), 'id' => 'status-tab', 'accesskey' => 'm', ], [ 'label' => DI::l10n()->t('Posts and Comments'), - 'url' => "contact/" . $contact['id'] . "/posts", - 'sel' => (($active_tab == 2) ? 'active' : ''), + 'url' => 'contact/' . $pcid . '/posts', + 'sel' => (($active_tab == self::TAB_POSTS) ? 'active' : ''), 'title' => DI::l10n()->t('Status Messages and Posts'), 'id' => 'posts-tab', 'accesskey' => 'p', ], [ 'label' => DI::l10n()->t('Profile'), - 'url' => "contact/" . $contact['id'], - 'sel' => (($active_tab == 3) ? 'active' : ''), + 'url' => 'contact/' . $cid, + 'sel' => (($active_tab == self::TAB_PROFILE) ? 'active' : ''), 'title' => DI::l10n()->t('Profile Details'), 'id' => 'profile-tab', 'accesskey' => 'o', - ] + ], + ['label' => DI::l10n()->t('Contacts'), + 'url' => 'contact/' . $pcid . '/contacts', + 'sel' => (($active_tab == self::TAB_CONTACTS) ? 'active' : ''), + 'title' => DI::l10n()->t('View all known contacts'), + 'id' => 'contacts-tab', + 'accesskey' => 't' + ], ]; - // Show this tab only if there is visible friend list - $x = Model\GContact::countAllFriends(local_user(), $contact['id']); - if ($x) { - $tabs[] = ['label' => DI::l10n()->t('Contacts'), - 'url' => "allfriends/" . $contact['id'], - 'sel' => (($active_tab == 4) ? 'active' : ''), - 'title' => DI::l10n()->t('View all contacts'), - 'id' => 'allfriends-tab', - 'accesskey' => 't']; - } - - // Show this tab only if there is visible common friend list - $common = Model\GContact::countCommonFriends(local_user(), $contact['id']); - if ($common) { - $tabs[] = ['label' => DI::l10n()->t('Common Friends'), - 'url' => "common/loc/" . local_user() . "/" . $contact['id'], - 'sel' => (($active_tab == 5) ? 'active' : ''), - 'title' => DI::l10n()->t('View all common friends'), - 'id' => 'common-loc-tab', - 'accesskey' => 'd' - ]; - } - - if (!empty($contact['uid'])) { + if (!empty($contact['network']) && in_array($contact['network'], [Protocol::FEED, Protocol::MAIL]) && ($cid != $pcid)) { $tabs[] = ['label' => DI::l10n()->t('Advanced'), - 'url' => 'contact/' . $contact['id'] . '/advanced/', - 'sel' => (($active_tab == 6) ? 'active' : ''), + 'url' => 'contact/' . $cid . '/advanced/', + 'sel' => (($active_tab == self::TAB_ADVANCED) ? 'active' : ''), 'title' => DI::l10n()->t('Advanced Contact Settings'), 'id' => 'advanced-tab', 'accesskey' => 'r' @@ -942,7 +965,7 @@ class Contact extends BaseModule return $tab_str; } - private static function getConversationsHMTL($a, $contact_id, $update) + public static function getConversationsHMTL($a, $contact_id, $update, $parent = 0) { $o = ''; @@ -967,16 +990,22 @@ class Contact extends BaseModule $contact = DBA::selectFirst('contact', ['uid', 'url', 'id'], ['id' => $contact_id, 'deleted' => false]); if (!$update) { - $o .= self::getTabsHTML($a, $contact, 1); + $o .= self::getTabsHTML($contact, self::TAB_CONVERSATIONS); } if (DBA::isResult($contact)) { DI::page()['aside'] = ''; - $profiledata = Model\Contact::getDetailsByURL($contact['url']); + if (!$update) { + $profiledata = Model\Contact::getByURLForUser($contact['url'], local_user()); + Model\Profile::load($a, '', $profiledata, true); + } - Model\Profile::load($a, '', $profiledata, true); - $o .= Model\Contact::getPostsFromUrl($contact['url'], true, $update); + if ($contact['uid'] == 0) { + $o .= Model\Contact::getPostsFromId($contact['id'], true, $update, $parent); + } else { + $o .= Model\Contact::getPostsFromUrl($contact['url'], true, $update, $parent); + } } return $o; @@ -986,43 +1015,59 @@ class Contact extends BaseModule { $contact = DBA::selectFirst('contact', ['uid', 'url', 'id'], ['id' => $contact_id, 'deleted' => false]); - $o = self::getTabsHTML($a, $contact, 2); + $o = self::getTabsHTML($contact, self::TAB_POSTS); if (DBA::isResult($contact)) { DI::page()['aside'] = ''; - $profiledata = Model\Contact::getDetailsByURL($contact['url']); + $profiledata = Model\Contact::getByURLForUser($contact['url'], local_user()); if (local_user() && in_array($profiledata['network'], Protocol::FEDERATED)) { $profiledata['remoteconnect'] = DI::baseUrl() . '/follow?url=' . urlencode($profiledata['url']); } Model\Profile::load($a, '', $profiledata, true); - $o .= Model\Contact::getPostsFromUrl($contact['url']); + + if ($contact['uid'] == 0) { + $o .= Model\Contact::getPostsFromId($contact['id']); + } else { + $o .= Model\Contact::getPostsFromUrl($contact['url']); + } } return $o; } - public static function getContactTemplateVars(array $rr) + /** + * Return the fields for the contact template + * + * @param array $contact Contact array + * @return array Template fields + */ + public static function getContactTemplateVars(array $contact) { - $dir_icon = ''; $alt_text = ''; - if (!empty($rr['uid']) && !empty($rr['rel'])) { - switch ($rr['rel']) { + if (!empty($contact['url']) && isset($contact['uid']) && ($contact['uid'] == 0) && local_user()) { + $personal = Model\Contact::getByURL($contact['url'], false, ['uid', 'rel', 'self'], local_user()); + if (!empty($personal)) { + $contact['uid'] = $personal['uid']; + $contact['rel'] = $personal['rel']; + $contact['self'] = $personal['self']; + } + } + + if (!empty($contact['uid']) && !empty($contact['rel']) && local_user() == $contact['uid']) { + switch ($contact['rel']) { case Model\Contact::FRIEND: - $dir_icon = 'images/lrarrow.gif'; $alt_text = DI::l10n()->t('Mutual Friendship'); break; case Model\Contact::FOLLOWER; - $dir_icon = 'images/larrow.gif'; $alt_text = DI::l10n()->t('is a fan of yours'); break; case Model\Contact::SHARING; - $dir_icon = 'images/rarrow.gif'; $alt_text = DI::l10n()->t('you are a fan of'); break; @@ -1031,7 +1076,7 @@ class Contact extends BaseModule } } - $url = Model\Contact::magicLink($rr['url']); + $url = Model\Contact::magicLink($contact['url']); if (strpos($url, 'redir/') === 0) { $sparkle = ' class="sparkle" '; @@ -1039,37 +1084,36 @@ class Contact extends BaseModule $sparkle = ''; } - if ($rr['pending']) { - if (in_array($rr['rel'], [Model\Contact::FRIEND, Model\Contact::SHARING])) { + if ($contact['pending']) { + if (in_array($contact['rel'], [Model\Contact::FRIEND, Model\Contact::SHARING])) { $alt_text = DI::l10n()->t('Pending outgoing contact request'); } else { $alt_text = DI::l10n()->t('Pending incoming contact request'); } } - if ($rr['self']) { - $dir_icon = 'images/larrow.gif'; + if ($contact['self']) { $alt_text = DI::l10n()->t('This is you'); - $url = $rr['url']; + $url = $contact['url']; $sparkle = ''; } return [ - 'img_hover' => DI::l10n()->t('Visit %s\'s profile [%s]', $rr['name'], $rr['url']), - 'edit_hover'=> DI::l10n()->t('Edit contact'), - 'photo_menu'=> Model\Contact::photoMenu($rr), - 'id' => $rr['id'], - 'alt_text' => $alt_text, - 'dir_icon' => $dir_icon, - 'thumb' => ProxyUtils::proxifyUrl($rr['thumb'], false, ProxyUtils::SIZE_THUMB), - 'name' => $rr['name'], - 'username' => $rr['name'], - 'account_type' => Model\Contact::getAccountType($rr), - 'sparkle' => $sparkle, - 'itemurl' => ($rr['addr'] ?? '') ?: $rr['url'], - 'url' => $url, - 'network' => ContactSelector::networkToName($rr['network'], $rr['url'], $rr['protocol']), - 'nick' => $rr['nick'], + 'id' => $contact['id'], + 'url' => $url, + 'img_hover' => DI::l10n()->t('Visit %s\'s profile [%s]', $contact['name'], $contact['url']), + 'photo_menu' => Model\Contact::photoMenu($contact), + 'thumb' => Model\Contact::getThumb($contact), + 'alt_text' => $alt_text, + 'name' => $contact['name'], + 'nick' => $contact['nick'], + 'details' => $contact['location'], + 'tags' => $contact['keywords'], + 'about' => $contact['about'], + 'account_type' => Model\Contact::getAccountType($contact), + 'sparkle' => $sparkle, + 'itemurl' => ($contact['addr'] ?? '') ?: $contact['url'], + 'network' => ContactSelector::networkToName($contact['network'], $contact['url'], $contact['protocol']), ]; } @@ -1107,6 +1151,16 @@ class Contact extends BaseModule ]; } + if (in_array($contact['network'], Protocol::NATIVE_SUPPORT)) { + $contact_actions['updateprofile'] = [ + 'label' => DI::l10n()->t('Refetch contact data'), + 'url' => 'contact/' . $contact['id'] . '/updateprofile', + 'title' => '', + 'sel' => '', + 'id' => 'updateprofile', + ]; + } + $contact_actions['block'] = [ 'label' => (intval($contact['blocked']) ? DI::l10n()->t('Unblock') : DI::l10n()->t('Block')), 'url' => 'contact/' . $contact['id'] . '/block', diff --git a/src/Module/Contact/Advanced.php b/src/Module/Contact/Advanced.php index cc9fdcf3d2..b37a9affc9 100644 --- a/src/Module/Contact/Advanced.php +++ b/src/Module/Contact/Advanced.php @@ -63,7 +63,6 @@ class Advanced extends BaseModule $poll = $_POST['poll'] ?? ''; $attag = $_POST['attag'] ?? ''; $photo = $_POST['photo'] ?? ''; - $remote_self = $_POST['remote_self'] ?? false; $nurl = Strings::normaliseLink($url); $r = DI::dba()->update( @@ -79,7 +78,6 @@ class Advanced extends BaseModule 'notify' => $notify, 'poll' => $poll, 'attag' => $attag, - 'remote_self' => $remote_self, ], ['id' => $contact['id'], 'uid' => local_user()] ); @@ -87,13 +85,11 @@ class Advanced extends BaseModule if ($photo) { DI::logger()->notice('Updating photo.', ['photo' => $photo]); - Model\Contact::updateAvatar($photo, local_user(), $contact['id'], true); + Model\Contact::updateAvatar($contact['id'], $photo, true); } - if ($r) { - info(DI::l10n()->t('Contact settings applied.') . EOL); - } else { - notice(DI::l10n()->t('Contact update failed.') . EOL); + if (!$r) { + notice(DI::l10n()->t('Contact update failed.')); } return; @@ -108,26 +104,22 @@ class Advanced extends BaseModule throw new BadRequestException(DI::l10n()->t('Contact not found.')); } - Model\Profile::load(DI::app(), "", Model\Contact::getDetailsByURL($contact["url"])); + Model\Profile::load(DI::app(), "", Model\Contact::getByURL($contact["url"], false)); $warning = DI::l10n()->t('WARNING: This is highly advanced and if you enter incorrect information your communications with this contact may stop working.'); $info = DI::l10n()->t('Please use your browser \'Back\' button now if you are uncertain what to do on this page.'); $returnaddr = "contact/$cid"; - // Disable remote self for everything except feeds. - // There is an issue when you repeat an item from maybe twitter and you got comments from friendica and twitter - // Problem is, you couldn't reply to both networks. - $allow_remote_self = in_array($contact['network'], [Protocol::FEED, Protocol::DFRN, Protocol::DIASPORA, Protocol::TWITTER]) - && DI::config()->get('system', 'allow_users_remote_self'); - - if ($contact['network'] == Protocol::FEED) { - $remote_self_options = ['0' => DI::l10n()->t('No mirroring'), '1' => DI::l10n()->t('Mirror as forwarded posting'), '2' => DI::l10n()->t('Mirror as my own posting')]; + // This data is fetched automatically for most networks. + // Editing does only makes sense for mail and feed contacts. + if (!in_array($contact['network'], [Protocol::FEED, Protocol::MAIL])) { + $readonly = 'readonly'; } else { - $remote_self_options = ['0' => DI::l10n()->t('No mirroring'), '2' => DI::l10n()->t('Mirror as my own posting')]; + $readonly = ''; } - $tab_str = Contact::getTabsHTML(DI::app(), $contact, 6); + $tab_str = Contact::getTabsHTML($contact, Contact::TAB_ADVANCED); $tpl = Renderer::getMarkupTemplate('contact/advanced.tpl'); return Renderer::replaceMacros($tpl, [ @@ -136,29 +128,19 @@ class Advanced extends BaseModule '$info' => $info, '$returnaddr' => $returnaddr, '$return' => DI::l10n()->t('Return to contact editor'), - '$update_profile' => in_array($contact['network'], Protocol::FEDERATED), - '$udprofilenow' => DI::l10n()->t('Refetch contact data'), '$contact_id' => $contact['id'], '$lbl_submit' => DI::l10n()->t('Submit'), - '$label_remote_self' => DI::l10n()->t('Remote Self'), - '$allow_remote_self' => $allow_remote_self, - '$remote_self' => ['remote_self', - DI::l10n()->t('Mirror postings from this contact'), - $contact['remote_self'], - DI::l10n()->t('Mark this contact as remote_self, this will cause friendica to repost new entries from this contact.'), - $remote_self_options - ], - '$name' => ['name', DI::l10n()->t('Name'), $contact['name']], - '$nick' => ['nick', DI::l10n()->t('Account Nickname'), $contact['nick']], + '$name' => ['name', DI::l10n()->t('Name'), $contact['name'], '', '', $readonly], + '$nick' => ['nick', DI::l10n()->t('Account Nickname'), $contact['nick'], '', '', $readonly], '$attag' => ['attag', DI::l10n()->t('@Tagname - overrides Name/Nickname'), $contact['attag']], - '$url' => ['url', DI::l10n()->t('Account URL'), $contact['url']], - '$alias' => ['alias', DI::l10n()->t('Account URL Alias'), $contact['alias']], - '$request' => ['request', DI::l10n()->t('Friend Request URL'), $contact['request']], - 'confirm' => ['confirm', DI::l10n()->t('Friend Confirm URL'), $contact['confirm']], - 'notify' => ['notify', DI::l10n()->t('Notification Endpoint URL'), $contact['notify']], - 'poll' => ['poll', DI::l10n()->t('Poll/Feed URL'), $contact['poll']], - 'photo' => ['photo', DI::l10n()->t('New photo from this URL'), ''], + '$url' => ['url', DI::l10n()->t('Account URL'), $contact['url'], '', '', $readonly], + '$alias' => ['alias', DI::l10n()->t('Account URL Alias'), $contact['alias'], '', '', $readonly], + '$request' => ['request', DI::l10n()->t('Friend Request URL'), $contact['request'], '', '', $readonly], + 'confirm' => ['confirm', DI::l10n()->t('Friend Confirm URL'), $contact['confirm'], '', '', $readonly], + 'notify' => ['notify', DI::l10n()->t('Notification Endpoint URL'), $contact['notify'], '', '', $readonly], + 'poll' => ['poll', DI::l10n()->t('Poll/Feed URL'), $contact['poll'], '', '', $readonly], + 'photo' => ['photo', DI::l10n()->t('New photo from this URL'), '', '', '', $readonly], ]); } } diff --git a/src/Module/Contact/Contacts.php b/src/Module/Contact/Contacts.php new file mode 100644 index 0000000000..e38d7acfce --- /dev/null +++ b/src/Module/Contact/Contacts.php @@ -0,0 +1,129 @@ +t('Invalid contact.')); + } + + $contact = Model\Contact::getById($cid, []); + if (empty($contact)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('Contact not found.')); + } + + $localContactId = Model\Contact::getPublicIdByUserId(local_user()); + + Model\Profile::load($app, '', $contact); + + $condition = [ + 'blocked' => false, + 'self' => false, + 'hidden' => false, + 'failed' => false, + ]; + + if (isset($accounttypeid)) { + $condition['contact-type'] = $accounttypeid; + } + + $noresult_label = DI::l10n()->t('No known contacts.'); + + switch ($type) { + case 'followers': + $total = Model\Contact\Relation::countFollowers($cid, $condition); + break; + case 'following': + $total = Model\Contact\Relation::countFollows($cid, $condition); + break; + case 'mutuals': + $total = Model\Contact\Relation::countMutuals($cid, $condition); + break; + case 'common': + $total = Model\Contact\Relation::countCommon($localContactId, $cid, $condition); + $noresult_label = DI::l10n()->t('No common contacts.'); + break; + default: + $total = Model\Contact\Relation::countAll($cid, $condition); + } + + $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), 30); + $desc = ''; + + switch ($type) { + case 'followers': + $friends = Model\Contact\Relation::listFollowers($cid, $condition, $pager->getItemsPerPage(), $pager->getStart()); + $title = DI::l10n()->tt('Follower (%s)', 'Followers (%s)', $total); + break; + case 'following': + $friends = Model\Contact\Relation::listFollows($cid, $condition, $pager->getItemsPerPage(), $pager->getStart()); + $title = DI::l10n()->tt('Following (%s)', 'Following (%s)', $total); + break; + case 'mutuals': + $friends = Model\Contact\Relation::listMutuals($cid, $condition, $pager->getItemsPerPage(), $pager->getStart()); + $title = DI::l10n()->tt('Mutual friend (%s)', 'Mutual friends (%s)', $total); + $desc = DI::l10n()->t( + 'These contacts both follow and are followed by %s.', + htmlentities($contact['name'], ENT_COMPAT, 'UTF-8') + ); + break; + case 'common': + $friends = Model\Contact\Relation::listCommon($localContactId, $cid, $condition, $pager->getItemsPerPage(), $pager->getStart()); + $title = DI::l10n()->tt('Common contact (%s)', 'Common contacts (%s)', $total); + $desc = DI::l10n()->t( + 'Both %s and yourself have publicly interacted with these contacts (follow, comment or likes on public posts).', + htmlentities($contact['name'], ENT_COMPAT, 'UTF-8') + ); + break; + default: + $friends = Model\Contact\Relation::listAll($cid, $condition, $pager->getItemsPerPage(), $pager->getStart()); + $title = DI::l10n()->tt('Contact (%s)', 'Contacts (%s)', $total); + } + + $o = Module\Contact::getTabsHTML($contact, Module\Contact::TAB_CONTACTS); + + $tabs = self::getContactFilterTabs('contact/' . $cid, $type, true); + + $contacts = array_map([Module\Contact::class, 'getContactTemplateVars'], $friends); + + $tpl = Renderer::getMarkupTemplate('profile/contacts.tpl'); + $o .= Renderer::replaceMacros($tpl, [ + '$title' => $title, + '$desc' => $desc, + '$tabs' => $tabs, + + '$noresult_label' => $noresult_label, + + '$contacts' => $contacts, + '$paginate' => $pager->renderFull($total), + ]); + + DI::page()['aside'] .= Widget::accounttypes($_SERVER['REQUEST_URI'], $accounttype); + + return $o; + } +} diff --git a/src/Module/Contact/Hovercard.php b/src/Module/Contact/Hovercard.php index 4ef8162400..c67d6d2476 100644 --- a/src/Module/Contact/Hovercard.php +++ b/src/Module/Contact/Hovercard.php @@ -27,10 +27,8 @@ use Friendica\Core\Session; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; -use Friendica\Model\GContact; use Friendica\Network\HTTPException; use Friendica\Util\Strings; -use Friendica\Util\Proxy; /** * Asynchronous HTML fragment provider for frio contact hovercards @@ -51,6 +49,13 @@ class Hovercard extends BaseModule // the real url (nurl) if (strpos($contact_url, 'redir/') === 0) { $cid = intval(substr($contact_url, 6)); + } + + if (strpos($contact_url, 'contact/') === 0) { + $cid = intval(substr($contact_url, 8)); + } + + if (!empty($cid)) { $remote_contact = Contact::selectFirst(['nurl'], ['id' => $cid]); $contact_url = $remote_contact['nurl'] ?? ''; } @@ -58,31 +63,16 @@ class Hovercard extends BaseModule $contact = []; // if it's the url containing https it should be converted to http - $contact_nurl = Strings::normaliseLink(GContact::cleanContactUrl($contact_url)); - if (!$contact_nurl) { + if (!$contact_url) { throw new HTTPException\BadRequestException(); } // Search for contact data // Look if the local user has got the contact if (Session::isAuthenticated()) { - $contact = Contact::getDetailsByURL($contact_nurl, local_user()); - } - - // If not then check the global user - if (!count($contact)) { - $contact = Contact::getDetailsByURL($contact_nurl); - } - - // Feeds url could have been destroyed through "cleanContactUrl", so we now use the original url - if (!count($contact) && Session::isAuthenticated()) { - $contact_nurl = Strings::normaliseLink($contact_url); - $contact = Contact::getDetailsByURL($contact_nurl, local_user()); - } - - if (!count($contact)) { - $contact_nurl = Strings::normaliseLink($contact_url); - $contact = Contact::getDetailsByURL($contact_nurl); + $contact = Contact::getByURLForUser($contact_url, local_user()); + } else { + $contact = Contact::getByURL($contact_url, false); } if (!count($contact)) { @@ -103,14 +93,14 @@ class Hovercard extends BaseModule 'name' => $contact['name'], 'nick' => $contact['nick'], 'addr' => $contact['addr'] ?: $contact['url'], - 'thumb' => Proxy::proxifyUrl($contact['thumb'], false, Proxy::SIZE_THUMB), + 'thumb' => Contact::getThumb($contact), 'url' => Contact::magicLink($contact['url']), 'nurl' => $contact['nurl'], 'location' => $contact['location'], 'about' => $contact['about'], 'network_link' => Strings::formatNetworkName($contact['network'], $contact['url']), 'tags' => $contact['keywords'], - 'bd' => $contact['birthday'] <= DBA::NULL_DATE ? '' : $contact['birthday'], + 'bd' => $contact['bd'] <= DBA::NULL_DATE ? '' : $contact['bd'], 'account_type' => Contact::getAccountType($contact), 'actions' => $actions, ], diff --git a/src/Module/Contact/Poke.php b/src/Module/Contact/Poke.php index 9975ac1f28..96c0e3fea5 100644 --- a/src/Module/Contact/Poke.php +++ b/src/Module/Contact/Poke.php @@ -44,14 +44,14 @@ class Poke extends BaseModule Logger::info('verb ' . $verb . ' contact ' . $contact_id); - $contact = DBA::selectFirst('contact', ['id', 'name'], ['id' => $parameters['id'], 'uid' => local_user()]); + $contact = DBA::selectFirst('contact', ['id', 'name', 'url', 'photo'], ['id' => $parameters['id'], 'uid' => local_user()]); if (!DBA::isResult($contact)) { return self::postReturn(false); } $a = DI::app(); - $private = (!empty($_GET['private']) ? intval($_GET['private']) : Model\Item::PUBLIC); + $private = !empty($_POST['private']) ? Model\Item::PRIVATE : Model\Item::PUBLIC; $allow_cid = ($private ? '<' . $contact['id']. '>' : $a->user['allow_cid']); $allow_gid = ($private ? '' : $a->user['allow_gid']); @@ -67,7 +67,6 @@ class Poke extends BaseModule $arr['guid'] = System::createUUID(); $arr['uid'] = $uid; $arr['uri'] = $uri; - $arr['parent-uri'] = $uri; $arr['wall'] = 1; $arr['contact-id'] = $actor['id']; $arr['owner-name'] = $actor['name']; @@ -87,7 +86,7 @@ class Poke extends BaseModule $arr['object-type'] = Activity\ObjectType::PERSON; $arr['origin'] = 1; - $arr['body'] = '[url=' . $actor['url'] . ']' . $actor['name'] . '[/url]' . ' ' . $verbs[$verb][2] . ' ' . '[url=' . $contact['url'] . ']' . $contact['name'] . '[/url]'; + $arr['body'] = '@[url=' . $actor['url'] . ']' . $actor['name'] . '[/url]' . ' ' . $verbs[$verb][2] . ' ' . '@[url=' . $contact['url'] . ']' . $contact['name'] . '[/url]'; $arr['object'] = '' . Activity\ObjectType::PERSON . '' . XML::escape($contact['name']) . '' . XML::escape($contact['url']) . ''; $arr['object'] .= '' . XML::escape('') . "\n"; @@ -110,9 +109,7 @@ class Poke extends BaseModule */ private static function postReturn(bool $success) { - if ($success) { - info(DI::l10n()->t('Poke successfully sent.')); - } else { + if (!$success) { notice(DI::l10n()->t('Error while sending poke, please retry.')); } @@ -138,7 +135,7 @@ class Poke extends BaseModule throw new HTTPException\NotFoundException(); } - Model\Profile::load(DI::app(), '', Model\Contact::getDetailsByURL($contact["url"])); + Model\Profile::load(DI::app(), '', Model\Contact::getByURL($contact["url"], false)); $verbs = []; foreach (DI::l10n()->getPokeVerbs() as $verb => $translations) { diff --git a/src/Module/Conversation/Community.php b/src/Module/Conversation/Community.php index 5637c6f419..a960bf5e0e 100644 --- a/src/Module/Conversation/Community.php +++ b/src/Module/Conversation/Community.php @@ -26,6 +26,8 @@ use Friendica\BaseModule; use Friendica\Content\BoundariesPager; use Friendica\Content\Feature; use Friendica\Content\Nav; +use Friendica\Content\Text\HTML; +use Friendica\Content\Widget; use Friendica\Content\Widget\TrendingTags; use Friendica\Core\ACL; use Friendica\Core\Renderer; @@ -40,67 +42,113 @@ class Community extends BaseModule { protected static $page_style; protected static $content; - protected static $accounttype; + protected static $accountTypeString; + protected static $accountType; protected static $itemsPerPage; - protected static $since_id; + protected static $min_id; protected static $max_id; + protected static $item_id; public static function content(array $parameters = []) { self::parseRequest($parameters); - $tabs = []; - - if ((Session::isAuthenticated() || in_array(self::$page_style, [CP_USERS_AND_GLOBAL, CP_USERS_ON_SERVER])) && empty(DI::config()->get('system', 'singleuser'))) { - $tabs[] = [ - 'label' => DI::l10n()->t('Local Community'), - 'url' => 'community/local', - 'sel' => self::$content == 'local' ? 'active' : '', - 'title' => DI::l10n()->t('Posts from local users on this server'), - 'id' => 'community-local-tab', - 'accesskey' => 'l' - ]; + if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll')) { + $tpl = Renderer::getMarkupTemplate('infinite_scroll_head.tpl'); + $o = Renderer::replaceMacros($tpl, ['$reload_uri' => DI::args()->getQueryString()]); + } else { + $o = ''; } - if (Session::isAuthenticated() || in_array(self::$page_style, [CP_USERS_AND_GLOBAL, CP_GLOBAL_COMMUNITY])) { - $tabs[] = [ - 'label' => DI::l10n()->t('Global Community'), - 'url' => 'community/global', - 'sel' => self::$content == 'global' ? 'active' : '', - 'title' => DI::l10n()->t('Posts from users of the whole federated network'), - 'id' => 'community-global-tab', - 'accesskey' => 'g' - ]; + if (empty($_GET['mode']) || ($_GET['mode'] != 'raw')) { + $tabs = []; + + if ((Session::isAuthenticated() || in_array(self::$page_style, [CP_USERS_AND_GLOBAL, CP_USERS_ON_SERVER])) && empty(DI::config()->get('system', 'singleuser'))) { + $tabs[] = [ + 'label' => DI::l10n()->t('Local Community'), + 'url' => 'community/local', + 'sel' => self::$content == 'local' ? 'active' : '', + 'title' => DI::l10n()->t('Posts from local users on this server'), + 'id' => 'community-local-tab', + 'accesskey' => 'l' + ]; + } + + if (Session::isAuthenticated() || in_array(self::$page_style, [CP_USERS_AND_GLOBAL, CP_GLOBAL_COMMUNITY])) { + $tabs[] = [ + 'label' => DI::l10n()->t('Global Community'), + 'url' => 'community/global', + 'sel' => self::$content == 'global' ? 'active' : '', + 'title' => DI::l10n()->t('Posts from users of the whole federated network'), + 'id' => 'community-global-tab', + 'accesskey' => 'g' + ]; + } + + $tab_tpl = Renderer::getMarkupTemplate('common_tabs.tpl'); + $o .= Renderer::replaceMacros($tab_tpl, ['$tabs' => $tabs]); + + Nav::setSelected('community'); + + DI::page()['aside'] .= Widget::accounttypes('community/' . self::$content, self::$accountTypeString); + + if (local_user() && DI::config()->get('system', 'community_no_sharer')) { + $path = self::$content; + if (!empty($parameters['accounttype'])) { + $path .= '/' . $parameters['accounttype']; + } + $query_parameters = []; + + if (!empty($_GET['min_id'])) { + $query_parameters['min_id'] = $_GET['min_id']; + } + if (!empty($_GET['max_id'])) { + $query_parameters['max_id'] = $_GET['max_id']; + } + if (!empty($_GET['last_commented'])) { + $query_parameters['max_id'] = $_GET['last_commented']; + } + + $path_all = $path . (!empty($query_parameters) ? '?' . http_build_query($query_parameters) : ''); + $path_no_sharer = $path . '?' . http_build_query(array_merge($query_parameters, ['no_sharer' => true])); + DI::page()['aside'] .= Renderer::replaceMacros(Renderer::getMarkupTemplate('widget/community_sharer.tpl'), [ + '$title' => DI::l10n()->t('Own Contacts'), + '$path_all' => $path_all, + '$path_no_sharer' => $path_no_sharer, + '$no_sharer' => !empty($_REQUEST['no_sharer']), + '$all' => DI::l10n()->t('Include'), + '$no_sharer_label' => DI::l10n()->t('Hide'), + ]); + } + + if (Feature::isEnabled(local_user(), 'trending_tags')) { + DI::page()['aside'] .= TrendingTags::getHTML(self::$content); + } + + // We need the editor here to be able to reshare an item. + if (Session::isAuthenticated()) { + $x = [ + 'is_owner' => true, + 'allow_location' => DI::app()->user['allow_location'], + 'default_location' => DI::app()->user['default-location'], + 'nickname' => DI::app()->user['nickname'], + 'lockstate' => (is_array(DI::app()->user) && (strlen(DI::app()->user['allow_cid']) || strlen(DI::app()->user['allow_gid']) || strlen(DI::app()->user['deny_cid']) || strlen(DI::app()->user['deny_gid'])) ? 'lock' : 'unlock'), + 'acl' => ACL::getFullSelectorHTML(DI::page(), DI::app()->user, true), + 'bang' => '', + 'visitor' => 'block', + 'profile_uid' => local_user(), + ]; + $o .= status_editor(DI::app(), $x, 0, true); + } } - $tab_tpl = Renderer::getMarkupTemplate('common_tabs.tpl'); - $o = Renderer::replaceMacros($tab_tpl, ['$tabs' => $tabs]); - - Nav::setSelected('community'); - $items = self::getItems(); if (!DBA::isResult($items)) { - info(DI::l10n()->t('No results.')); + notice(DI::l10n()->t('No results.')); return $o; } - // We need the editor here to be able to reshare an item. - if (Session::isAuthenticated()) { - $x = [ - 'is_owner' => true, - 'allow_location' => DI::app()->user['allow_location'], - 'default_location' => DI::app()->user['default-location'], - 'nickname' => DI::app()->user['nickname'], - 'lockstate' => (is_array(DI::app()->user) && (strlen(DI::app()->user['allow_cid']) || strlen(DI::app()->user['allow_gid']) || strlen(DI::app()->user['deny_cid']) || strlen(DI::app()->user['deny_gid'])) ? 'lock' : 'unlock'), - 'acl' => ACL::getFullSelectorHTML(DI::page(), DI::app()->user, true), - 'bang' => '', - 'visitor' => 'block', - 'profile_uid' => local_user(), - ]; - $o .= status_editor(DI::app(), $x, 0, true); - } - $o .= conversation(DI::app(), $items, 'community', false, false, 'commented', local_user()); $pager = new BoundariesPager( @@ -111,10 +159,10 @@ class Community extends BaseModule self::$itemsPerPage ); - $o .= $pager->renderMinimal(count($items)); - - if (Feature::isEnabled(local_user(), 'trending_tags')) { - DI::page()['aside'] .= TrendingTags::getHTML(self::$content); + if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll')) { + $o .= HTML::scrollLoader(); + } else { + $o .= $pager->renderMinimal(count($items)); } $t = Renderer::getMarkupTemplate("community.tpl"); @@ -145,23 +193,8 @@ class Community extends BaseModule throw new HTTPException\ForbiddenException(DI::l10n()->t('Access denied.')); } - switch ($parameters['accounttype'] ?? '') { - case 'person': - self::$accounttype = User::ACCOUNT_TYPE_PERSON; - break; - case 'organisation': - self::$accounttype = User::ACCOUNT_TYPE_ORGANISATION; - break; - case 'news': - self::$accounttype = User::ACCOUNT_TYPE_NEWS; - break; - case 'community': - self::$accounttype = User::ACCOUNT_TYPE_COMMUNITY; - break; - default: - self::$accounttype = null; - break; - } + self::$accountTypeString = $_GET['accounttype'] ?? $parameters['accounttype'] ?? ''; + self::$accountType = User::getAccountTypeByString(self::$accountTypeString); self::$content = $parameters['content'] ?? ''; if (!self::$content) { @@ -203,14 +236,16 @@ class Community extends BaseModule DI::config()->get('system', 'itemspage_network')); } - // now that we have the user settings, see if the theme forces - // a maximum item number which is lower then the user choice - if ((DI::app()->force_max_items > 0) && (DI::app()->force_max_items < self::$itemsPerPage)) { - self::$itemsPerPage = DI::app()->force_max_items; + if (!empty($_GET['item'])) { + $item = Item::selectFirst(['parent'], ['id' => $_GET['item']]); + self::$item_id = $item['parent'] ?? 0; + } else { + self::$item_id = 0; } - self::$since_id = $_GET['since_id'] ?? null; + self::$min_id = $_GET['min_id'] ?? null; self::$max_id = $_GET['max_id'] ?? null; + self::$max_id = $_GET['last_commented'] ?? self::$max_id; } /** @@ -224,7 +259,7 @@ class Community extends BaseModule */ protected static function getItems() { - $items = self::selectItems(self::$since_id, self::$max_id, self::$itemsPerPage); + $items = self::selectItems(self::$min_id, self::$max_id, self::$item_id, self::$itemsPerPage); $maxpostperauthor = (int) DI::config()->get('system', 'max_author_posts_community_page'); if ($maxpostperauthor != 0 && self::$content == 'local') { @@ -249,14 +284,14 @@ class Community extends BaseModule // If we're looking at a "previous page", the lookup continues forward in time because the list is // sorted in chronologically decreasing order - if (isset(self::$since_id)) { - self::$since_id = $items[0]['commented']; + if (isset(self::$min_id)) { + self::$min_id = $items[0]['commented']; } else { // In any other case, the lookup continues backwards in time self::$max_id = $items[count($items) - 1]['commented']; } - $items = self::selectItems(self::$since_id, self::$max_id, self::$itemsPerPage); + $items = self::selectItems(self::$min_id, self::$max_id, self::$item_id, self::$itemsPerPage); } } else { $selected_items = $items; @@ -268,26 +303,24 @@ class Community extends BaseModule /** * Database query for the comunity page * - * @param $since_id + * @param $min_id * @param $max_id * @param $itemspage * @return array * @throws \Exception * @TODO Move to repository/factory */ - private static function selectItems($since_id, $max_id, $itemspage) + private static function selectItems($min_id, $max_id, $item_id, $itemspage) { - $r = false; - if (self::$content == 'local') { - if (!is_null(self::$accounttype)) { - $condition = ["`wall` AND `origin` AND `private` = ? AND `owner`.`contact-type` = ?", Item::PUBLIC, self::$accounttype]; + if (!is_null(self::$accountType)) { + $condition = ["`wall` AND `origin` AND `private` = ? AND `owner`.`contact-type` = ?", Item::PUBLIC, self::$accountType]; } else { - $condition = ["`wall` AND `origin` AND `private` = ?", Item::PUBLIC]; + $condition = ["`wall` AND `origin` AND `private` = ?", Item::PUBLIC]; } } elseif (self::$content == 'global') { - if (!is_null(self::$accounttype)) { - $condition = ["`uid` = ? AND `private` = ? AND `owner`.`contact-type` = ?", 0, Item::PUBLIC, self::$accounttype]; + if (!is_null(self::$accountType)) { + $condition = ["`uid` = ? AND `private` = ? AND `owner`.`contact-type` = ?", 0, Item::PUBLIC, self::$accountType]; } else { $condition = ["`uid` = ? AND `private` = ?", 0, Item::PUBLIC]; } @@ -295,18 +328,42 @@ class Community extends BaseModule return []; } - if (isset($max_id)) { - $condition[0] .= " AND `commented` < ?"; - $condition[] = $max_id; + $params = ['order' => ['commented' => true], 'limit' => $itemspage]; + + if (!empty($item_id)) { + $condition[0] .= " AND `iid` = ?"; + $condition[] = $item_id; + } else { + if (local_user() && !empty($_REQUEST['no_sharer'])) { + $condition[0] .= " AND NOT EXISTS (SELECT `uri-id` FROM `thread` AS t1 WHERE `t1`.`uri-id` = `thread`.`uri-id` AND `t1`.`uid` = ?)"; + $condition[] = local_user(); + } + + if (isset($max_id)) { + $condition[0] .= " AND `commented` < ?"; + $condition[] = $max_id; + } + + if (isset($min_id)) { + $condition[0] .= " AND `commented` > ?"; + $condition[] = $min_id; + + // Previous page case: we want the items closest to min_id but for that we need to reverse the query order + if (!isset($max_id)) { + $params['order']['commented'] = false; + } + } } - if (isset($since_id)) { - $condition[0] .= " AND `commented` > ?"; - $condition[] = $since_id; + $r = Item::selectThreadForUser(0, ['uri', 'commented', 'author-link'], $condition, $params); + + $items = DBA::toArray($r); + + // Previous page case: once we get the relevant items closest to min_id, we need to restore the expected display order + if (empty($item_id) && isset($min_id) && !isset($max_id)) { + $items = array_reverse($items); } - $r = Item::selectThreadForUser(0, ['uri', 'commented', 'author-link'], $condition, ['order' => ['commented' => true], 'limit' => $itemspage]); - - return DBA::toArray($r); + return $items; } } diff --git a/src/Module/Conversation/Network.php b/src/Module/Conversation/Network.php new file mode 100644 index 0000000000..ca8e8c89c2 --- /dev/null +++ b/src/Module/Conversation/Network.php @@ -0,0 +1,454 @@ +getQueryString()); + DI::page()['aside'] .= Widget::fileAs('filed', null); + + $arr = ['query' => DI::args()->getQueryString()]; + Hook::callAll('network_content_init', $arr); + + $o = ''; + + // Fetch a page full of parent items for this page + $params = ['limit' => self::$itemsPerPage]; + $table = 'network-thread-view'; + + $items = self::getItems($table, $params); + + if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll') && ($_GET['mode'] ?? '') != 'minimal') { + $tpl = Renderer::getMarkupTemplate('infinite_scroll_head.tpl'); + $o .= Renderer::replaceMacros($tpl, ['$reload_uri' => DI::args()->getQueryString()]); + } + + if (!(isset($_GET['mode']) AND ($_GET['mode'] == 'raw'))) { + $o .= self::getTabsHTML(self::$selectedTab); + + Nav::setSelected(DI::args()->get(0)); + + $content = ''; + + if (self::$forumContactId) { + // If self::$forumContactId belongs to a communitity forum or a privat goup,.add a mention to the status editor + $condition = ["`id` = ? AND (`forum` OR `prv`)", self::$forumContactId]; + $contact = DBA::selectFirst('contact', ['addr'], $condition); + if (!empty($contact['addr'])) { + $content = '!' . $contact['addr']; + } + } + + $a = DI::app(); + + $default_permissions = []; + if (self::$groupId) { + $default_permissions['allow_gid'] = [self::$groupId]; + } + + $allowedCids = []; + if (self::$forumContactId) { + $allowedCids[] = (int) self::$forumContactId; + } elseif (self::$network) { + $condition = [ + 'uid' => local_user(), + 'network' => self::$network, + 'self' => false, + 'blocked' => false, + 'pending' => false, + 'archive' => false, + 'rel' => [Contact::SHARING, Contact::FRIEND], + ]; + $contactStmt = DBA::select('contact', ['id'], $condition); + while ($contact = DBA::fetch($contactStmt)) { + $allowedCids[] = (int) $contact['id']; + } + DBA::close($contactStmt); + } + + if (count($allowedCids)) { + $default_permissions['allow_cid'] = $allowedCids; + } + + $x = [ + 'is_owner' => true, + 'allow_location' => $a->user['allow_location'], + 'default_location' => $a->user['default-location'], + 'nickname' => $a->user['nickname'], + 'lockstate' => (self::$groupId || self::$forumContactId || self::$network || (is_array($a->user) && + (strlen($a->user['allow_cid']) || strlen($a->user['allow_gid']) || + strlen($a->user['deny_cid']) || strlen($a->user['deny_gid']))) ? 'lock' : 'unlock'), + 'default_perms' => ACL::getDefaultUserPermissions($a->user), + 'acl' => ACL::getFullSelectorHTML(DI::page(), $a->user, true, $default_permissions), + 'bang' => ((self::$groupId || self::$forumContactId || self::$network) ? '!' : ''), + 'visitor' => 'block', + 'profile_uid' => local_user(), + 'content' => $content, + ]; + + $o .= status_editor($a, $x); + } + + if (self::$groupId) { + $group = DBA::selectFirst('group', ['name'], ['id' => self::$groupId, 'uid' => local_user()]); + if (!DBA::isResult($group)) { + notice(DI::l10n()->t('No such group')); + } + + $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('section_title.tpl'), [ + '$title' => DI::l10n()->t('Group: %s', $group['name']) + ]) . $o; + } elseif (self::$forumContactId) { + $contact = Contact::getById(self::$forumContactId); + if (DBA::isResult($contact)) { + $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('viewcontact_template.tpl'), [ + 'contacts' => [ModuleContact::getContactTemplateVars($contact)], + 'id' => DI::args()->get(0), + ]) . $o; + } else { + notice(DI::l10n()->t('Invalid contact.')); + } + } elseif (!DI::config()->get('theme', 'hide_eventlist')) { + $o .= Profile::getBirthdays(); + $o .= Profile::getEventsReminderHTML(); + } + + if (self::$order === 'received') { + $ordering = '`received`'; + } else { + $ordering = '`commented`'; + } + + $o .= conversation(DI::app(), $items, 'network', false, false, $ordering, local_user()); + + if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll')) { + $o .= HTML::scrollLoader(); + } else { + $pager = new BoundariesPager( + DI::l10n(), + DI::args()->getQueryString(), + $items[0][self::$order] ?? null, + $items[count($items) - 1][self::$order] ?? null, + self::$itemsPerPage + ); + + $o .= $pager->renderMinimal(count($items)); + } + + return $o; + } + + /** + * Sets items as seen + * + * @param array $condition The array with the SQL condition + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + private static function setItemsSeenByCondition(array $condition) + { + if (empty($condition)) { + return; + } + + $unseen = Item::exists($condition); + + if ($unseen) { + /// @todo handle huge "unseen" updates in the background to avoid timeout errors + Item::update(['unseen' => false], $condition); + } + } + + /** + * Get the network tabs menu + * + * @param string $selectedTab + * @return string Html of the network tabs + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + private static function getTabsHTML(string $selectedTab) + { + $cmd = DI::args()->getCommand(); + + // tabs + $tabs = [ + [ + 'label' => DI::l10n()->t('Latest Activity'), + 'url' => $cmd . '?' . http_build_query(['order' => 'commented']), + 'sel' => !$selectedTab || $selectedTab == 'commented' ? 'active' : '', + 'title' => DI::l10n()->t('Sort by latest activity'), + 'id' => 'activity-order-tab', + 'accesskey' => 'e', + ], + [ + 'label' => DI::l10n()->t('Latest Posts'), + 'url' => $cmd . '?' . http_build_query(['order' => 'received']), + 'sel' => $selectedTab == 'received' ? 'active' : '', + 'title' => DI::l10n()->t('Sort by post received date'), + 'id' => 'post-order-tab', + 'accesskey' => 't', + ], + [ + 'label' => DI::l10n()->t('Personal'), + 'url' => $cmd . '?' . http_build_query(['mention' => true]), + 'sel' => $selectedTab == 'mention' ? 'active' : '', + 'title' => DI::l10n()->t('Posts that mention or involve you'), + 'id' => 'personal-tab', + 'accesskey' => 'r', + ], + [ + 'label' => DI::l10n()->t('Starred'), + 'url' => $cmd . '?' . http_build_query(['star' => true]), + 'sel' => $selectedTab == 'star' ? 'active' : '', + 'title' => DI::l10n()->t('Favourite Posts'), + 'id' => 'starred-posts-tab', + 'accesskey' => 'm', + ], + ]; + + $arr = ['tabs' => $tabs]; + Hook::callAll('network_tabs', $arr); + + $tpl = Renderer::getMarkupTemplate('common_tabs.tpl'); + + return Renderer::replaceMacros($tpl, ['$tabs' => $arr['tabs']]); + } + + protected static function parseRequest(array $parameters, array $get) + { + self::$groupId = $parameters['group_id'] ?? 0; + + self::$forumContactId = $parameters['contact_id'] ?? 0; + + self::$selectedTab = Session::get('network-tab', DI::pConfig()->get(local_user(), 'network.view', 'selected_tab', '')); + + self::$order = 'commented'; + + if (!empty($get['star'])) { + self::$selectedTab = 'star'; + self::$star = true; + } else { + self::$star = self::$selectedTab == 'star'; + } + + if (!empty($get['mention'])) { + self::$selectedTab = 'mention'; + self::$mention = true; + } else { + self::$mention = self::$selectedTab == 'mention'; + } + + if (!empty($get['order'])) { + self::$selectedTab = $get['order']; + self::$order = $get['order']; + } elseif (in_array(self::$selectedTab, ['received', 'star', 'mention'])) { + self::$order = 'received'; + } + + self::$selectedTab = self::$selectedTab ?? self::$order; + + Session::set('network-tab', self::$selectedTab); + DI::pConfig()->set(local_user(), 'network.view', 'selected_tab', self::$selectedTab); + + self::$accountTypeString = $get['accounttype'] ?? $parameters['accounttype'] ?? ''; + self::$accountType = User::getAccountTypeByString(self::$accountTypeString); + + self::$network = $get['nets'] ?? ''; + + self::$dateFrom = $parameters['from'] ?? ''; + self::$dateTo = $parameters['to'] ?? ''; + + if (DI::mode()->isMobile()) { + self::$itemsPerPage = DI::pConfig()->get(local_user(), 'system', 'itemspage_mobile_network', + DI::config()->get('system', 'itemspage_network_mobile')); + } else { + self::$itemsPerPage = DI::pConfig()->get(local_user(), 'system', 'itemspage_network', + DI::config()->get('system', 'itemspage_network')); + } + + self::$min_id = $get['min_id'] ?? null; + self::$max_id = $get['max_id'] ?? null; + + switch (self::$order) { + case 'received': + self::$max_id = $get['last_received'] ?? self::$max_id; + break; + case 'created': + self::$max_id = $get['last_created'] ?? self::$max_id; + break; + case 'uriid': + self::$max_id = $get['last_uriid'] ?? self::$max_id; + break; + default: + self::$order = 'commented'; + self::$max_id = $get['last_commented'] ?? self::$max_id; + } + } + + protected static function getItems(string $table, array $params, array $conditionFields = []) + { + $conditionFields['uid'] = local_user(); + $conditionStrings = []; + + if (!is_null(self::$accountType)) { + $conditionFields['contact-type'] = self::$accountType; + } + + if (self::$star) { + $conditionFields['starred'] = true; + } + if (self::$mention) { + $conditionFields['mention'] = true; + } + if (self::$network) { + $conditionFields['network'] = self::$network; + } + + if (self::$dateFrom) { + $conditionStrings = DBA::mergeConditions($conditionStrings, ["`received` <= ? ", DateTimeFormat::convert(self::$dateFrom, 'UTC', date_default_timezone_get())]); + } + if (self::$dateTo) { + $conditionStrings = DBA::mergeConditions($conditionStrings, ["`received` >= ? ", DateTimeFormat::convert(self::$dateTo, 'UTC', date_default_timezone_get())]); + } + + if (self::$groupId) { + $conditionStrings = DBA::mergeConditions($conditionStrings, ["`contact-id` IN (SELECT `contact-id` FROM `group_member` WHERE `gid` = ?)", self::$groupId]); + } elseif (self::$forumContactId) { + $conditionFields['contact-id'] = self::$forumContactId; + } + + // Currently only the order modes "received" and "commented" are in use + if (isset(self::$max_id)) { + switch (self::$order) { + case 'received': + $conditionStrings = DBA::mergeConditions($conditionStrings, ["`received` < ?", self::$max_id]); + break; + case 'commented': + $conditionStrings = DBA::mergeConditions($conditionStrings, ["`commented` < ?", self::$max_id]); + break; + case 'created': + $conditionStrings = DBA::mergeConditions($conditionStrings, ["`created` < ?", self::$max_id]); + break; + case 'uriid': + $conditionStrings = DBA::mergeConditions($conditionStrings, ["`uri-id` < ?", self::$max_id]); + break; + } + } + + if (isset(self::$min_id)) { + switch (self::$order) { + case 'received': + $conditionStrings = DBA::mergeConditions($conditionStrings, ["`received` > ?", self::$min_id]); + break; + case 'commented': + $conditionStrings = DBA::mergeConditions($conditionStrings, ["`commented` > ?", self::$min_id]); + break; + case 'created': + $conditionStrings = DBA::mergeConditions($conditionStrings, ["`created` > ?", self::$min_id]); + break; + case 'uriid': + $conditionStrings = DBA::mergeConditions($conditionStrings, ["`uri-id` > ?", self::$min_id]); + break; + } + } + + if (isset(self::$min_id) && !isset(self::$max_id)) { + // min_id quirk: querying in reverse order with min_id gets the most recent rows, regardless of how close + // they are to min_id. We change the query ordering to get the expected data, and we need to reverse the + // order of the results. + $params['order'] = [self::$order => false]; + } else { + $params['order'] = [self::$order => true]; + } + + $items = DBA::selectToArray($table, [], DBA::mergeConditions($conditionFields, $conditionStrings), $params); + + // min_id quirk, continued + if (isset(self::$min_id) && !isset(self::$max_id)) { + $items = array_reverse($items); + } + + if (DBA::isResult($items)) { + $parents = array_column($items, 'parent'); + } else { + $parents = []; + } + + // We aren't going to try and figure out at the item, group, and page + // level which items you've seen and which you haven't. If you're looking + // at the top level network page just mark everything seen. + if (!self::$groupId && !self::$forumContactId && !self::$star && !self::$mention) { + $condition = ['unseen' => true, 'uid' => local_user()]; + self::setItemsSeenByCondition($condition); + } elseif (!empty($parents)) { + $condition = ['unseen' => true, 'uid' => local_user(), 'parent' => $parents]; + self::setItemsSeenByCondition($condition); + } + + return $items; + } +} diff --git a/src/Module/Debug/ActivityPubConversion.php b/src/Module/Debug/ActivityPubConversion.php new file mode 100644 index 0000000000..87a531d5b4 --- /dev/null +++ b/src/Module/Debug/ActivityPubConversion.php @@ -0,0 +1,144 @@ +. + * + */ + +namespace Friendica\Module\Debug; + +use Friendica\BaseModule; +use Friendica\Content\Text; +use Friendica\Core\Logger; +use Friendica\Core\Renderer; +use Friendica\DI; +use Friendica\Model\Item; +use Friendica\Model\Tag; +use Friendica\Protocol\ActivityPub; +use Friendica\Util\JsonLD; +use Friendica\Util\XML; + +class ActivityPubConversion extends BaseModule +{ + public static function content(array $parameters = []) + { + function visible_whitespace($s) + { + return '
    ' . htmlspecialchars($s) . '
    '; + } + + $results = []; + if (!empty($_REQUEST['source'])) { + try { + $source = json_decode($_REQUEST['source'], true); + $trust_source = true; + $uid = local_user(); + $push = false; + + if (!$source) { + throw new \Exception('Failed to decode source JSON'); + } + + $formatted = json_encode($source, JSON_PRETTY_PRINT); + $results[] = [ + 'title' => DI::l10n()->t('Formatted'), + 'content' => visible_whitespace(trim(var_export($formatted, true), "'")), + ]; + $results[] = [ + 'title' => DI::l10n()->t('Source'), + 'content' => visible_whitespace(var_export($source, true)) + ]; + $activity = JsonLD::compact($source); + if (!$activity) { + throw new \Exception('Failed to compact JSON'); + } + $results[] = [ + 'title' => DI::l10n()->t('Activity'), + 'content' => visible_whitespace(var_export($activity, true)) + ]; + + $type = JsonLD::fetchElement($activity, '@type'); + + if (!$type) { + throw new \Exception('Empty type'); + } + + if (!JsonLD::fetchElement($activity, 'as:object', '@id')) { + throw new \Exception('Empty object'); + } + + if (!JsonLD::fetchElement($activity, 'as:actor', '@id')) { + throw new \Exception('Empty actor'); + } + + // Don't trust the source if "actor" differs from "attributedTo". The content could be forged. + if ($trust_source && ($type == 'as:Create') && is_array($activity['as:object'])) { + $actor = JsonLD::fetchElement($activity, 'as:actor', '@id'); + $attributed_to = JsonLD::fetchElement($activity['as:object'], 'as:attributedTo', '@id'); + $trust_source = ($actor == $attributed_to); + if (!$trust_source) { + throw new \Exception('Not trusting actor: ' . $actor . '. It differs from attributedTo: ' . $attributed_to); + } + } + + // $trust_source is called by reference and is set to true if the content was retrieved successfully + $object_data = ActivityPub\Receiver::prepareObjectData($activity, $uid, $push, $trust_source); + if (empty($object_data)) { + throw new \Exception('No object data found'); + } + + if (!$trust_source) { + throw new \Exception('No trust for activity type "' . $type . '", so we quit now.'); + } + + if (!empty($body) && empty($object_data['raw'])) { + $object_data['raw'] = $body; + } + + // Internal flag for thread completion. See Processor.php + if (!empty($activity['thread-completion'])) { + $object_data['thread-completion'] = $activity['thread-completion']; + } + + $results[] = [ + 'title' => DI::l10n()->t('Object data'), + 'content' => visible_whitespace(var_export($object_data, true)) + ]; + + $item = ActivityPub\Processor::createItem($object_data); + + $results[] = [ + 'title' => DI::l10n()->t('Result Item'), + 'content' => visible_whitespace(var_export($item, true)) + ]; + } catch (\Throwable $e) { + $results[] = [ + 'title' => DI::l10n()->t('Error'), + 'content' => $e->getMessage(), + ]; + } + } + + $tpl = Renderer::getMarkupTemplate('debug/activitypubconversion.tpl'); + $o = Renderer::replaceMacros($tpl, [ + '$source' => ['source', DI::l10n()->t('Source activity'), $_REQUEST['source'] ?? '', ''], + '$results' => $results + ]); + + return $o; + } +} diff --git a/src/Module/Debug/Babel.php b/src/Module/Debug/Babel.php index 80c70f7888..e33f03214f 100644 --- a/src/Module/Debug/Babel.php +++ b/src/Module/Debug/Babel.php @@ -22,10 +22,15 @@ namespace Friendica\Module\Debug; use Friendica\BaseModule; +use Friendica\Content\PageInfo; use Friendica\Content\Text; +use Friendica\Core\Protocol; use Friendica\Core\Renderer; use Friendica\DI; +use Friendica\Model\Conversation; use Friendica\Model\Item; +use Friendica\Protocol\Activity; +use Friendica\Model\Tag; use Friendica\Util\XML; /** @@ -44,7 +49,7 @@ class Babel extends BaseModule if (!empty($_REQUEST['text'])) { switch (($_REQUEST['type'] ?? '') ?: 'bbcode') { case 'bbcode': - $bbcode = trim($_REQUEST['text']); + $bbcode = $_REQUEST['text']; $results[] = [ 'title' => DI::l10n()->t('Source input'), 'content' => visible_whitespace($bbcode) @@ -62,6 +67,11 @@ class Babel extends BaseModule 'content' => visible_whitespace($html) ]; + $results[] = [ + 'title' => DI::l10n()->t('BBCode::convert (hex)'), + 'content' => visible_whitespace(bin2hex($html)), + ]; + $results[] = [ 'title' => DI::l10n()->t('BBCode::convert'), 'content' => $html @@ -101,19 +111,31 @@ class Babel extends BaseModule 'content' => visible_whitespace($bbcode4) ]; - $item = [ - 'body' => $bbcode, - 'tag' => '', - ]; + $tags = Text\BBCode::getTags($bbcode); - Item::setHashtags($item); + $body = Item::setHashtags($bbcode); $results[] = [ 'title' => DI::l10n()->t('Item Body'), - 'content' => visible_whitespace($item['body']) + 'content' => visible_whitespace($body) ]; $results[] = [ 'title' => DI::l10n()->t('Item Tags'), - 'content' => $item['tag'] + 'content' => visible_whitespace(var_export($tags, true)), + ]; + + $body2 = PageInfo::searchAndAppendToBody($bbcode, true); + $results[] = [ + 'title' => DI::l10n()->t('PageInfo::appendToBody'), + 'content' => visible_whitespace($body2) + ]; + $html3 = Text\BBCode::convert($body2); + $results[] = [ + 'title' => DI::l10n()->t('PageInfo::appendToBody => BBCode::convert (raw HTML)'), + 'content' => visible_whitespace($html3) + ]; + $results[] = [ + 'title' => DI::l10n()->t('PageInfo::appendToBody => BBCode::convert'), + 'content' => $html3 ]; break; case 'diaspora': @@ -125,9 +147,7 @@ class Babel extends BaseModule $markdown = XML::unescape($diaspora); case 'markdown': - if (!isset($markdown)) { - $markdown = trim($_REQUEST['text']); - } + $markdown = $markdown ?? trim($_REQUEST['text']); $results[] = [ 'title' => DI::l10n()->t('Source input (Markdown)'), @@ -163,6 +183,25 @@ class Babel extends BaseModule 'content' => $html ]; + $config = \HTMLPurifier_Config::createDefault(); + $HTMLPurifier = new \HTMLPurifier($config); + $purified = $HTMLPurifier->purify($html); + + $results[] = [ + 'title' => DI::l10n()->t('HTML Purified (raw)'), + 'content' => visible_whitespace($purified), + ]; + + $results[] = [ + 'title' => DI::l10n()->t('HTML Purified (hex)'), + 'content' => visible_whitespace(bin2hex($purified)), + ]; + + $results[] = [ + 'title' => DI::l10n()->t('HTML Purified'), + 'content' => $purified, + ]; + $bbcode = Text\HTML::toBBCode($html); $results[] = [ 'title' => DI::l10n()->t('HTML::toBBCode'), @@ -203,6 +242,60 @@ class Babel extends BaseModule 'title' => DI::l10n()->t('HTML::toPlaintext (compact)'), 'content' => visible_whitespace($text), ]; + break; + case 'twitter': + $json = trim($_REQUEST['text']); + + $status = json_decode($json); + + $results[] = [ + 'title' => DI::l10n()->t('Decoded post'), + 'content' => visible_whitespace(var_export($status, true)), + ]; + + $postarray = []; + $postarray['object-type'] = Activity\ObjectType::NOTE; + + if (!empty($status->full_text)) { + $postarray['body'] = $status->full_text; + } else { + $postarray['body'] = $status->text; + } + + // When the post contains links then use the correct object type + if (count($status->entities->urls) > 0) { + $postarray['object-type'] = Activity\ObjectType::BOOKMARK; + } + + if (file_exists('addon/twitter/twitter.php')) { + require_once 'addon/twitter/twitter.php'; + + $picture = \twitter_media_entities($status, $postarray); + + $results[] = [ + 'title' => DI::l10n()->t('Post array before expand entities'), + 'content' => visible_whitespace(var_export($postarray, true)), + ]; + + $converted = \twitter_expand_entities($postarray['body'], $status, $picture); + + $results[] = [ + 'title' => DI::l10n()->t('Post converted'), + 'content' => visible_whitespace(var_export($converted, true)), + ]; + + $results[] = [ + 'title' => DI::l10n()->t('Converted body'), + 'content' => visible_whitespace($converted['body']), + ]; + } else { + $results[] = [ + 'title' => DI::l10n()->t('Error'), + 'content' => DI::l10n()->t('Twitter addon is absent from the addon/ folder.'), + ]; + } + + break; } } @@ -213,6 +306,8 @@ class Babel extends BaseModule '$type_diaspora' => ['type', DI::l10n()->t('Diaspora'), 'diaspora', '', (($_REQUEST['type'] ?? '') ?: 'bbcode') == 'diaspora'], '$type_markdown' => ['type', DI::l10n()->t('Markdown'), 'markdown', '', (($_REQUEST['type'] ?? '') ?: 'bbcode') == 'markdown'], '$type_html' => ['type', DI::l10n()->t('HTML'), 'html', '', (($_REQUEST['type'] ?? '') ?: 'bbcode') == 'html'], + '$flag_twitter' => file_exists('addon/twitter/twitter.php'), + '$type_twitter' => ['type', DI::l10n()->t('Twitter Source'), 'twitter', '', (($_REQUEST['type'] ?? '') ?: 'bbcode') == 'twitter'], '$results' => $results ]); diff --git a/src/Module/Debug/Feed.php b/src/Module/Debug/Feed.php index 4f17b70e68..4107422094 100644 --- a/src/Module/Debug/Feed.php +++ b/src/Module/Debug/Feed.php @@ -26,7 +26,6 @@ use Friendica\Core\Renderer; use Friendica\DI; use Friendica\Model; use Friendica\Protocol; -use Friendica\Util\Network; /** * Tests a given feed of a contact @@ -36,7 +35,7 @@ class Feed extends BaseModule public static function init(array $parameters = []) { if (!local_user()) { - info(DI::l10n()->t('You must be logged in to use this module')); + notice(DI::l10n()->t('You must be logged in to use this module')); DI::baseUrl()->redirect(); } } @@ -47,10 +46,9 @@ class Feed extends BaseModule if (!empty($_REQUEST['url'])) { $url = $_REQUEST['url']; - $contact_id = Model\Contact::getIdForURL($url, local_user(), true); - $contact = Model\Contact::getById($contact_id); + $contact = Model\Contact::getByURLForUser($url, local_user(), false); - $xml = Network::fetchUrl($contact['poll']); + $xml = DI::httpRequest()->fetch($contact['poll']); $import_result = Protocol\Feed::import($xml); diff --git a/src/Module/Debug/Probe.php b/src/Module/Debug/Probe.php index 0d1c3282b2..8090f2b082 100644 --- a/src/Module/Debug/Probe.php +++ b/src/Module/Debug/Probe.php @@ -44,7 +44,7 @@ class Probe extends BaseModule $res = ''; if (!empty($addr)) { - $res = NetworkProbe::uri($addr, '', 0, false); + $res = NetworkProbe::uri($addr, '', 0); $res = print_r($res, true); } @@ -54,7 +54,7 @@ class Probe extends BaseModule DI::l10n()->t('Lookup address'), $addr, '', - 'required' + DI::l10n()->t('Required') ], '$res' => $res, ]); diff --git a/src/Module/Delegation.php b/src/Module/Delegation.php index b6451c85af..f68b9db8d5 100644 --- a/src/Module/Delegation.php +++ b/src/Module/Delegation.php @@ -144,7 +144,8 @@ class Delegation extends BaseModule } $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('delegation.tpl'), [ - '$title' => DI::l10n()->t('Manage Identities and/or Pages'), + '$title' => DI::l10n()->t('Switch between your accounts'), + '$settings_label' => DI::l10n()->t('Manage your accounts'), '$desc' => DI::l10n()->t('Toggle between different identities or community/group pages which share your account details or which you have been granted "manage" permissions'), '$choose' => DI::l10n()->t('Select an identity to manage: '), '$identities' => $identities, diff --git a/src/Module/Diaspora/Fetch.php b/src/Module/Diaspora/Fetch.php index aba9d33be7..67f6fd0f7e 100644 --- a/src/Module/Diaspora/Fetch.php +++ b/src/Module/Diaspora/Fetch.php @@ -52,14 +52,14 @@ class Fetch extends BaseModule // Fetch the item $fields = [ 'uid', 'title', 'body', 'guid', 'contact-id', 'private', 'created', 'received', 'app', 'location', 'coord', 'network', - 'event-id', 'resource-id', 'author-link', 'author-avatar', 'author-name', 'plink', 'owner-link', 'attach' + 'event-id', 'resource-id', 'author-link', 'author-avatar', 'author-name', 'plink', 'owner-link', 'uri-id' ]; $condition = ['wall' => true, 'private' => [Item::PUBLIC, Item::UNLISTED], 'guid' => $guid, 'network' => [Protocol::DFRN, Protocol::DIASPORA]]; $item = Item::selectFirst($fields, $condition); if (empty($item)) { $condition = ['guid' => $guid, 'network' => [Protocol::DFRN, Protocol::DIASPORA]]; $item = Item::selectFirst(['author-link'], $condition); - if (empty($item)) { + if (!empty($item["author-link"])) { $parts = parse_url($item["author-link"]); if (empty($parts["scheme"]) || empty($parts["host"])) { throw new HTTPException\InternalServerErrorException(); diff --git a/src/Module/Diaspora/Receive.php b/src/Module/Diaspora/Receive.php index 01c04dfb6c..372e739463 100644 --- a/src/Module/Diaspora/Receive.php +++ b/src/Module/Diaspora/Receive.php @@ -148,7 +148,7 @@ class Receive extends BaseModule } self::$logger->info('Diaspora: Post decoded.'); - self::$logger->debug('Diaspora: Decoded message.', ['msg' => print_r($msg, true)]); + self::$logger->debug('Diaspora: Decoded message.', ['msg' => $msg]); if (!is_array($msg)) { throw new HTTPException\InternalServerErrorException('Message is not an array.'); diff --git a/src/Module/Directory.php b/src/Module/Directory.php index 3d03f10711..93d14cc176 100644 --- a/src/Module/Directory.php +++ b/src/Module/Directory.php @@ -29,10 +29,9 @@ use Friendica\Core\Hook; use Friendica\Core\Session; use Friendica\Core\Renderer; use Friendica\DI; -use Friendica\Model\Contact; +use Friendica\Model; use Friendica\Model\Profile; use Friendica\Network\HTTPException; -use Friendica\Util\Proxy as ProxyUtils; use Friendica\Util\Strings; /** @@ -75,7 +74,7 @@ class Directory extends BaseModule $profiles = Profile::searchProfiles($pager->getStart(), $pager->getItemsPerPage(), $search); if ($profiles['total'] === 0) { - info(DI::l10n()->t('No entries (some entries may be hidden).') . EOL); + notice(DI::l10n()->t('No entries (some entries may be hidden).')); } else { if (in_array('small', $app->argv)) { $photo = 'thumb'; @@ -84,7 +83,10 @@ class Directory extends BaseModule } foreach ($profiles['entries'] as $entry) { - $entries[] = self::formatEntry($entry, $photo); + $contact = Model\Contact::getByURLForUser($entry['url'], local_user()); + if (!empty($contact)) { + $entries[] = Contact::getContactTemplateVars($contact); + } } } @@ -161,18 +163,18 @@ class Directory extends BaseModule $location_e = $location; $photo_menu = [ - 'profile' => [DI::l10n()->t("View Profile"), Contact::magicLink($profile_link)] + 'profile' => [DI::l10n()->t("View Profile"), Model\Contact::magicLink($profile_link)] ]; $entry = [ 'id' => $contact['id'], - 'url' => Contact::magicLink($profile_link), + 'url' => Model\Contact::magicLink($profile_link), 'itemurl' => $itemurl, - 'thumb' => ProxyUtils::proxifyUrl($contact[$photo_size], false, ProxyUtils::SIZE_THUMB), + 'thumb' => Model\Contact::getThumb($contact), 'img_hover' => $contact['name'], 'name' => $contact['name'], 'details' => $details, - 'account_type' => Contact::getAccountType($contact), + 'account_type' => Model\Contact::getAccountType($contact), 'profile' => $profile, 'location' => $location_e, 'tags' => $contact['pub_keywords'], diff --git a/src/Module/Feed.php b/src/Module/Feed.php index ff0abdb2ab..0ccffbb96b 100644 --- a/src/Module/Feed.php +++ b/src/Module/Feed.php @@ -23,7 +23,7 @@ namespace Friendica\Module; use Friendica\BaseModule; use Friendica\DI; -use Friendica\Protocol\OStatus; +use Friendica\Protocol\Feed as ProtocolFeed; /** * Provides public Atom feeds @@ -75,7 +75,7 @@ class Feed extends BaseModule // @TODO: Replace with parameter from router $nickname = $a->argv[1]; header("Content-type: application/atom+xml; charset=utf-8"); - echo OStatus::feed($nickname, $last_update, 10, $type, $nocache, true); + echo ProtocolFeed::atom($nickname, $last_update, 10, $type, $nocache, true); exit(); } } diff --git a/src/Module/Filer/RemoveTag.php b/src/Module/Filer/RemoveTag.php index 7866656e33..a8a8a896b3 100644 --- a/src/Module/Filer/RemoveTag.php +++ b/src/Module/Filer/RemoveTag.php @@ -59,11 +59,11 @@ class RemoveTag extends BaseModule ]); if ($item_id && strlen($term)) { - if (FileTag::unsaveFile(local_user(), $item_id, $term, $category)) { - info('Item removed'); + if (!FileTag::unsaveFile(local_user(), $item_id, $term, $category)) { + notice(DI::l10n()->t('Item was not removed')); } } else { - info('Item was not deleted'); + notice(DI::l10n()->t('Item was not deleted')); } DI::baseUrl()->redirect('network?file=' . rawurlencode($term)); diff --git a/src/Module/Filer/SaveTag.php b/src/Module/Filer/SaveTag.php index 12226107ba..4b2fdb09e8 100644 --- a/src/Module/Filer/SaveTag.php +++ b/src/Module/Filer/SaveTag.php @@ -35,7 +35,7 @@ class SaveTag extends BaseModule public static function init(array $parameters = []) { if (!local_user()) { - info(DI::l10n()->t('You must be logged in to use this module')); + notice(DI::l10n()->t('You must be logged in to use this module')); DI::baseUrl()->redirect(); } } @@ -54,7 +54,6 @@ class SaveTag extends BaseModule if ($item_id && strlen($term)) { // file item Model\FileTag::saveFile(local_user(), $item_id, $term); - info(DI::l10n()->t('Filetag %s saved to item', $term)); } // return filer dialog diff --git a/src/Module/FollowConfirm.php b/src/Module/FollowConfirm.php index 28c849a861..f4e4c5ebf9 100644 --- a/src/Module/FollowConfirm.php +++ b/src/Module/FollowConfirm.php @@ -13,7 +13,7 @@ class FollowConfirm extends BaseModule { $uid = local_user(); if (!$uid) { - notice(DI::l10n()->t('Permission denied.') . EOL); + notice(DI::l10n()->t('Permission denied.')); return; } diff --git a/src/Module/Followers.php b/src/Module/Followers.php index 8e683e5623..bcf4bb7829 100644 --- a/src/Module/Followers.php +++ b/src/Module/Followers.php @@ -22,8 +22,8 @@ namespace Friendica\Module; use Friendica\BaseModule; -use Friendica\Core\System; use Friendica\DI; +use Friendica\Model\Contact; use Friendica\Model\User; use Friendica\Protocol\ActivityPub; @@ -49,7 +49,7 @@ class Followers extends BaseModule $page = $_REQUEST['page'] ?? null; - $followers = ActivityPub\Transmitter::getFollowers($owner, $page); + $followers = ActivityPub\Transmitter::getContacts($owner, [Contact::FOLLOWER, Contact::FRIEND], 'followers', $page); header('Content-Type: application/activity+json'); echo json_encode($followers); diff --git a/src/Module/Following.php b/src/Module/Following.php index 30f47b5986..c2a765d74c 100644 --- a/src/Module/Following.php +++ b/src/Module/Following.php @@ -22,8 +22,8 @@ namespace Friendica\Module; use Friendica\BaseModule; -use Friendica\Core\System; use Friendica\DI; +use Friendica\Model\Contact; use Friendica\Model\User; use Friendica\Protocol\ActivityPub; @@ -49,10 +49,10 @@ class Following extends BaseModule $page = $_REQUEST['page'] ?? null; - $Following = ActivityPub\Transmitter::getFollowing($owner, $page); + $following = ActivityPub\Transmitter::getContacts($owner, [Contact::SHARING, Contact::FRIEND], 'following', $page); header('Content-Type: application/activity+json'); - echo json_encode($Following); + echo json_encode($following); exit(); } } diff --git a/src/Module/Friendica.php b/src/Module/Friendica.php index 3325b1ae82..7da0280896 100644 --- a/src/Module/Friendica.php +++ b/src/Module/Friendica.php @@ -25,8 +25,11 @@ use Friendica\BaseModule; use Friendica\Core\Addon; use Friendica\Core\Hook; use Friendica\Core\Renderer; +use Friendica\Core\System; +use Friendica\Database\PostUpdate; use Friendica\DI; use Friendica\Model\User; +use Friendica\Protocol\ActivityPub; /** * Prints information about the current node @@ -93,8 +96,8 @@ class Friendica extends BaseModule 'about' => DI::l10n()->t('This is Friendica, version %s that is running at the web location %s. The database version is %s, the post update version is %s.', '' . FRIENDICA_VERSION . '', DI::baseUrl()->get(), - '' . DB_UPDATE_VERSION . '', - '' . $config->get('system', 'post_update_version') . ''), + '' . DB_UPDATE_VERSION . '/' . $config->get('system', 'build') .'', + '' . PostUpdate::VERSION . '/' . $config->get('system', 'post_update_version') . ''), 'friendica' => DI::l10n()->t('Please visit Friendi.ca to learn more about the Friendica project.'), 'bugs' => DI::l10n()->t('Bug reports and issues: please visit') . ' ' . '' . DI::l10n()->t('the bugtracker at github') . '', 'info' => DI::l10n()->t('Suggestions, praise, etc. - please email "info" at "friendi - dot - ca'), @@ -108,6 +111,15 @@ class Friendica extends BaseModule public static function rawContent(array $parameters = []) { + if (ActivityPub::isRequest()) { + $data = ActivityPub\Transmitter::getProfile(0); + if (!empty($data)) { + header('Access-Control-Allow-Origin: *'); + header('Cache-Control: max-age=23200, stale-while-revalidate=23200'); + System::jsonExit($data, 'application/activity+json'); + } + } + $app = DI::app(); // @TODO: Replace with parameter from router @@ -130,21 +142,13 @@ class Friendica extends BaseModule $register_policy = $register_policies[$register_policy_int]; } - $condition = []; - $admin = false; - if (!empty($config->get('config', 'admin_nickname'))) { - $condition['nickname'] = $config->get('config', 'admin_nickname'); - } - if (!empty($config->get('config', 'admin_email'))) { - $adminList = explode(',', str_replace(' ', '', $config->get('config', 'admin_email'))); - $condition['email'] = $adminList[0]; - $administrator = User::getByEmail($adminList[0], ['username', 'nickname']); - if (!empty($administrator)) { - $admin = [ - 'name' => $administrator['username'], - 'profile' => DI::baseUrl()->get() . '/profile/' . $administrator['nickname'], - ]; - } + $admin = []; + $administrator = User::getFirstAdmin(['username', 'nickname']); + if (!empty($administrator)) { + $admin = [ + 'name' => $administrator['username'], + 'profile' => DI::baseUrl()->get() . '/profile/' . $administrator['nickname'], + ]; } $visible_addons = Addon::getVisibleList(); diff --git a/src/Module/Group.php b/src/Module/Group.php index 11e7f1a760..8da062a93e 100644 --- a/src/Module/Group.php +++ b/src/Module/Group.php @@ -53,7 +53,6 @@ class Group extends BaseModule $name = Strings::escapeTags(trim($_POST['groupname'])); $r = Model\Group::create(local_user(), $name); if ($r) { - info(DI::l10n()->t('Group created.')); $r = Model\Group::getIdByName(local_user(), $name); if ($r) { DI::baseUrl()->redirect('group/' . $r); @@ -75,8 +74,8 @@ class Group extends BaseModule } $groupname = Strings::escapeTags(trim($_POST['groupname'])); if (strlen($groupname) && ($groupname != $group['name'])) { - if (Model\Group::update($group['id'], $groupname)) { - info(DI::l10n()->t('Group name changed.')); + if (!Model\Group::update($group['id'], $groupname)) { + notice(DI::l10n()->t('Group name was not changed.')); } } } @@ -132,7 +131,7 @@ class Group extends BaseModule throw new \Exception(DI::l10n()->t('Bad request.'), 400); } - notice($message); + info($message); System::jsonExit(['status' => 'OK', 'message' => $message]); } catch (\Exception $e) { notice($e->getMessage()); @@ -216,9 +215,7 @@ class Group extends BaseModule DI::baseUrl()->redirect('contact'); } - if (Model\Group::remove($a->argv[2])) { - info(DI::l10n()->t('Group removed.')); - } else { + if (!Model\Group::remove($a->argv[2])) { notice(DI::l10n()->t('Unable to remove group.')); } } @@ -242,7 +239,7 @@ class Group extends BaseModule DI::baseUrl()->redirect('contact'); } - $members = Model\Contact::getByGroupId($group['id']); + $members = Model\Contact\Group::getById($group['id']); $preselected = []; if (count($members)) { @@ -258,7 +255,7 @@ class Group extends BaseModule Model\Group::addMember($group['id'], $change); } - $members = Model\Contact::getByGroupId($group['id']); + $members = Model\Contact\Group::getById($group['id']); $preselected = []; if (count($members)) { foreach ($members as $member) { @@ -319,10 +316,10 @@ class Group extends BaseModule } if ($nogroup) { - $contacts = Model\Contact::getUngroupedList(local_user()); + $contacts = Model\Contact\Group::listUngrouped(local_user()); } else { $contacts_stmt = DBA::select('contact', [], - ['uid' => local_user(), 'pending' => false, 'blocked' => false, 'self' => false], + ['uid' => local_user(), 'pending' => false, 'blocked' => false, 'failed' => false, 'self' => false], ['order' => ['name']] ); $contacts = DBA::toArray($contacts_stmt); diff --git a/src/Module/HoverCard.php b/src/Module/HoverCard.php index f3b8248a6b..ae107ca172 100644 --- a/src/Module/HoverCard.php +++ b/src/Module/HoverCard.php @@ -26,7 +26,7 @@ use Friendica\Core\Session; use Friendica\DI; use Friendica\Model\Profile; use Friendica\Model\User; -use Friendica\Network\HTTPException\NotFoundException; +use Friendica\Network\HTTPException; /** * Loads a profile for the HoverCard view @@ -44,11 +44,15 @@ class HoverCard extends BaseModule // Show the profile hovercard $nickname = $parameters['profile']; } else { - throw new NotFoundException(DI::l10n()->t('No profile')); + throw new HTTPException\NotFoundException(DI::l10n()->t('No profile')); } Profile::load($a, $nickname); + if (empty($a->profile)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found.')); + } + $page = DI::page(); if (!empty($a->profile['page-flags']) && ($a->profile['page-flags'] == User::PAGE_FLAGS_COMMUNITY)) { diff --git a/src/Module/Install.php b/src/Module/Install.php index 21510d206e..3ad38041e1 100644 --- a/src/Module/Install.php +++ b/src/Module/Install.php @@ -186,9 +186,13 @@ class Install extends BaseModule $output .= Renderer::replaceMacros($tpl, [ '$title' => $install_title, '$pass' => DI::l10n()->t('System check'), + '$required' => DI::l10n()->t('Required'), + '$requirement_not_satisfied' => DI::l10n()->t('Requirement not satisfied'), + '$optional_requirement_not_satisfied' => DI::l10n()->t('Optional requirement not satisfied'), + '$ok' => DI::l10n()->t('OK'), '$checks' => self::$installer->getChecks(), '$passed' => $status, - '$see_install' => DI::l10n()->t('Please see the file "INSTALL.txt".'), + '$see_install' => DI::l10n()->t('Please see the file "doc/INSTALL.md".'), '$next' => DI::l10n()->t('Next'), '$reload' => DI::l10n()->t('Check again'), '$php_path' => $php_path, @@ -215,12 +219,12 @@ class Install extends BaseModule DI::l10n()->t('Host name'), $configCache->get('config', 'hostname'), DI::l10n()->t('Overwrite this field in case the determinated hostname isn\'t right, otherweise leave it as is.'), - 'required'], + DI::l10n()->t('Required')], '$basepath' => ['system-basepath', DI::l10n()->t("Base path to installation"), $configCache->get('system', 'basepath'), DI::l10n()->t("If the system cannot detect the correct path to your installation, enter the correct path here. This setting should only be set if you are using a restricted system and symbolic links to your webroot."), - 'required'], + DI::l10n()->t('Required')], '$urlpath' => ['system-urlpath', DI::l10n()->t('Sub path of the URL'), $configCache->get('system', 'urlpath'), @@ -239,7 +243,9 @@ class Install extends BaseModule '$info_01' => DI::l10n()->t('In order to install Friendica we need to know how to connect to your database.'), '$info_02' => DI::l10n()->t('Please contact your hosting provider or site administrator if you have questions about these settings.'), '$info_03' => DI::l10n()->t('The database you specify below should already exist. If it does not, please create it before continuing.'), - 'checks' => self::$installer->getChecks(), + '$required' => DI::l10n()->t('Required'), + '$requirement_not_satisfied' => DI::l10n()->t('Requirement not satisfied'), + '$checks' => self::$installer->getChecks(), '$hostname' => $configCache->get('config', 'hostname'), '$ssl_policy' => $configCache->get('system', 'ssl_policy'), '$basepath' => $configCache->get('system', 'basepath'), @@ -248,23 +254,23 @@ class Install extends BaseModule DI::l10n()->t('Database Server Name'), $configCache->get('database', 'hostname'), '', - 'required'], + DI::l10n()->t('Required')], '$dbuser' => ['database-username', DI::l10n()->t('Database Login Name'), $configCache->get('database', 'username'), '', - 'required', + DI::l10n()->t('Required'), 'autofocus'], '$dbpass' => ['database-password', DI::l10n()->t('Database Login Password'), $configCache->get('database', 'password'), DI::l10n()->t("For security reasons the password must not be empty"), - 'required'], + DI::l10n()->t('Required')], '$dbdata' => ['database-database', DI::l10n()->t('Database Name'), $configCache->get('database', 'database'), '', - 'required'], + DI::l10n()->t('Required')], '$lbl_10' => DI::l10n()->t('Please select a default timezone for your website'), '$php_path' => $configCache->get('config', 'php_path'), '$submit' => DI::l10n()->t('Submit') @@ -278,6 +284,7 @@ class Install extends BaseModule $tpl = Renderer::getMarkupTemplate('install_settings.tpl'); $output .= Renderer::replaceMacros($tpl, [ '$title' => $install_title, + '$required' => DI::l10n()->t('Required'), '$checks' => self::$installer->getChecks(), '$pass' => DI::l10n()->t('Site settings'), '$hostname' => $configCache->get('config', 'hostname'), @@ -292,7 +299,7 @@ class Install extends BaseModule DI::l10n()->t('Site administrator email address'), $configCache->get('config', 'admin_email'), DI::l10n()->t('Your account email address must match this in order to use the web admin panel.'), - 'required', 'autofocus', 'email'], + DI::l10n()->t('Required'), 'autofocus', 'email'], '$timezone' => Temporal::getTimezoneField('system-default_timezone', DI::l10n()->t('Please select a default timezone for your website'), $configCache->get('system', 'default_timezone'), @@ -318,10 +325,12 @@ class Install extends BaseModule $tpl = Renderer::getMarkupTemplate('install_finished.tpl'); $output .= Renderer::replaceMacros($tpl, [ - '$title' => $install_title, - '$checks' => self::$installer->getChecks(), - '$pass' => DI::l10n()->t('Installation finished'), - '$text' => $db_return_text . self::whatNext(), + '$title' => $install_title, + '$required' => DI::l10n()->t('Required'), + '$requirement_not_satisfied' => DI::l10n()->t('Requirement not satisfied'), + '$checks' => self::$installer->getChecks(), + '$pass' => DI::l10n()->t('Installation finished'), + '$text' => $db_return_text . self::whatNext(), ]); break; diff --git a/src/Module/Invite.php b/src/Module/Invite.php index 98668bf71d..287478954c 100644 --- a/src/Module/Invite.php +++ b/src/Module/Invite.php @@ -75,7 +75,7 @@ class Invite extends BaseModule $recipient = trim($recipient); if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) { - notice(DI::l10n()->t('%s : Not a valid email address.', $recipient) . EOL); + notice(DI::l10n()->t('%s : Not a valid email address.', $recipient)); continue; } @@ -111,15 +111,15 @@ class Invite extends BaseModule $current_invites++; DI::pConfig()->set(local_user(), 'system', 'sent_invites', $current_invites); if ($current_invites > $max_invites) { - notice(DI::l10n()->t('Invitation limit exceeded. Please contact your site administrator.') . EOL); + notice(DI::l10n()->t('Invitation limit exceeded. Please contact your site administrator.')); return; } } else { - notice(DI::l10n()->t('%s : Message delivery failed.', $recipient) . EOL); + notice(DI::l10n()->t('%s : Message delivery failed.', $recipient)); } } - notice(DI::l10n()->tt('%d message sent.', '%d messages sent.', $total) . EOL); + info(DI::l10n()->tt('%d message sent.', '%d messages sent.', $total)); } public static function content(array $parameters = []) diff --git a/src/Module/Like.php b/src/Module/Like.php index c926012f18..4a6831b738 100644 --- a/src/Module/Like.php +++ b/src/Module/Like.php @@ -22,9 +22,13 @@ namespace Friendica\Module; use Friendica\BaseModule; +use Friendica\Content\Text\BBCode; +use Friendica\Core\Protocol; +use Friendica\Core\System; use Friendica\DI; use Friendica\Model\Item; use Friendica\Core\Session; +use Friendica\Database\DBA; use Friendica\Network\HTTPException; use Friendica\Util\Strings; @@ -50,7 +54,14 @@ class Like extends BaseModule // @TODO: Replace with parameter from router $itemId = (($app->argc > 1) ? Strings::escapeTags(trim($app->argv[1])) : 0); - if (!Item::performActivity($itemId, $verb)) { + if (in_array($verb, ['announce', 'unannounce'])) { + $item = Item::selectFirst(['network'], ['id' => $itemId]); + if ($item['network'] == Protocol::DIASPORA) { + self::performDiasporaReshare($itemId); + } + } + + if (!Item::performActivity($itemId, $verb, local_user())) { throw new HTTPException\BadRequestException(); } @@ -68,5 +79,35 @@ class Like extends BaseModule DI::baseUrl()->redirect($returnPath . $rand); } + + System::jsonExit(['status' => 'OK']); + } + + private static function performDiasporaReshare(int $itemId) + { + $fields = ['uri-id', 'body', 'title', 'author-name', 'author-link', 'author-avatar', 'guid', 'created', 'plink']; + $item = Item::selectFirst($fields, ['id' => $itemId, 'private' => [Item::PUBLIC, Item::UNLISTED]]); + if (!DBA::isResult($item) || ($item['body'] == '')) { + return; + } + + if (strpos($item['body'], '[/share]') !== false) { + $pos = strpos($item['body'], '[share'); + $post = substr($item['body'], $pos); + } else { + $post = BBCode::getShareOpeningTag($item['author-name'], $item['author-link'], $item['author-avatar'], $item['plink'], $item['created'], $item['guid']); + + if (!empty($item['title'])) { + $post .= '[h3]' . $item['title'] . "[/h3]\n"; + } + + $post .= $item['body']; + $post .= '[/share]'; + } + $_REQUEST['body'] = $post; + $_REQUEST['profile_uid'] = local_user(); + + require_once 'mod/item.php'; + item_post(DI::app()); } } diff --git a/src/Module/Magic.php b/src/Module/Magic.php index 85da8eb485..af8ff36056 100644 --- a/src/Module/Magic.php +++ b/src/Module/Magic.php @@ -28,7 +28,6 @@ use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Util\HTTPSignature; -use Friendica\Util\Network; use Friendica\Util\Strings; /** @@ -42,9 +41,9 @@ class Magic extends BaseModule { $a = DI::app(); $ret = ['success' => false, 'url' => '', 'message' => '']; - Logger::log('magic mdule: invoked', Logger::DEBUG); + Logger::info('magic mdule: invoked'); - Logger::log('args: ' . print_r($_REQUEST, true), Logger::DATA); + Logger::debug('args', ['request' => $_REQUEST]); $addr = $_REQUEST['addr'] ?? ''; $dest = $_REQUEST['dest'] ?? ''; @@ -73,7 +72,7 @@ class Magic extends BaseModule return $ret; } - Logger::log('Contact is already authenticated', Logger::DEBUG); + Logger::info('Contact is already authenticated'); System::externalRedirect($dest); } @@ -89,19 +88,19 @@ class Magic extends BaseModule $exp = explode('/profile/', $contact['url']); $basepath = $exp[0]; - $headers = []; - $headers['Accept'] = 'application/x-dfrn+json, application/x-zot+json'; - $headers['X-Open-Web-Auth'] = Strings::getRandomHex(); + $header = []; + $header['Accept'] = 'application/x-dfrn+json, application/x-zot+json'; + $header['X-Open-Web-Auth'] = Strings::getRandomHex(); // Create a header that is signed with the local users private key. - $headers = HTTPSignature::createSig( - $headers, + $header = HTTPSignature::createSig( + $header, $user['prvkey'], 'acct:' . $user['nickname'] . '@' . DI::baseUrl()->getHostname() . (DI::baseUrl()->getUrlPath() ? '/' . DI::baseUrl()->getUrlPath() : '') ); // Try to get an authentication token from the other instance. - $curlResult = Network::curl($basepath . '/owa', false, ['headers' => $headers]); + $curlResult = DI::httpRequest()->get($basepath . '/owa', ['header' => $header]); if ($curlResult->isSuccess()) { $j = json_decode($curlResult->getBody(), true); diff --git a/src/Module/Manifest.php b/src/Module/Manifest.php index bfe758fa97..8ea6fbcfc1 100644 --- a/src/Module/Manifest.php +++ b/src/Module/Manifest.php @@ -31,7 +31,7 @@ class Manifest extends BaseModule { $config = DI::config(); - $touch_icon = $config->get('system', 'touch_icon') ?: 'images/friendica-128.png'; + $touch_icon = $config->get('system', 'touch_icon') ?: 'images/friendica-192.png'; $theme = DI::config()->get('system', 'theme'); @@ -44,7 +44,12 @@ class Manifest extends BaseModule 'icons' => [ [ 'src' => DI::baseUrl()->get() . '/' . $touch_icon, - 'sizes' => '128x128', + 'sizes' => '192x192', + 'type' => 'image/png', + ], + [ + 'src' => DI::baseUrl()->get() . '/' . $touch_icon, + 'sizes' => '512x512', 'type' => 'image/png', ], ], diff --git a/src/Module/NoScrape.php b/src/Module/NoScrape.php index 13a683d974..7178209bd8 100644 --- a/src/Module/NoScrape.php +++ b/src/Module/NoScrape.php @@ -26,14 +26,13 @@ use Friendica\Core\Protocol; use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\DI; -use Friendica\Model\GContact; use Friendica\Model\Profile; use Friendica\Model\User; /** * Endpoint for getting current user infos * - * @see GContact::updateFromNoScrape() for usage + * @see Contact::updateFromNoScrape() for usage */ class NoScrape extends BaseModule { @@ -46,19 +45,22 @@ class NoScrape extends BaseModule $which = $parameters['nick']; } elseif (local_user() && isset($parameters['profile']) && DI::args()->get(2) == 'view') { // view infos about a known profile (needs a login) - $which = $a->user['nickname']; + $which = $a->user['nickname']; } else { System::jsonError(403, 'Authentication required'); - exit(); } Profile::load($a, $which); + if (empty($a->profile['uid'])) { + System::jsonError(404, 'Profile not found'); + } + $json_info = [ 'addr' => $a->profile['addr'], 'nick' => $which, 'guid' => $a->profile['guid'], - 'key' => $a->profile['pubkey'], + 'key' => $a->profile['upubkey'], 'homepage' => DI::baseUrl() . "/profile/{$which}", 'comm' => ($a->profile['account-type'] == User::ACCOUNT_TYPE_COMMUNITY), 'account-type' => $a->profile['account-type'], diff --git a/src/Module/NodeInfo.php b/src/Module/NodeInfo.php deleted file mode 100644 index 87321489f3..0000000000 --- a/src/Module/NodeInfo.php +++ /dev/null @@ -1,245 +0,0 @@ -. - * - */ - -namespace Friendica\Module; - -use Friendica\BaseModule; -use Friendica\Core\Addon; -use Friendica\DI; -use stdClass; - -/** - * Standardized way of exposing metadata about a server running one of the distributed social networks. - * @see https://github.com/jhass/nodeinfo/blob/master/PROTOCOL.md - */ -class NodeInfo extends BaseModule -{ - public static function rawContent(array $parameters = []) - { - if ($parameters['version'] == '1.0') { - self::printNodeInfo1(); - } elseif ($parameters['version'] == '2.0') { - self::printNodeInfo2(); - } else { - throw new \Friendica\Network\HTTPException\NotFoundException(); - } - } - - /** - * Return the supported services - * - * @return Object with supported services - */ - private static function getUsage() - { - $config = DI::config(); - - $usage = new stdClass(); - - if (!empty($config->get('system', 'nodeinfo'))) { - $usage->users = [ - 'total' => intval($config->get('nodeinfo', 'total_users')), - 'activeHalfyear' => intval($config->get('nodeinfo', 'active_users_halfyear')), - 'activeMonth' => intval($config->get('nodeinfo', 'active_users_monthly')) - ]; - $usage->localPosts = intval($config->get('nodeinfo', 'local_posts')); - $usage->localComments = intval($config->get('nodeinfo', 'local_comments')); - } - - return $usage; - } - - /** - * Return the supported services - * - * @return array with supported services - */ - private static function getServices() - { - $services = [ - 'inbound' => [], - 'outbound' => [], - ]; - - if (Addon::isEnabled('blogger')) { - $services['outbound'][] = 'blogger'; - } - if (Addon::isEnabled('dwpost')) { - $services['outbound'][] = 'dreamwidth'; - } - if (Addon::isEnabled('statusnet')) { - $services['inbound'][] = 'gnusocial'; - $services['outbound'][] = 'gnusocial'; - } - if (Addon::isEnabled('ijpost')) { - $services['outbound'][] = 'insanejournal'; - } - if (Addon::isEnabled('libertree')) { - $services['outbound'][] = 'libertree'; - } - if (Addon::isEnabled('buffer')) { - $services['outbound'][] = 'linkedin'; - } - if (Addon::isEnabled('ljpost')) { - $services['outbound'][] = 'livejournal'; - } - if (Addon::isEnabled('buffer')) { - $services['outbound'][] = 'pinterest'; - } - if (Addon::isEnabled('posterous')) { - $services['outbound'][] = 'posterous'; - } - if (Addon::isEnabled('pumpio')) { - $services['inbound'][] = 'pumpio'; - $services['outbound'][] = 'pumpio'; - } - - $services['outbound'][] = 'smtp'; - - if (Addon::isEnabled('tumblr')) { - $services['outbound'][] = 'tumblr'; - } - if (Addon::isEnabled('twitter') || Addon::isEnabled('buffer')) { - $services['outbound'][] = 'twitter'; - } - if (Addon::isEnabled('wppost')) { - $services['outbound'][] = 'wordpress'; - } - - return $services; - } - - /** - * Print the nodeinfo version 1 - */ - private static function printNodeInfo1() - { - $config = DI::config(); - - $nodeinfo = [ - 'version' => '1.0', - 'software' => [ - 'name' => 'friendica', - 'version' => FRIENDICA_VERSION . '-' . DB_UPDATE_VERSION, - ], - 'protocols' => [ - 'inbound' => [ - 'friendica' - ], - 'outbound' => [ - 'friendica' - ], - ], - 'services' => [], - 'usage' => [], - 'openRegistrations' => intval($config->get('config', 'register_policy')) !== Register::CLOSED, - 'metadata' => [ - 'nodeName' => $config->get('config', 'sitename'), - ], - ]; - - if (!empty($config->get('system', 'diaspora_enabled'))) { - $nodeinfo['protocols']['inbound'][] = 'diaspora'; - $nodeinfo['protocols']['outbound'][] = 'diaspora'; - } - - if (empty($config->get('system', 'ostatus_disabled'))) { - $nodeinfo['protocols']['inbound'][] = 'gnusocial'; - $nodeinfo['protocols']['outbound'][] = 'gnusocial'; - } - - $nodeinfo['usage'] = self::getUsage(); - - $nodeinfo['services'] = self::getServices(); - - $nodeinfo['metadata']['protocols'] = $nodeinfo['protocols']; - $nodeinfo['metadata']['protocols']['outbound'][] = 'atom1.0'; - $nodeinfo['metadata']['protocols']['inbound'][] = 'atom1.0'; - $nodeinfo['metadata']['protocols']['inbound'][] = 'rss2.0'; - - $nodeinfo['metadata']['services'] = $nodeinfo['services']; - - if (Addon::isEnabled('twitter')) { - $nodeinfo['metadata']['services']['inbound'][] = 'twitter'; - } - - $nodeinfo['metadata']['explicitContent'] = $config->get('system', 'explicit_content', false) == true; - - header('Content-type: application/json; charset=utf-8'); - echo json_encode($nodeinfo, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - exit; - } - - /** - * Print the nodeinfo version 2 - */ - private static function printNodeInfo2() - { - $config = DI::config(); - - $imap = (function_exists('imap_open') && !$config->get('system', 'imap_disabled') && !$config->get('system', 'dfrn_only')); - - $nodeinfo = [ - 'version' => '2.0', - 'software' => [ - 'name' => 'friendica', - 'version' => FRIENDICA_VERSION . '-' . DB_UPDATE_VERSION, - ], - 'protocols' => ['dfrn', 'activitypub'], - 'services' => [], - 'usage' => [], - 'openRegistrations' => intval($config->get('config', 'register_policy')) !== Register::CLOSED, - 'metadata' => [ - 'nodeName' => $config->get('config', 'sitename'), - ], - ]; - - if (!empty($config->get('system', 'diaspora_enabled'))) { - $nodeinfo['protocols'][] = 'diaspora'; - } - - if (empty($config->get('system', 'ostatus_disabled'))) { - $nodeinfo['protocols'][] = 'ostatus'; - } - - $nodeinfo['usage'] = self::getUsage(); - - $nodeinfo['services'] = self::getServices(); - - if (Addon::isEnabled('twitter')) { - $nodeinfo['services']['inbound'][] = 'twitter'; - } - - $nodeinfo['services']['inbound'][] = 'atom1.0'; - $nodeinfo['services']['inbound'][] = 'rss2.0'; - $nodeinfo['services']['outbound'][] = 'atom1.0'; - - if ($imap) { - $nodeinfo['services']['inbound'][] = 'imap'; - } - - $nodeinfo['metadata']['explicitContent'] = $config->get('system', 'explicit_content', false) == true; - - header('Content-type: application/json; charset=utf-8'); - echo json_encode($nodeinfo, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - exit; - } -} diff --git a/src/Module/NodeInfo110.php b/src/Module/NodeInfo110.php new file mode 100644 index 0000000000..79e215e4fb --- /dev/null +++ b/src/Module/NodeInfo110.php @@ -0,0 +1,91 @@ +. + * + */ + +namespace Friendica\Module; + +use Friendica\BaseModule; +use Friendica\Core\Addon; +use Friendica\Core\System; +use Friendica\DI; +use Friendica\Model\Nodeinfo; + +/** + * Version 1.0 of Nodeinfo, a standardized way of exposing metadata about a server running one of the distributed social networks. + * @see https://github.com/jhass/nodeinfo/blob/master/PROTOCOL.md + */ +class NodeInfo110 extends BaseModule +{ + public static function rawContent(array $parameters = []) + { + $config = DI::config(); + + $nodeinfo = [ + 'version' => '1.0', + 'software' => [ + 'name' => 'friendica', + 'version' => FRIENDICA_VERSION . '-' . DB_UPDATE_VERSION, + ], + 'protocols' => [ + 'inbound' => [ + 'friendica' + ], + 'outbound' => [ + 'friendica' + ], + ], + 'services' => [], + 'usage' => [], + 'openRegistrations' => intval($config->get('config', 'register_policy')) !== Register::CLOSED, + 'metadata' => [ + 'nodeName' => $config->get('config', 'sitename'), + ], + ]; + + if (!empty($config->get('system', 'diaspora_enabled'))) { + $nodeinfo['protocols']['inbound'][] = 'diaspora'; + $nodeinfo['protocols']['outbound'][] = 'diaspora'; + } + + if (empty($config->get('system', 'ostatus_disabled'))) { + $nodeinfo['protocols']['inbound'][] = 'gnusocial'; + $nodeinfo['protocols']['outbound'][] = 'gnusocial'; + } + + $nodeinfo['usage'] = Nodeinfo::getUsage(); + + $nodeinfo['services'] = Nodeinfo::getServices(); + + $nodeinfo['metadata']['protocols'] = $nodeinfo['protocols']; + $nodeinfo['metadata']['protocols']['outbound'][] = 'atom1.0'; + $nodeinfo['metadata']['protocols']['inbound'][] = 'atom1.0'; + $nodeinfo['metadata']['protocols']['inbound'][] = 'rss2.0'; + + $nodeinfo['metadata']['services'] = $nodeinfo['services']; + + if (Addon::isEnabled('twitter')) { + $nodeinfo['metadata']['services']['inbound'][] = 'twitter'; + } + + $nodeinfo['metadata']['explicitContent'] = $config->get('system', 'explicit_content', false) == true; + + System::jsonExit($nodeinfo, 'application/json; charset=utf-8', JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } +} diff --git a/src/Module/NodeInfo120.php b/src/Module/NodeInfo120.php new file mode 100644 index 0000000000..9d02a4b54b --- /dev/null +++ b/src/Module/NodeInfo120.php @@ -0,0 +1,83 @@ +. + * + */ + +namespace Friendica\Module; + +use Friendica\BaseModule; +use Friendica\Core\Addon; +use Friendica\Core\System; +use Friendica\DI; +use Friendica\Model\Nodeinfo; + +/** + * Version 2.0 of Nodeinfo, a standardized way of exposing metadata about a server running one of the distributed social networks. + * @see https://github.com/jhass/nodeinfo/blob/master/PROTOCOL.md + */ +class NodeInfo120 extends BaseModule +{ + public static function rawContent(array $parameters = []) + { + $config = DI::config(); + + $nodeinfo = [ + 'version' => '2.0', + 'software' => [ + 'name' => 'friendica', + 'version' => FRIENDICA_VERSION . '-' . DB_UPDATE_VERSION, + ], + 'protocols' => ['dfrn', 'activitypub'], + 'services' => [], + 'usage' => [], + 'openRegistrations' => intval($config->get('config', 'register_policy')) !== Register::CLOSED, + 'metadata' => [ + 'nodeName' => $config->get('config', 'sitename'), + ], + ]; + + if (!empty($config->get('system', 'diaspora_enabled'))) { + $nodeinfo['protocols'][] = 'diaspora'; + } + + if (empty($config->get('system', 'ostatus_disabled'))) { + $nodeinfo['protocols'][] = 'ostatus'; + } + + $nodeinfo['usage'] = Nodeinfo::getUsage(); + + $nodeinfo['services'] = Nodeinfo::getServices(); + + if (Addon::isEnabled('twitter')) { + $nodeinfo['services']['inbound'][] = 'twitter'; + } + + $nodeinfo['services']['inbound'][] = 'atom1.0'; + $nodeinfo['services']['inbound'][] = 'rss2.0'; + $nodeinfo['services']['outbound'][] = 'atom1.0'; + + if (function_exists('imap_open') && !$config->get('system', 'imap_disabled') && !$config->get('system', 'dfrn_only')) { + $nodeinfo['services']['inbound'][] = 'imap'; + } + + $nodeinfo['metadata']['explicitContent'] = $config->get('system', 'explicit_content', false) == true; + + System::jsonExit($nodeinfo, 'application/json; charset=utf-8', JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } +} diff --git a/src/Module/NodeInfo210.php b/src/Module/NodeInfo210.php new file mode 100644 index 0000000000..8512f6d077 --- /dev/null +++ b/src/Module/NodeInfo210.php @@ -0,0 +1,81 @@ +. + * + */ + +namespace Friendica\Module; + +use Friendica\BaseModule; +use Friendica\Core\Addon; +use Friendica\Core\System; +use Friendica\DI; +use Friendica\Model\Nodeinfo; + +/** + * Version 1.0 of Nodeinfo 2, a sStandardized way of exposing metadata about a server running one of the distributed social networks. + * @see https://github.com/jhass/nodeinfo/blob/master/PROTOCOL.md + */ +class NodeInfo210 extends BaseModule +{ + public static function rawContent(array $parameters = []) + { + $config = DI::config(); + + $nodeinfo = [ + 'version' => '1.0', + 'server' => [ + 'baseUrl' => DI::baseUrl()->get(), + 'name' => $config->get('config', 'sitename'), + 'software' => 'friendica', + 'version' => FRIENDICA_VERSION . '-' . DB_UPDATE_VERSION, + ], + 'organization' => Nodeinfo::getOrganization($config), + 'protocols' => ['dfrn', 'activitypub'], + 'services' => [], + 'openRegistrations' => intval($config->get('config', 'register_policy')) !== Register::CLOSED, + 'usage' => [], + ]; + + if (!empty($config->get('system', 'diaspora_enabled'))) { + $nodeinfo['protocols'][] = 'diaspora'; + } + + if (empty($config->get('system', 'ostatus_disabled'))) { + $nodeinfo['protocols'][] = 'ostatus'; + } + + $nodeinfo['usage'] = Nodeinfo::getUsage(true); + + $nodeinfo['services'] = Nodeinfo::getServices(); + + if (Addon::isEnabled('twitter')) { + $nodeinfo['services']['inbound'][] = 'twitter'; + } + + $nodeinfo['services']['inbound'][] = 'atom1.0'; + $nodeinfo['services']['inbound'][] = 'rss2.0'; + $nodeinfo['services']['outbound'][] = 'atom1.0'; + + if (function_exists('imap_open') && !$config->get('system', 'imap_disabled') && !$config->get('system', 'dfrn_only')) { + $nodeinfo['services']['inbound'][] = 'imap'; + } + + System::jsonExit($nodeinfo, 'application/json; charset=utf-8', JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } +} diff --git a/src/Module/Notifications/Introductions.php b/src/Module/Notifications/Introductions.php index 0b1cb9e6a3..ccd5963877 100644 --- a/src/Module/Notifications/Introductions.php +++ b/src/Module/Notifications/Introductions.php @@ -23,10 +23,12 @@ namespace Friendica\Module\Notifications; use Friendica\Content\ContactSelector; use Friendica\Content\Nav; +use Friendica\Content\Text\BBCode; use Friendica\Core\Protocol; use Friendica\Core\Renderer; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Model\User; use Friendica\Module\BaseNotifications; use Friendica\Object\Notification\Introduction; @@ -76,6 +78,8 @@ class Introductions extends BaseNotifications 'text' => (!$all ? DI::l10n()->t('Show Ignored Requests') : DI::l10n()->t('Hide Ignored Requests')), ]; + $owner = User::getOwnerDataById(local_user()); + // Loop through all introduction notifications.This creates an array with the output html for each // introduction /** @var Introduction $notification */ @@ -98,17 +102,17 @@ class Introductions extends BaseNotifications '$contact_id' => $notification->getContactId(), '$photo' => $notification->getPhoto(), '$fullname' => $notification->getName(), + '$dfrn_url' => $owner['url'], '$url' => $notification->getUrl(), '$zrl' => $notification->getZrl(), '$lbl_url' => DI::l10n()->t('Profile URL'), '$addr' => $notification->getAddr(), - '$hidden' => ['hidden', DI::l10n()->t('Hide this contact from others'), $notification->isHidden(), ''], - '$knowyou' => $notification->getKnowYou(), + '$action' => 'follow', '$approve' => DI::l10n()->t('Approve'), '$note' => $notification->getNote(), - '$request' => $notification->getRequest(), '$ignore' => DI::l10n()->t('Ignore'), '$discard' => DI::l10n()->t('Discard'), + '$is_mobile' => DI::mode()->isMobile(), ]); break; @@ -122,10 +126,12 @@ class Introductions extends BaseNotifications $knowyou = ''; } - $helptext = DI::l10n()->t('Shall your connection be bidirectional or not?'); - $helptext2 = DI::l10n()->t('Accepting %s as a friend allows %s to subscribe to your posts, and you will also receive updates from them in your news feed.', $notification->getName(), $notification->getName()); - $helptext3 = DI::l10n()->t('Accepting %s as a subscriber allows them to subscribe to your posts, but you will not receive updates from them in your news feed.', $notification->getName()); + $convertedName = BBCode::convert($notification->getName()); + $helptext = DI::l10n()->t('Shall your connection be bidirectional or not?'); + $helptext2 = DI::l10n()->t('Accepting %s as a friend allows %s to subscribe to your posts, and you will also receive updates from them in your news feed.', $convertedName, $convertedName); + $helptext3 = DI::l10n()->t('Accepting %s as a subscriber allows them to subscribe to your posts, but you will not receive updates from them in your news feed.', $convertedName); + $friend = ['duplex', DI::l10n()->t('Friend'), '1', $helptext2, true]; $follower = ['duplex', DI::l10n()->t('Subscriber'), '0', $helptext3, false]; @@ -185,13 +191,14 @@ class Introductions extends BaseNotifications '$ignore' => DI::l10n()->t('Ignore'), '$discard' => $discard, '$action' => $action, + '$is_mobile' => DI::mode()->isMobile(), ]); break; } } if (count($notifications['notifications']) == 0) { - info(DI::l10n()->t('No introductions.') . EOL); + notice(DI::l10n()->t('No introductions.')); $notificationNoContent = DI::l10n()->t('No more %s notifications.', $notifications['ident']); } diff --git a/src/Module/Notifications/Notification.php b/src/Module/Notifications/Notification.php index 2dc008248e..4566c4223e 100644 --- a/src/Module/Notifications/Notification.php +++ b/src/Module/Notifications/Notification.php @@ -108,7 +108,13 @@ class Notification extends BaseModule if ($request_id) { $notify = DI::notify()->getByID($request_id, local_user()); - DI::notify()->setSeen(true, $notify); + + if (DI::pConfig()->get(local_user(), 'system', 'detailed_notif')) { + $notify->seen = true; + DI::notify()->update($notify); + } else { + DI::notify()->setSeen(true, $notify); + } if (!empty($notify->link)) { System::externalRedirect($notify->link); diff --git a/src/Module/Objects.php b/src/Module/Objects.php index b5b5227141..cbe2e53fe0 100644 --- a/src/Module/Objects.php +++ b/src/Module/Objects.php @@ -22,13 +22,17 @@ namespace Friendica\Module; use Friendica\BaseModule; +use Friendica\Core\Logger; use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Model\Contact; use Friendica\Model\Item; use Friendica\Network\HTTPException; use Friendica\Protocol\ActivityPub; +use Friendica\Util\HTTPSignature; use Friendica\Util\Network; +use Friendica\Util\Strings; /** * ActivityPub Objects @@ -45,19 +49,51 @@ class Objects extends BaseModule DI::baseUrl()->redirect(str_replace('objects/', 'display/', DI::args()->getQueryString())); } - /// @todo Add Authentication to enable fetching of non public content - // $requester = HTTPSignature::getSigner('', $_SERVER); + $itemuri = DBA::selectFirst('item-uri', ['id'], ['guid' => $parameters['guid']]); + + if (DBA::isResult($itemuri)) { + Logger::info('Provided GUID found.', ['guid' => $parameters['guid'], 'uri-id' => $itemuri['id']]); + } else { + // The item URI does not always contain the GUID. This means that we have to search the URL instead + $url = DI::baseUrl()->get() . '/' . DI::args()->getQueryString(); + $nurl = Strings::normaliseLink($url); + $ssl_url = str_replace('http://', 'https://', $nurl); + + $itemuri = DBA::selectFirst('item-uri', ['guid', 'id'], ['uri' => [$url, $nurl, $ssl_url]]); + if (DBA::isResult($itemuri)) { + Logger::info('URL found.', ['url' => $url, 'guid' => $itemuri['guid'], 'uri-id' => $itemuri['id']]); + } else { + Logger::info('URL not found.', ['url' => $url]); + throw new HTTPException\NotFoundException(); + } + } + + $item = Item::selectFirst(['id', 'uid', 'origin', 'author-link', 'changed', 'private', 'psid', 'gravity'], + ['uri-id' => $itemuri['id']], ['order' => ['origin' => true]]); + + if (!DBA::isResult($item)) { + throw new HTTPException\NotFoundException(); + } + + $validated = in_array($item['private'], [Item::PUBLIC, Item::UNLISTED]); + + if (!$validated) { + $requester = HTTPSignature::getSigner('', $_SERVER); + if (!empty($requester) && $item['origin']) { + $requester_id = Contact::getIdForURL($requester, $item['uid']); + if (!empty($requester_id)) { + $permissionSets = DI::permissionSet()->selectByContactId($requester_id, $item['uid']); + if (!empty($permissionSets)) { + $psid = array_merge($permissionSets->column('id'), + [DI::permissionSet()->getIdFromACL($item['uid'], '', '', '', '')]); + $validated = in_array($item['psid'], $psid); + } + } + } + } - $item = Item::selectFirst( - ['id', 'origin', 'author-link', 'changed'], - [ - 'guid' => $parameters['guid'], - 'private' => [Item::PUBLIC, Item::UNLISTED] - ], - ['order' => ['origin' => true]] - ); // Valid items are original post or posted from this node (including in the case of a forum) - if (!DBA::isResult($item) || !$item['origin'] && !strstr($item['author-link'], DI::baseUrl()->get())) { + if (!$validated || !$item['origin'] && (parse_url($item['author-link'], PHP_URL_HOST) != parse_url(DI::baseUrl()->get(), PHP_URL_HOST))) { throw new HTTPException\NotFoundException(); } @@ -65,17 +101,36 @@ class Objects extends BaseModule $last_modified = $item['changed']; Network::checkEtagModified($etag, $last_modified); - $activity = ActivityPub\Transmitter::createActivityFromItem($item['id'], true); - $activity['type'] = $activity['type'] == 'Update' ? 'Create' : $activity['type']; + if (empty($parameters['activity']) && ($item['gravity'] != GRAVITY_ACTIVITY)) { + $activity = ActivityPub\Transmitter::createActivityFromItem($item['id'], true); + if (empty($activity['type'])) { + throw new HTTPException\NotFoundException(); + } - // Only display "Create" activity objects here, no reshares or anything else - if (empty($activity['object']) || ($activity['type'] != 'Create')) { + $activity['type'] = $activity['type'] == 'Update' ? 'Create' : $activity['type']; + + // Only display "Create" activity objects here, no reshares or anything else + if (empty($activity['object']) || ($activity['type'] != 'Create')) { + throw new HTTPException\NotFoundException(); + } + + $data = ['@context' => ActivityPub::CONTEXT]; + $data = array_merge($data, $activity['object']); + } elseif (empty($parameters['activity']) || in_array($parameters['activity'], + ['Create', 'Announce', 'Update', 'Like', 'Dislike', 'Accept', 'Reject', + 'TentativeAccept', 'Follow', 'Add'])) { + $data = ActivityPub\Transmitter::createActivityFromItem($item['id']); + if (empty($data)) { + throw new HTTPException\NotFoundException(); + } + if (!empty($parameters['activity']) && ($parameters['activity'] != 'Create')) { + $data['type'] = $parameters['activity']; + $data['id'] = str_replace('/Create', '/' . $parameters['activity'], $data['id']); + } + } else { throw new HTTPException\NotFoundException(); } - $data = ['@context' => ActivityPub::CONTEXT]; - $data = array_merge($data, $activity['object']); - // Relaxed CORS header for public items header('Access-Control-Allow-Origin: *'); System::jsonExit($data, 'application/activity+json'); diff --git a/src/Module/Outbox.php b/src/Module/Outbox.php index 265c130b18..3a822cc6e5 100644 --- a/src/Module/Outbox.php +++ b/src/Module/Outbox.php @@ -22,10 +22,10 @@ namespace Friendica\Module; use Friendica\BaseModule; -use Friendica\Core\System; use Friendica\DI; use Friendica\Model\User; use Friendica\Protocol\ActivityPub; +use Friendica\Util\HTTPSignature; /** * ActivityPub Outbox @@ -48,11 +48,8 @@ class Outbox extends BaseModule $page = $_REQUEST['page'] ?? null; - /// @todo Add Authentication to enable fetching of non public content - // $requester = HTTPSignature::getSigner('', $_SERVER); - - $outbox = ActivityPub\Transmitter::getOutbox($owner, $page); - + $requester = HTTPSignature::getSigner('', $_SERVER); + $outbox = ActivityPub\Transmitter::getOutbox($owner, $page, $requester); header('Content-Type: application/activity+json'); echo json_encode($outbox); exit(); diff --git a/src/Module/Owa.php b/src/Module/Owa.php index 9a8d8fbb65..322cafa097 100644 --- a/src/Module/Owa.php +++ b/src/Module/Owa.php @@ -76,8 +76,7 @@ class Owa extends BaseModule $verified = HTTPSignature::verifyMagic($contact['pubkey']); if ($verified && $verified['header_signed'] && $verified['header_valid']) { - Logger::log('OWA header: ' . print_r($verified, true), Logger::DATA); - Logger::log('OWA success: ' . $contact['addr'], Logger::DATA); + Logger::debug('OWA header', ['addr' => $contact['addr'], 'data' => $verified]); $ret['success'] = true; $token = Strings::getRandomHex(32); @@ -94,10 +93,10 @@ class Owa extends BaseModule openssl_public_encrypt($token, $result, $contact['pubkey']); $ret['encrypted_token'] = Strings::base64UrlEncode($result); } else { - Logger::log('OWA fail: ' . $contact['id'] . ' ' . $contact['addr'] . ' ' . $contact['url'], Logger::DEBUG); + Logger::info('OWA fail', ['id' => $contact['id'], 'addr' => $contact['addr'], 'url' => $contact['url']]); } } else { - Logger::log('Contact not found: ' . $handle, Logger::DEBUG); + Logger::info('Contact not found', ['handle' => $handle]); } } } diff --git a/src/Module/PermissionTooltip.php b/src/Module/PermissionTooltip.php new file mode 100644 index 0000000000..3e760ef1e0 --- /dev/null +++ b/src/Module/PermissionTooltip.php @@ -0,0 +1,120 @@ +t('Wrong type "%s", expected one of: %s', $type, implode(', ', $expectedTypes))); + } + + $condition = ['id' => $referenceId]; + if ($type == 'item') { + $fields = ['uid', 'psid', 'private']; + $model = Item::selectFirst($fields, $condition); + } else { + $fields = ['uid', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid']; + $model = DBA::selectFirst($type, $fields, $condition); + } + + if (!DBA::isResult($model)) { + throw new HttpException\NotFoundException(DI::l10n()->t('Model not found')); + } + + if (isset($model['psid'])) { + $permissionSet = DI::permissionSet()->selectFirst(['id' => $model['psid']]); + $model['allow_cid'] = $permissionSet->allow_cid; + $model['allow_gid'] = $permissionSet->allow_gid; + $model['deny_cid'] = $permissionSet->deny_cid; + $model['deny_gid'] = $permissionSet->deny_gid; + } + + // Kept for backwards compatiblity + Hook::callAll('lockview_content', $model); + + if ($model['uid'] != local_user() || + isset($model['private']) + && $model['private'] == Item::PRIVATE + && empty($model['allow_cid']) + && empty($model['allow_gid']) + && empty($model['deny_cid']) + && empty($model['deny_gid'])) + { + echo DI::l10n()->t('Remote privacy information not available.'); + exit; + } + + $aclFormatter = DI::aclFormatter(); + + $allowed_users = $aclFormatter->expand($model['allow_cid']); + $allowed_groups = $aclFormatter->expand($model['allow_gid']); + $deny_users = $aclFormatter->expand($model['deny_cid']); + $deny_groups = $aclFormatter->expand($model['deny_gid']); + + $o = DI::l10n()->t('Visible to:') . '
    '; + $l = []; + + if (count($allowed_groups)) { + $key = array_search(Group::FOLLOWERS, $allowed_groups); + if ($key !== false) { + $l[] = '' . DI::l10n()->t('Followers') . ''; + unset($allowed_groups[$key]); + } + + $key = array_search(Group::MUTUALS, $allowed_groups); + if ($key !== false) { + $l[] = '' . DI::l10n()->t('Mutuals') . ''; + unset($allowed_groups[$key]); + } + + foreach (DI::dba()->selectToArray('group', ['name'], ['id' => $allowed_groups]) as $group) { + $l[] = '' . $group['name'] . ''; + } + } + + foreach (DI::dba()->selectToArray('contact', ['name'], ['id' => $allowed_users]) as $contact) { + $l[] = $contact['name']; + } + + if (count($deny_groups)) { + $key = array_search(Group::FOLLOWERS, $deny_groups); + if ($key !== false) { + $l[] = '' . DI::l10n()->t('Followers') . ''; + unset($deny_groups[$key]); + } + + $key = array_search(Group::MUTUALS, $deny_groups); + if ($key !== false) { + $l[] = '' . DI::l10n()->t('Mutuals') . ''; + unset($deny_groups[$key]); + } + + foreach (DI::dba()->selectToArray('group', ['name'], ['id' => $allowed_groups]) as $group) { + $l[] = '' . $group['name'] . ''; + } + } + + foreach (DI::dba()->selectToArray('contact', ['name'], ['id' => $deny_users]) as $contact) { + $l[] = '' . $contact['name'] . ''; + } + + echo $o . implode(', ', $l); + exit(); + } +} diff --git a/src/Module/Photo.php b/src/Module/Photo.php index 826d86bdd5..64d9e3542a 100644 --- a/src/Module/Photo.php +++ b/src/Module/Photo.php @@ -23,9 +23,12 @@ namespace Friendica\Module; use Friendica\BaseModule; use Friendica\Core\Logger; -use Friendica\Core\System; +use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Model\Contact; use Friendica\Model\Photo as MPhoto; +use Friendica\Util\Proxy; +use Friendica\Object\Image; /** * Photo Module @@ -38,8 +41,9 @@ class Photo extends BaseModule * Fetch a photo or an avatar, in optional size, check for permissions and * return the image */ - public static function init(array $parameters = []) + public static function rawContent(array $parameters = []) { + $totalstamp = microtime(true); $a = DI::app(); // @TODO: Replace with parameter from router if ($a->argc <= 1 || $a->argc > 4) { @@ -64,7 +68,9 @@ class Photo extends BaseModule $customsize = 0; $photo = false; + $scale = null; // @TODO: Replace with parameter from router + $stamp = microtime(true); switch($a->argc) { case 4: $customsize = intval($a->argv[2]); @@ -88,6 +94,7 @@ class Photo extends BaseModule } break; } + $fetch = microtime(true) - $stamp; if ($photo === false) { throw new \Friendica\Network\HTTPException\NotFoundException(); @@ -95,16 +102,20 @@ class Photo extends BaseModule $cacheable = ($photo["allow_cid"] . $photo["allow_gid"] . $photo["deny_cid"] . $photo["deny_gid"] === "") && (isset($photo["cacheable"]) ? $photo["cacheable"] : true); - $img = MPhoto::getImageForPhoto($photo); + $stamp = microtime(true); + $imgdata = MPhoto::getImageDataForPhoto($photo); + $data = microtime(true) - $stamp; - if (is_null($img) || !$img->isValid()) { - Logger::log("Invalid photo with id {$photo["id"]}."); + if (empty($imgdata)) { + Logger::warning("Invalid photo with id {$photo["id"]}."); throw new \Friendica\Network\HTTPException\InternalServerErrorException(DI::l10n()->t('Invalid photo with id %s.', $photo["id"])); } // if customsize is set and image is not a gif, resize it - if ($img->getType() !== "image/gif" && $customsize > 0 && $customsize < 501) { + if ($photo['type'] !== "image/gif" && $customsize > 0 && $customsize < 501) { + $img = new Image($imgdata, $photo['type']); $img->scaleToSquare($customsize); + $imgdata = $img->asString(); } if (function_exists("header_remove")) { @@ -112,50 +123,74 @@ class Photo extends BaseModule header_remove("pragma"); } - header("Content-type: " . $img->getType()); + header("Content-type: " . $photo['type']); + $stamp = microtime(true); if (!$cacheable) { // it is a private photo that they have no permission to view. // tell the browser not to cache it, in case they authenticate // and subsequently have permission to see it header("Cache-Control: no-store, no-cache, must-revalidate"); } else { - $md5 = md5($img->asString()); + $md5 = $photo['hash'] ?: md5($imgdata); header("Last-Modified: " . gmdate("D, d M Y H:i:s", time()) . " GMT"); header("Etag: \"{$md5}\""); header("Expires: " . gmdate("D, d M Y H:i:s", time() + (31536000)) . " GMT"); header("Cache-Control: max-age=31536000"); } + $checksum = microtime(true) - $stamp; - echo $img->asString(); + $stamp = microtime(true); + echo $imgdata; + $output = microtime(true) - $stamp; + + $total = microtime(true) - $totalstamp; + $rest = $total - ($fetch + $data + $checksum + $output); + + if (!is_null($scale) && ($scale < 4)) { + Logger::info('Performance:', ['scale' => $scale, 'resource' => $photo['resource-id'], + 'total' => number_format($total, 3), 'fetch' => number_format($fetch, 3), + 'data' => number_format($data, 3), 'checksum' => number_format($checksum, 3), + 'output' => number_format($output, 3), 'rest' => number_format($rest, 3)]); + } exit(); } private static function getAvatar($uid, $type="avatar") { - switch($type) { - case "profile": - case "custom": - $scale = 4; - $default = "images/person-300.jpg"; - break; - case "micro": - $scale = 6; - $default = "images/person-48.jpg"; - break; - case "avatar": - default: - $scale = 5; - $default = "images/person-80.jpg"; + case "profile": + case "custom": + $scale = 4; + break; + case "micro": + $scale = 6; + break; + case "avatar": + default: + $scale = 5; } $photo = MPhoto::selectFirst([], ["scale" => $scale, "uid" => $uid, "profile" => 1]); - if ($photo === false) { + if (empty($photo)) { + $contact = DBA::selectFirst('contact', [], ['uid' => $uid, 'self' => true]) ?: []; + + switch($type) { + case "profile": + case "custom": + $default = Contact::getDefaultAvatar($contact, Proxy::SIZE_SMALL); + break; + case "micro": + $default = Contact::getDefaultAvatar($contact, Proxy::SIZE_MICRO); + break; + case "avatar": + default: + $default = Contact::getDefaultAvatar($contact, Proxy::SIZE_THUMB); + } + $photo = MPhoto::createPhotoForSystemResource($default); } return $photo; } - } diff --git a/src/Module/Profile/Common.php b/src/Module/Profile/Common.php new file mode 100644 index 0000000000..ee08177525 --- /dev/null +++ b/src/Module/Profile/Common.php @@ -0,0 +1,107 @@ +. + * + */ + +namespace Friendica\Module\Profile; + +use Friendica\Content\Nav; +use Friendica\Content\Pager; +use Friendica\Core\Protocol; +use Friendica\Core\Renderer; +use Friendica\Core\Session; +use Friendica\Module; +use Friendica\DI; +use Friendica\Model\Contact; +use Friendica\Model\Profile; +use Friendica\Module\BaseProfile; +use Friendica\Network\HTTPException; + +class Common extends BaseProfile +{ + public static function content(array $parameters = []) + { + if (DI::config()->get('system', 'block_public') && !Session::isAuthenticated()) { + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found.')); + } + + $a = DI::app(); + + Nav::setSelected('home'); + + $nickname = $parameters['nickname']; + + Profile::load($a, $nickname); + + if (empty($a->profile)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found.')); + } + + if (!empty($a->profile['hide-friends'])) { + throw new HTTPException\ForbiddenException(DI::l10n()->t('Permission denied.')); + } + + $displayCommonTab = Session::isAuthenticated() && $a->profile['uid'] != local_user(); + + if (!$displayCommonTab) { + $a->redirect('profile/' . $nickname . '/contacts'); + }; + + $o = self::getTabsHTML($a, 'contacts', false, $nickname); + + $tabs = self::getContactFilterTabs('profile/' . $nickname, 'common', $displayCommonTab); + + $sourceId = Contact::getIdForURL(Profile::getMyURL()); + $targetId = Contact::getPublicIdByUserId($a->profile['uid']); + + $condition = [ + 'blocked' => false, + 'deleted' => false, + 'network' => [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA, Protocol::OSTATUS, Protocol::FEED], + ]; + + $total = Contact\Relation::countCommon($sourceId, $targetId, $condition); + + $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), 30); + + $commonFollows = Contact\Relation::listCommon($sourceId, $targetId, $condition, $pager->getItemsPerPage(), $pager->getStart()); + + $contacts = array_map([Module\Contact::class, 'getContactTemplateVars'], $commonFollows); + + $title = DI::l10n()->tt('Common contact (%s)', 'Common contacts (%s)', $total); + $desc = DI::l10n()->t( + 'Both %s and yourself have publicly interacted with these contacts (follow, comment or likes on public posts).', + htmlentities($a->profile['name'], ENT_COMPAT, 'UTF-8') + ); + + $tpl = Renderer::getMarkupTemplate('profile/contacts.tpl'); + $o .= Renderer::replaceMacros($tpl, [ + '$title' => $title, + '$desc' => $desc, + '$tabs' => $tabs, + + '$noresult_label' => DI::l10n()->t('No common contacts.'), + + '$contacts' => $contacts, + '$paginate' => $pager->renderFull($total), + ]); + + return $o; + } +} diff --git a/src/Module/Profile/Contacts.php b/src/Module/Profile/Contacts.php index 3a42b0d311..654414f35c 100644 --- a/src/Module/Profile/Contacts.php +++ b/src/Module/Profile/Contacts.php @@ -21,7 +21,6 @@ namespace Friendica\Module\Profile; -use Friendica\Content\ContactSelector; use Friendica\Content\Nav; use Friendica\Content\Pager; use Friendica\Core\Protocol; @@ -29,44 +28,40 @@ use Friendica\Core\Renderer; use Friendica\Core\Session; use Friendica\Database\DBA; use Friendica\DI; -use Friendica\Model\Contact; -use Friendica\Model\Profile; -use Friendica\Module\BaseProfile; -use Friendica\Util\Proxy as ProxyUtils; +use Friendica\Model; +use Friendica\Module; +use Friendica\Network\HTTPException; -class Contacts extends BaseProfile +class Contacts extends Module\BaseProfile { public static function content(array $parameters = []) { if (DI::config()->get('system', 'block_public') && !Session::isAuthenticated()) { - throw new \Friendica\Network\HTTPException\NotFoundException(DI::l10n()->t('User not found.')); + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found.')); } $a = DI::app(); - //@TODO: Get value from router parameters - $nickname = $a->argv[1]; - $type = ($a->argv[3] ?? '') ?: 'all'; + $nickname = $parameters['nickname']; + $type = $parameters['type'] ?? 'all'; - Nav::setSelected('home'); + Model\Profile::load($a, $nickname); - $user = DBA::selectFirst('user', [], ['nickname' => $nickname, 'blocked' => false]); - if (!DBA::isResult($user)) { - throw new \Friendica\Network\HTTPException\NotFoundException(DI::l10n()->t('User not found.')); + if (empty($a->profile)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found.')); } - $a->profile_uid = $user['uid']; - - Profile::load($a, $nickname); - $is_owner = $a->profile['uid'] == local_user(); + if (!empty($a->profile['hide-friends']) && !$is_owner) { + throw new HTTPException\ForbiddenException(DI::l10n()->t('Permission denied.')); + } + + Nav::setSelected('home'); + $o = self::getTabsHTML($a, 'contacts', $is_owner, $nickname); - if (!count($a->profile) || $a->profile['hide-friends']) { - notice(DI::l10n()->t('Permission denied.') . EOL); - return $o; - } + $tabs = self::getContactFilterTabs('profile/' . $nickname, $type, Session::isAuthenticated() && $a->profile['uid'] != local_user()); $condition = [ 'uid' => $a->profile['uid'], @@ -74,75 +69,56 @@ class Contacts extends BaseProfile 'pending' => false, 'hidden' => false, 'archive' => false, + 'failed' => false, + 'self' => false, 'network' => [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA, Protocol::OSTATUS, Protocol::FEED] ]; switch ($type) { - case 'followers': $condition['rel'] = [1, 3]; break; - case 'following': $condition['rel'] = [2, 3]; break; - case 'mutuals': $condition['rel'] = 3; break; + case 'followers': $condition['rel'] = [Model\Contact::FOLLOWER, Model\Contact::FRIEND]; break; + case 'following': $condition['rel'] = [Model\Contact::SHARING, Model\Contact::FRIEND]; break; + case 'mutuals': $condition['rel'] = Model\Contact::FRIEND; break; } $total = DBA::count('contact', $condition); - $pager = new Pager(DI::l10n(), DI::args()->getQueryString()); + $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), 30); $params = ['order' => ['name' => false], 'limit' => [$pager->getStart(), $pager->getItemsPerPage()]]; - $contacts_stmt = DBA::select('contact', [], $condition, $params); - - if (!DBA::isResult($contacts_stmt)) { - info(DI::l10n()->t('No contacts.') . EOL); - return $o; - } - - $contacts = []; - - while ($contact = DBA::fetch($contacts_stmt)) { - if ($contact['self']) { - continue; - } - - $contact_details = Contact::getDetailsByURL($contact['url'], $a->profile['uid'], $contact); - - $contacts[] = [ - 'id' => $contact['id'], - 'img_hover' => DI::l10n()->t('Visit %s\'s profile [%s]', $contact_details['name'], $contact['url']), - 'photo_menu' => Contact::photoMenu($contact), - 'thumb' => ProxyUtils::proxifyUrl($contact_details['thumb'], false, ProxyUtils::SIZE_THUMB), - 'name' => substr($contact_details['name'], 0, 20), - 'username' => $contact_details['name'], - 'details' => $contact_details['location'], - 'tags' => $contact_details['keywords'], - 'about' => $contact_details['about'], - 'account_type' => Contact::getAccountType($contact_details), - 'url' => Contact::magicLink($contact['url']), - 'sparkle' => '', - 'itemurl' => $contact_details['addr'] ? : $contact['url'], - 'network' => ContactSelector::networkToName($contact['network'], $contact['url'], $contact['protocol']), - ]; - } - - DBA::close($contacts_stmt); + $contacts = array_map( + [Module\Contact::class, 'getContactTemplateVars'], + Model\Contact::selectToArray([], $condition, $params) + ); + $desc = ''; switch ($type) { - case 'followers': $title = DI::l10n()->tt('Follower (%s)', 'Followers (%s)', $total); break; - case 'following': $title = DI::l10n()->tt('Following (%s)', 'Following (%s)', $total); break; - case 'mutuals': $title = DI::l10n()->tt('Mutual friend (%s)', 'Mutual friends (%s)', $total); break; - - case 'all': default: $title = DI::l10n()->tt('Contact (%s)', 'Contacts (%s)', $total); break; + case 'followers': + $title = DI::l10n()->tt('Follower (%s)', 'Followers (%s)', $total); + break; + case 'following': + $title = DI::l10n()->tt('Following (%s)', 'Following (%s)', $total); + break; + case 'mutuals': + $title = DI::l10n()->tt('Mutual friend (%s)', 'Mutual friends (%s)', $total); + $desc = DI::l10n()->t( + 'These contacts both follow and are followed by %s.', + htmlentities($a->profile['name'], ENT_COMPAT, 'UTF-8') + ); + break; + case 'all': + default: + $title = DI::l10n()->tt('Contact (%s)', 'Contacts (%s)', $total); + break; } $tpl = Renderer::getMarkupTemplate('profile/contacts.tpl'); $o .= Renderer::replaceMacros($tpl, [ '$title' => $title, - '$nickname' => $nickname, - '$type' => $type, + '$desc' => $desc, + '$tabs' => $tabs, - '$all_label' => DI::l10n()->t('All contacts'), - '$followers_label' => DI::l10n()->t('Followers'), - '$following_label' => DI::l10n()->t('Following'), - '$mutuals_label' => DI::l10n()->t('Mutual friends'), + '$noresult_label' => DI::l10n()->t('No contacts.'), '$contacts' => $contacts, '$paginate' => $pager->renderFull($total), diff --git a/src/Module/Profile/Profile.php b/src/Module/Profile/Profile.php index c187281d33..6b7da3d716 100644 --- a/src/Module/Profile/Profile.php +++ b/src/Module/Profile/Profile.php @@ -112,6 +112,7 @@ class Profile extends BaseProfile $view_as_contacts = []; $view_as_contact_id = 0; + $view_as_contact_alert = ''; if ($is_owner) { $view_as_contact_id = intval($_GET['viewas'] ?? 0); @@ -122,10 +123,20 @@ class Profile extends BaseProfile 'blocked' => false, ]); + $view_as_contact_ids = array_column($view_as_contacts, 'id'); + // User manually provided a contact ID they aren't privy to, silently defaulting to their own view - if (!in_array($view_as_contact_id, array_column($view_as_contacts, 'id'))) { + if (!in_array($view_as_contact_id, $view_as_contact_ids)) { $view_as_contact_id = 0; } + + if (($key = array_search($view_as_contact_id, $view_as_contact_ids)) !== false) { + $view_as_contact_alert = DI::l10n()->t( + 'You\'re currently viewing your profile as %s Cancel', + htmlentities($view_as_contacts[$key]['name'], ENT_COMPAT, 'UTF-8'), + 'profile/' . $parameters['nickname'] . '/profile' + ); + } } $basic_fields = []; @@ -220,12 +231,15 @@ class Profile extends BaseProfile ); } - $tpl = Renderer::getMarkupTemplate('profile/index.tpl'); + $tpl = Renderer::getMarkupTemplate('profile/profile.tpl'); $o .= Renderer::replaceMacros($tpl, [ '$title' => DI::l10n()->t('Profile'), + '$yourself' => DI::l10n()->t('Yourself'), '$view_as_contacts' => $view_as_contacts, '$view_as_contact_id' => $view_as_contact_id, + '$view_as_contact_alert' => $view_as_contact_alert, '$view_as' => DI::l10n()->t('View profile as:'), + '$submit' => DI::l10n()->t('Submit'), '$basic' => DI::l10n()->t('Basic'), '$advanced' => DI::l10n()->t('Advanced'), '$is_owner' => $a->profile_uid == local_user(), @@ -238,6 +252,11 @@ class Profile extends BaseProfile 'title' => '', 'label' => DI::l10n()->t('Edit profile') ], + '$viewas_link' => [ + 'url' => DI::args()->getQueryString() . '#viewas', + 'title' => '', + 'label' => DI::l10n()->t('View as') + ], ]); Hook::callAll('profile_advanced', $o); diff --git a/src/Module/Profile/Status.php b/src/Module/Profile/Status.php index 75ba60cb26..cadb9aa4b8 100644 --- a/src/Module/Profile/Status.php +++ b/src/Module/Profile/Status.php @@ -25,6 +25,7 @@ use Friendica\Content\Nav; use Friendica\Content\Pager; use Friendica\Content\Widget; use Friendica\Core\ACL; +use Friendica\Core\Protocol; use Friendica\Core\Session; use Friendica\Database\DBA; use Friendica\DI; @@ -32,10 +33,13 @@ use Friendica\Model\Item; use Friendica\Model\Post\Category; use Friendica\Model\Profile as ProfileModel; use Friendica\Model\User; +use Friendica\Model\Verb; use Friendica\Module\BaseProfile; use Friendica\Module\Security\Login; +use Friendica\Network\HTTPException; +use Friendica\Protocol\Activity; use Friendica\Util\DateTimeFormat; -use Friendica\Util\Security; +use Friendica\Security\Security; use Friendica\Util\Strings; use Friendica\Util\XML; @@ -49,6 +53,10 @@ class Status extends BaseProfile ProfileModel::load($a, $parameters['nickname']); + if (empty($a->profile)) { + throw new HTTPException\NotFoundException(DI::l10n()->t('User not found.')); + } + if (!$a->profile['net-publish']) { DI::page()['htmlhead'] .= '' . "\n"; } @@ -97,13 +105,13 @@ class Status extends BaseProfile $last_updated_key = "profile:" . $a->profile['uid'] . ":" . local_user() . ":" . $remote_contact; if (!empty($a->profile['hidewall']) && !$is_owner && !$remote_contact) { - notice(DI::l10n()->t('Access to this profile has been restricted.') . EOL); + notice(DI::l10n()->t('Access to this profile has been restricted.')); return ''; } $o .= self::getTabsHTML($a, 'status', $is_owner, $a->profile['nickname']); - $o .= Widget::commonFriendsVisitor($a->profile['uid']); + $o .= Widget::commonFriendsVisitor($a->profile['uid'], $a->profile['nickname']); $commpage = $a->profile['page-flags'] == User::PAGE_FLAGS_COMMUNITY; $commvisitor = $commpage && $remote_contact; @@ -134,37 +142,32 @@ class Status extends BaseProfile } // Get permissions SQL - if $remote_contact is true, our remote user has been pre-verified and we already have fetched his/her groups - $sql_extra = Item::getPermissionsSQLByUserId($a->profile['uid']); - $sql_extra2 = ''; + $condition = Item::getPermissionsConditionArrayByUserId($a->profile['uid']); $last_updated_array = Session::get('last_updated', []); - $sql_post_table = ""; - if (!empty($category)) { - $sql_post_table = sprintf("INNER JOIN (SELECT `uri-id` FROM `category-view` WHERE `name` = '%s' AND `type` = %d AND `uid` = %d ORDER BY `uri-id` DESC) AS `category` ON `item`.`uri-id` = `category`.`uri-id` ", - DBA::escape(Strings::protectSprintf($category)), intval(Category::CATEGORY), intval($a->profile['uid'])); + $condition = DBA::mergeConditions($condition, ["`uri-id` IN (SELECT `uri-id` FROM `category-view` WHERE `name` = ? AND `type` = ? AND `uid` = ?)", + $category, Category::CATEGORY, $a->profile['uid']]); } if (!empty($hashtags)) { - $sql_post_table .= sprintf("INNER JOIN (SELECT `uri-id` FROM `tag-search-view` WHERE `name` = '%s' AND `uid` = %d ORDER BY `uri-id` DESC) AS `tag-search` ON `item`.`uri-id` = `tag-search`.`uri-id` ", - DBA::escape(Strings::protectSprintf($hashtags)), intval($a->profile['uid'])); + $condition = DBA::mergeConditions($condition, ["`uri-id` IN (SELECT `uri-id` FROM `tag-search-view` WHERE `name` = ? AND `uid` = ?)", + $hashtags, $a->profile['uid']]); } if (!empty($datequery)) { - $sql_extra2 .= Strings::protectSprintf(sprintf(" AND `thread`.`received` <= '%s' ", DBA::escape(DateTimeFormat::convert($datequery, 'UTC', date_default_timezone_get())))); + $condition = DBA::mergeConditions($condition, ["`received` <= ?", DateTimeFormat::convert($datequery, 'UTC', date_default_timezone_get())]); } if (!empty($datequery2)) { - $sql_extra2 .= Strings::protectSprintf(sprintf(" AND `thread`.`received` >= '%s' ", DBA::escape(DateTimeFormat::convert($datequery2, 'UTC', date_default_timezone_get())))); + $condition = DBA::mergeConditions($condition, ["`received` >= ?", DateTimeFormat::convert($datequery2, 'UTC', date_default_timezone_get())]); } // Does the profile page belong to a forum? // If not then we can improve the performance with an additional condition - $condition = ['uid' => $a->profile['uid'], 'page-flags' => [User::PAGE_FLAGS_COMMUNITY, User::PAGE_FLAGS_PRVGROUP]]; - if (!DBA::exists('user', $condition)) { - $sql_extra3 = sprintf(" AND `thread`.`contact-id` = %d ", intval(intval($a->profile['id']))); - } else { - $sql_extra3 = ""; + $condition2 = ['uid' => $a->profile['uid'], 'page-flags' => [User::PAGE_FLAGS_COMMUNITY, User::PAGE_FLAGS_PRVGROUP]]; + if (!DBA::exists('user', $condition2)) { + $condition = DBA::mergeConditions($condition, ['contact-id' => $a->profile['id']]); } if (DI::mode()->isMobile()) { @@ -175,37 +178,22 @@ class Status extends BaseProfile DI::config()->get('system', 'itemspage_network')); } - // now that we have the user settings, see if the theme forces - // a maximum item number which is lower then the user choice - if (($a->force_max_items > 0) && ($a->force_max_items < $itemspage_network)) { - $itemspage_network = $a->force_max_items; - } + $condition = DBA::mergeConditions($condition, ["((`gravity` = ? AND `wall`) OR + (`gravity` = ? AND `vid` = ? AND `origin` AND `thr-parent-id` IN + (SELECT `uri-id` FROM `item` AS `i` + WHERE `gravity` = ? AND `network` IN (?, ?, ?, ?) AND `uid` IN (?, ?) + AND `i`.`uri-id` = `item`.`thr-parent-id`)))", + GRAVITY_PARENT, GRAVITY_ACTIVITY, Verb::getID(Activity::ANNOUNCE), GRAVITY_PARENT, + Protocol::DFRN, Protocol::ACTIVITYPUB, Protocol::DIASPORA, Protocol::OSTATUS, + 0, $a->profile['uid']]); + + $condition = DBA::mergeConditions($condition, ['uid' => $a->profile['uid'], 'network' => Protocol::FEDERATED, + 'visible' => true, 'deleted' => false, 'moderated' => false]); $pager = new Pager(DI::l10n(), $args->getQueryString(), $itemspage_network); + $params = ['limit' => [$pager->getStart(), $pager->getItemsPerPage()], 'order' => ['received' => true]]; - $pager_sql = sprintf(" LIMIT %d, %d ", $pager->getStart(), $pager->getItemsPerPage()); - - $items_stmt = DBA::p( - "SELECT `item`.`uri` - FROM `thread` - STRAIGHT_JOIN `item` ON `item`.`id` = `thread`.`iid` - $sql_post_table - STRAIGHT_JOIN `contact` - ON `contact`.`id` = `thread`.`contact-id` - AND NOT `contact`.`blocked` - AND NOT `contact`.`pending` - WHERE `thread`.`uid` = ? - AND `thread`.`visible` - AND NOT `thread`.`deleted` - AND NOT `thread`.`moderated` - AND `thread`.`wall` - $sql_extra3 - $sql_extra - $sql_extra2 - ORDER BY `thread`.`received` DESC - $pager_sql", - $a->profile['uid'] - ); + $items_stmt = DBA::select('item', ['uri', 'thr-parent-id', 'gravity', 'author-id', 'received'], $condition, $params); // Set a time stamp for this page. We will make use of it when we // search for new items (update routine) @@ -227,7 +215,19 @@ class Status extends BaseProfile $items = DBA::toArray($items_stmt); if ($pager->getStart() == 0 && !empty($a->profile['uid'])) { - $pinned_items = Item::selectPinned($a->profile['uid'], ['uri', 'pinned']); + $condition = ['private' => [Item::PUBLIC, Item::UNLISTED]]; + $remote_user = Session::getRemoteContactID($a->profile['uid']); + if (!empty($remote_user)) { + $permissionSets = DI::permissionSet()->selectByContactId($remote_user, $a->profile['uid']); + if (!empty($permissionSets)) { + $condition = ['psid' => array_merge($permissionSets->column('id'), + [DI::permissionSet()->getIdFromACL($a->profile['uid'], '', '', '', '')])]; + } + } elseif ($a->profile['uid'] == local_user()) { + $condition = []; + } + + $pinned_items = Item::selectPinned($a->profile['uid'], ['uri', 'pinned'], $condition); $pinned = Item::inArray($pinned_items); $items = array_merge($items, $pinned); } diff --git a/src/Module/Proxy.php b/src/Module/Proxy.php index e1231f0b13..f20b13bcef 100644 --- a/src/Module/Proxy.php +++ b/src/Module/Proxy.php @@ -104,7 +104,7 @@ class Proxy extends BaseModule // It shouldn't happen but it does - spaces in URL $request['url'] = str_replace(' ', '+', $request['url']); - $fetchResult = HTTPSignature::fetchRaw($request['url'], local_user(), true, ['timeout' => 10]); + $fetchResult = HTTPSignature::fetchRaw($request['url'], local_user(), ['timeout' => 10]); $img_str = $fetchResult->getBody(); // If there is an error then return a blank image diff --git a/src/Module/PublicRSAKey.php b/src/Module/PublicRSAKey.php index 3d0423688f..7c46b63356 100644 --- a/src/Module/PublicRSAKey.php +++ b/src/Module/PublicRSAKey.php @@ -21,11 +21,13 @@ namespace Friendica\Module; -use ASN_BASE; use Friendica\BaseModule; use Friendica\DI; use Friendica\Model\User; use Friendica\Network\HTTPException\BadRequestException; +use Friendica\Util\Crypto; +use Friendica\Util\Strings; +use phpseclib\File\ASN1; /** * prints the public RSA key of a user @@ -49,18 +51,10 @@ class PublicRSAKey extends BaseModule throw new BadRequestException(); } - $lines = explode("\n", $user['spubkey']); - unset($lines[0]); - unset($lines[count($lines)]); - - $asnString = base64_decode(implode('', $lines)); - $asnBase = ASN_BASE::parseASNString($asnString); - - $m = $asnBase[0]->asnData[1]->asnData[0]->asnData[0]->asnData; - $e = $asnBase[0]->asnData[1]->asnData[0]->asnData[1]->asnData; + Crypto::pemToMe($user['spubkey'], $modulus, $exponent); header('Content-type: application/magic-public-key'); - echo 'RSA' . '.' . $m . '.' . $e; + echo 'RSA' . '.' . Strings::base64UrlEncode($modulus, true) . '.' . Strings::base64UrlEncode($exponent, true); exit(); } diff --git a/src/Module/RandomProfile.php b/src/Module/RandomProfile.php index 111d92dc40..65ce565959 100644 --- a/src/Module/RandomProfile.php +++ b/src/Module/RandomProfile.php @@ -24,7 +24,6 @@ namespace Friendica\Module; use Friendica\BaseModule; use Friendica\DI; use Friendica\Model\Contact; -use Friendica\Model\GContact; /** * Redirects to a random Friendica profile this node knows about @@ -35,7 +34,7 @@ class RandomProfile extends BaseModule { $a = DI::app(); - $contactUrl = GContact::getRandomUrl(); + $contactUrl = Contact::getRandomUrl(); if ($contactUrl) { $link = Contact::magicLink($contactUrl); diff --git a/src/Module/Register.php b/src/Module/Register.php index dd603ad206..3e50de8972 100644 --- a/src/Module/Register.php +++ b/src/Module/Register.php @@ -132,7 +132,7 @@ class Register extends BaseModule $o = Renderer::replaceMacros($tpl, [ '$invitations' => DI::config()->get('system', 'invitation_only'), '$permonly' => intval(DI::config()->get('config', 'register_policy')) === self::APPROVE, - '$permonlybox' => ['permonlybox', DI::l10n()->t('Note for the admin'), '', DI::l10n()->t('Leave a message for the admin, why you want to join this node'), 'required'], + '$permonlybox' => ['permonlybox', DI::l10n()->t('Note for the admin'), '', DI::l10n()->t('Leave a message for the admin, why you want to join this node'), DI::l10n()->t('Required')], '$invite_desc' => DI::l10n()->t('Membership on this site is by invitation only.'), '$invite_label' => DI::l10n()->t('Your invitation code: '), '$invite_id' => $invite_id, @@ -184,8 +184,6 @@ class Register extends BaseModule { BaseModule::checkFormSecurityTokenRedirectOnError('/register', 'register'); - $a = DI::app(); - $arr = ['post' => $_POST]; Hook::callAll('register_post', $arr); @@ -369,15 +367,13 @@ class Register extends BaseModule \notification([ 'type' => Model\Notify\Type::SYSTEM, 'event' => 'SYSTEM_REGISTER_REQUEST', + 'uid' => $admin['uid'], + 'link' => $base_url . '/admin/users/', 'source_name' => $user['username'], 'source_mail' => $user['email'], 'source_nick' => $user['nickname'], 'source_link' => $base_url . '/admin/users/', - 'link' => $base_url . '/admin/users/', 'source_photo' => $base_url . '/photo/avatar/' . $user['uid'] . '.jpg', - 'to_email' => $admin['email'], - 'uid' => $admin['uid'], - 'language' => ($admin['language'] ?? '') ?: 'en', 'show_in_notification_page' => false ]); } diff --git a/src/Module/RemoteFollow.php b/src/Module/RemoteFollow.php index 8e4da3c63b..274ed3d06d 100644 --- a/src/Module/RemoteFollow.php +++ b/src/Module/RemoteFollow.php @@ -28,6 +28,7 @@ use Friendica\Core\Protocol; use Friendica\Core\Renderer; use Friendica\Core\Search; use Friendica\Core\System; +use Friendica\Model\Contact; use Friendica\Model\Profile; use Friendica\Network\Probe; @@ -61,23 +62,20 @@ class RemoteFollow extends BaseModule } // Detect the network, make sure the provided URL is valid - $data = Probe::uri($url); - if ($data['network'] == Protocol::PHANTOM) { + $data = Contact::getByURL($url); + if (!$data) { notice(DI::l10n()->t("The provided profile link doesn't seem to be valid")); return; } - // Fetch link for the "remote follow" functionality of the given profile - $follow_link_template = Probe::getRemoteFollowLink($url); - - if (empty($follow_link_template)) { + if (empty($data['subscribe'])) { notice(DI::l10n()->t("Remote subscription can't be done for your network. Please subscribe directly on your system.")); return; } - Logger::notice('Remote request', ['url' => $url, 'follow' => $a->profile['url'], 'remote' => $follow_link_template]); + Logger::notice('Remote request', ['url' => $url, 'follow' => $a->profile['url'], 'remote' => $data['subscribe']]); - // Substitute our user's feed URL into $follow_link_template + // Substitute our user's feed URL into $data['subscribe'] // Send the subscriber home to subscribe // Diaspora needs the uri in the format user@domain.tld if ($data['network'] == Protocol::DIASPORA) { @@ -86,7 +84,7 @@ class RemoteFollow extends BaseModule $uri = urlencode($a->profile['url']); } - $follow_link = str_replace('{uri}', $uri, $follow_link_template); + $follow_link = str_replace('{uri}', $uri, $data['subscribe']); System::externalRedirect($follow_link); } diff --git a/src/Module/Search/Acl.php b/src/Module/Search/Acl.php index 82880f83c3..e8b6f357d9 100644 --- a/src/Module/Search/Acl.php +++ b/src/Module/Search/Acl.php @@ -32,7 +32,6 @@ use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\Item; use Friendica\Network\HTTPException; -use Friendica\Util\Proxy as ProxyUtils; use Friendica\Util\Strings; /** @@ -57,7 +56,6 @@ class Acl extends BaseModule } $type = $_REQUEST['type'] ?? self::TYPE_MENTION_CONTACT_GROUP; - if ($type === self::TYPE_GLOBAL_CONTACT) { $o = self::globalContactSearch(); } else { @@ -75,17 +73,17 @@ class Acl extends BaseModule $mode = $_REQUEST['smode']; $page = $_REQUEST['page'] ?? 1; - $r = Search::searchGlobalContact($search, $mode, $page); + $result = Search::searchContact($search, $mode, $page); $contacts = []; - foreach ($r as $g) { + foreach ($result as $contact) { $contacts[] = [ - 'photo' => ProxyUtils::proxifyUrl($g['photo'], false, ProxyUtils::SIZE_MICRO), - 'name' => htmlspecialchars($g['name']), - 'nick' => $g['addr'] ?: $g['url'], - 'network' => $g['network'], - 'link' => $g['url'], - 'forum' => !empty($g['community']) ? 1 : 0, + 'photo' => Contact::getMicro($contact), + 'name' => htmlspecialchars($contact['name']), + 'nick' => $contact['addr'] ?: $contact['url'], + 'network' => $contact['network'], + 'link' => $contact['url'], + 'forum' => $contact['contact-type'] == Contact::TYPE_COMMUNITY, ]; } @@ -226,7 +224,7 @@ class Acl extends BaseModule $r = []; switch ($type) { case self::TYPE_MENTION_CONTACT_GROUP: - $r = q("SELECT `id`, `name`, `nick`, `micro`, `network`, `url`, `attag`, `addr`, `forum`, `prv`, (`prv` OR `forum`) AS `frm` FROM `contact` + $r = q("SELECT `id`, `name`, `nick`, `avatar`, `micro`, `network`, `url`, `attag`, `addr`, `forum`, `prv`, (`prv` OR `forum`) AS `frm` FROM `contact` WHERE `uid` = %d AND NOT `self` AND NOT `deleted` AND NOT `blocked` AND NOT `pending` AND NOT `archive` AND `notify` != '' AND NOT (`network` IN ('%s', '%s')) $sql_extra2 @@ -238,7 +236,7 @@ class Acl extends BaseModule break; case self::TYPE_MENTION_CONTACT: - $r = q("SELECT `id`, `name`, `nick`, `micro`, `network`, `url`, `attag`, `addr`, `forum`, `prv` FROM `contact` + $r = q("SELECT `id`, `name`, `nick`, `avatar`, `micro`, `network`, `url`, `attag`, `addr`, `forum`, `prv` FROM `contact` WHERE `uid` = %d AND NOT `self` AND NOT `deleted` AND NOT `blocked` AND NOT `pending` AND NOT `archive` AND `notify` != '' AND NOT (`network` IN ('%s')) $sql_extra2 @@ -249,7 +247,7 @@ class Acl extends BaseModule break; case self::TYPE_MENTION_FORUM: - $r = q("SELECT `id`, `name`, `nick`, `micro`, `network`, `url`, `attag`, `addr`, `forum`, `prv` FROM `contact` + $r = q("SELECT `id`, `name`, `nick`, `avatar`, `micro`, `network`, `url`, `attag`, `addr`, `forum`, `prv` FROM `contact` WHERE `uid` = %d AND NOT `self` AND NOT `deleted` AND NOT `blocked` AND NOT `pending` AND NOT `archive` AND `notify` != '' AND NOT (`network` IN ('%s')) AND (`forum` OR `prv`) @@ -261,7 +259,7 @@ class Acl extends BaseModule break; case self::TYPE_PRIVATE_MESSAGE: - $r = q("SELECT `id`, `name`, `nick`, `micro`, `network`, `url`, `attag`, `addr` FROM `contact` + $r = q("SELECT `id`, `name`, `nick`, `avatar`, `micro`, `network`, `url`, `attag`, `addr` FROM `contact` WHERE `uid` = %d AND NOT `self` AND NOT `deleted` AND NOT `blocked` AND NOT `pending` AND NOT `archive` AND `network` IN ('%s', '%s', '%s') $sql_extra2 @@ -275,7 +273,7 @@ class Acl extends BaseModule case self::TYPE_ANY_CONTACT: default: - $r = q("SELECT `id`, `name`, `nick`, `micro`, `network`, `url`, `attag`, `addr`, `forum`, `prv` FROM `contact` + $r = q("SELECT `id`, `name`, `nick`, `avatar`, `micro`, `network`, `url`, `attag`, `addr`, `forum`, `prv`, `avatar` FROM `contact` WHERE `uid` = %d AND NOT `deleted` AND NOT `pending` AND NOT `archive` $sql_extra2 ORDER BY `name`", @@ -289,7 +287,7 @@ class Acl extends BaseModule foreach ($r as $g) { $entry = [ 'type' => 'c', - 'photo' => ProxyUtils::proxifyUrl($g['micro'], false, ProxyUtils::SIZE_MICRO), + 'photo' => Contact::getMicro($g), 'name' => htmlspecialchars($g['name']), 'id' => intval($g['id']), 'network' => $g['network'], @@ -345,14 +343,14 @@ class Acl extends BaseModule continue; } - $contact = Contact::getDetailsByURL($author); + $contact = Contact::getByURL($author, false, ['micro', 'name', 'id', 'network', 'nick', 'addr', 'url', 'forum', 'avatar']); if (count($contact) > 0) { $unknown_contacts[] = [ 'type' => 'c', - 'photo' => ProxyUtils::proxifyUrl($contact['micro'], false, ProxyUtils::SIZE_MICRO), + 'photo' => Contact::getMicro($contact), 'name' => htmlspecialchars($contact['name']), - 'id' => intval($contact['cid']), + 'id' => intval($contact['id']), 'network' => $contact['network'], 'link' => $contact['url'], 'nick' => htmlentities(($contact['nick'] ?? '') ?: $contact['addr']), diff --git a/src/Module/Search/Filed.php b/src/Module/Search/Filed.php new file mode 100644 index 0000000000..505950f715 --- /dev/null +++ b/src/Module/Search/Filed.php @@ -0,0 +1,85 @@ +getCommand(), $_GET['file'] ?? ''); + + if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll') && ($_GET['mode'] ?? '') != 'minimal') { + $tpl = Renderer::getMarkupTemplate('infinite_scroll_head.tpl'); + $o = Renderer::replaceMacros($tpl, ['$reload_uri' => DI::args()->getQueryString()]); + } else { + $o = ''; + } + + $file = $_GET['file'] ?? ''; + + // Rawmode is used for fetching new content at the end of the page + if (!(isset($_GET['mode']) && ($_GET['mode'] == 'raw'))) { + Nav::setSelected(DI::args()->get(0)); + } + + if (DI::mode()->isMobile()) { + $itemspage_network = DI::pConfig()->get(local_user(), 'system', 'itemspage_mobile_network', + DI::config()->get('system', 'itemspage_network_mobile')); + } else { + $itemspage_network = DI::pConfig()->get(local_user(), 'system', 'itemspage_network', + DI::config()->get('system', 'itemspage_network')); + } + + $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), $itemspage_network); + + $term_condition = ['type' => Category::FILE, 'uid' => local_user()]; + if ($file) { + $term_condition['name'] = $file; + } + $term_params = ['order' => ['uri-id' => true], 'limit' => [$pager->getStart(), $pager->getItemsPerPage()]]; + $result = DBA::select('category-view', ['uri-id'], $term_condition, $term_params); + + $total = DBA::count('category-view', $term_condition); + + $posts = []; + while ($term = DBA::fetch($result)) { + $posts[] = $term['uri-id']; + } + DBA::close($result); + + if (count($posts) == 0) { + return ''; + } + $item_condition = ['uid' => local_user(), 'uri-id' => $posts]; + $item_params = ['order' => ['uri-id' => true]]; + + $result = Item::selectForUser(local_user(), [], $item_condition, $item_params); + $items = Item::inArray($result); + + $o .= conversation(DI::app(), $items, 'filed', false, false, '', local_user()); + + if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll')) { + $o .= HTML::scrollLoader(); + } else { + $o .= $pager->renderFull($total); + } + + return $o; + } +} diff --git a/src/Module/Search/Index.php b/src/Module/Search/Index.php index 27074fa82a..67534a61ab 100644 --- a/src/Module/Search/Index.php +++ b/src/Module/Search/Index.php @@ -28,11 +28,13 @@ use Friendica\Content\Widget; use Friendica\Core\Cache\Duration; use Friendica\Core\Logger; use Friendica\Core\Renderer; +use Friendica\Core\Search; use Friendica\Core\Session; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\Item; +use Friendica\Model\ItemContent; use Friendica\Model\Tag; use Friendica\Module\BaseSearch; use Friendica\Network\HTTPException; @@ -80,7 +82,7 @@ class Index extends BaseSearch } if (local_user()) { - DI::page()['aside'] .= Widget\SavedSearches::getHTML('search?q=' . urlencode($search), $search); + DI::page()['aside'] .= Widget\SavedSearches::getHTML(Search::getSearchPath($search), $search); } Nav::setSelected('search'); @@ -130,6 +132,14 @@ class Index extends BaseSearch } } + // Don't perform a fulltext or tag search on search results that look like an URL + // Tags don't look like an URL and the fulltext search does only work with natural words + if (parse_url($search, PHP_URL_SCHEME) && parse_url($search, PHP_URL_HOST)) { + Logger::info('Skipping tag and fulltext search since the search looks like a URL.', ['q' => $search]); + notice(DI::l10n()->t('No results.')); + return $o; + } + $tag = $tag || DI::config()->get('system', 'only_tag_search'); // Here is the way permissions work in the search module... @@ -145,40 +155,37 @@ class Index extends BaseSearch DI::config()->get('system', 'itemspage_network')); } + $last_uriid = isset($_GET['last_uriid']) ? intval($_GET['last_uriid']) : 0; + $pager = new Pager(DI::l10n(), DI::args()->getQueryString(), $itemsPerPage); if ($tag) { Logger::info('Start tag search.', ['q' => $search]); - $uriids = Tag::getURIIdListByTag($search, local_user(), $pager->getStart(), $pager->getItemsPerPage()); - - if (!empty($uriids)) { - $params = ['order' => ['id' => true], 'group_by' => ['uri-id']]; - $items = Item::selectForUser(local_user(), [], ['uri-id' => $uriids], $params); - $r = Item::inArray($items); - } else { - $r = []; - } + $uriids = Tag::getURIIdListByTag($search, local_user(), $pager->getStart(), $pager->getItemsPerPage(), $last_uriid); + $count = Tag::countByTag($search, local_user()); } else { Logger::info('Start fulltext search.', ['q' => $search]); - - $condition = [ - "(`uid` = 0 OR (`uid` = ? AND NOT `global`)) - AND `body` LIKE CONCAT('%',?,'%')", - local_user(), $search - ]; - $params = [ - 'order' => ['id' => true], - 'limit' => [$pager->getStart(), $pager->getItemsPerPage()] - ]; - $items = Item::selectForUser(local_user(), [], $condition, $params); - $r = Item::inArray($items); + $uriids = ItemContent::getURIIdListBySearch($search, local_user(), $pager->getStart(), $pager->getItemsPerPage(), $last_uriid); + $count = ItemContent::countBySearch($search, local_user()); } - if (!DBA::isResult($r)) { - info(DI::l10n()->t('No results.')); + if (!empty($uriids)) { + $params = ['order' => ['id' => true], 'group_by' => ['uri-id']]; + $items = Item::inArray(Item::selectForUser(local_user(), [], ['uri-id' => $uriids], $params)); + } + + if (empty($items)) { + if (empty($last_uriid)) { + notice(DI::l10n()->t('No results.')); + } return $o; } + if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll')) { + $tpl = Renderer::getMarkupTemplate('infinite_scroll_head.tpl'); + $o .= Renderer::replaceMacros($tpl, ['$reload_uri' => DI::args()->getQueryString()]); + } + if ($tag) { $title = DI::l10n()->t('Items tagged with: %s', $search); } else { @@ -191,9 +198,14 @@ class Index extends BaseSearch Logger::info('Start Conversation.', ['q' => $search]); - $o .= conversation(DI::app(), $r, 'search', false, false, 'commented', local_user()); + $o .= conversation(DI::app(), $items, 'search', false, false, 'commented', local_user()); + + if (DI::pConfig()->get(local_user(), 'system', 'infinite_scroll')) { + $o .= HTML::scrollLoader(); + } else { + $o .= $pager->renderMinimal($count); + } - $o .= $pager->renderMinimal(count($r)); return $o; } @@ -236,13 +248,13 @@ class Index extends BaseSearch } else { // Cheaper local lookup for anonymous users, no probe if ($isAddr) { - $contact = Contact::selectFirst(['id' => 'cid'], ['addr' => $search, 'uid' => 0]); + $contact = Contact::selectFirst(['id'], ['addr' => $search, 'uid' => 0]); } else { - $contact = Contact::getDetailsByURL($search, 0, ['cid' => 0]); + $contact = Contact::getByURL($search, null, ['id']) ?: ['id' => 0]; } if (DBA::isResult($contact)) { - $contact_id = $contact['cid']; + $contact_id = $contact['id']; } } diff --git a/src/Module/Search/Saved.php b/src/Module/Search/Saved.php index 7b8c8d012f..0f45b50f5b 100644 --- a/src/Module/Search/Saved.php +++ b/src/Module/Search/Saved.php @@ -22,6 +22,7 @@ namespace Friendica\Module\Search; use Friendica\BaseModule; +use Friendica\Core\Search; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Util\Strings; @@ -33,23 +34,25 @@ class Saved extends BaseModule $action = DI::args()->get(2, 'none'); $search = Strings::escapeTags(trim(rawurldecode($_GET['term'] ?? ''))); - $return_url = $_GET['return_url'] ?? 'search?q=' . urlencode($search); + $return_url = $_GET['return_url'] ?? Search::getSearchPath($search); if (local_user() && $search) { switch ($action) { case 'add': $fields = ['uid' => local_user(), 'term' => $search]; if (!DBA::exists('search', $fields)) { - DBA::insert('search', $fields); - info(DI::l10n()->t('Search term successfully saved.')); + if (!DBA::insert('search', $fields)) { + notice(DI::l10n()->t('Search term was not saved.')); + } } else { - info(DI::l10n()->t('Search term already saved.')); + notice(DI::l10n()->t('Search term already saved.')); } break; case 'remove': - DBA::delete('search', ['uid' => local_user(), 'term' => $search]); - info(DI::l10n()->t('Search term successfully removed.')); + if (!DBA::delete('search', ['uid' => local_user(), 'term' => $search])) { + notice(DI::l10n()->t('Search term was not removed.')); + } break; } } diff --git a/src/Module/Security/TwoFactor/Recovery.php b/src/Module/Security/TwoFactor/Recovery.php index 5168f3b67d..7af1b6ac01 100644 --- a/src/Module/Security/TwoFactor/Recovery.php +++ b/src/Module/Security/TwoFactor/Recovery.php @@ -57,7 +57,7 @@ class Recovery extends BaseModule if (RecoveryCode::existsForUser(local_user(), $recovery_code)) { RecoveryCode::markUsedForUser(local_user(), $recovery_code); Session::set('2fa', true); - notice(DI::l10n()->t('Remaining recovery codes: %d', RecoveryCode::countValidForUser(local_user()))); + info(DI::l10n()->t('Remaining recovery codes: %d', RecoveryCode::countValidForUser(local_user()))); DI::auth()->setForUser($a, $a->user, true, true); } else { diff --git a/src/Module/Security/TwoFactor/Verify.php b/src/Module/Security/TwoFactor/Verify.php index 7d42456be3..d7a44f0c56 100644 --- a/src/Module/Security/TwoFactor/Verify.php +++ b/src/Module/Security/TwoFactor/Verify.php @@ -82,7 +82,7 @@ class Verify extends BaseModule '$errors_label' => DI::l10n()->tt('Error', 'Errors', count(self::$errors)), '$errors' => self::$errors, '$recovery_message' => DI::l10n()->t('Don’t have your phone? Enter a two-factor recovery code', '2fa/recovery'), - '$verify_code' => ['verify_code', DI::l10n()->t('Please enter a code from your authentication app'), '', '', 'required', 'autofocus placeholder="000000"', 'tel'], + '$verify_code' => ['verify_code', DI::l10n()->t('Please enter a code from your authentication app'), '', '', DI::l10n()->t('Required'), 'autofocus autocomplete="off" placeholder="000000"', 'tel'], '$verify_label' => DI::l10n()->t('Verify code and complete login'), ]); } diff --git a/src/Module/Settings/Display.php b/src/Module/Settings/Display.php index bde049718d..cdafbb1c05 100644 --- a/src/Module/Settings/Display.php +++ b/src/Module/Settings/Display.php @@ -52,6 +52,8 @@ class Display extends BaseSettings $no_auto_update = !empty($_POST['no_auto_update']) ? intval($_POST['no_auto_update']) : 0; $no_smart_threading = !empty($_POST['no_smart_threading']) ? intval($_POST['no_smart_threading']) : 0; $hide_dislike = !empty($_POST['hide_dislike']) ? intval($_POST['hide_dislike']) : 0; + $display_resharer = !empty($_POST['display_resharer']) ? intval($_POST['display_resharer']) : 0; + $stay_local = !empty($_POST['stay_local']) ? intval($_POST['stay_local']) : 0; $browser_update = !empty($_POST['browser_update']) ? intval($_POST['browser_update']) : 0; if ($browser_update != -1) { $browser_update = $browser_update * 1000; @@ -85,6 +87,8 @@ class Display extends BaseSettings DI::pConfig()->set(local_user(), 'system', 'infinite_scroll' , $infinite_scroll); DI::pConfig()->set(local_user(), 'system', 'no_smart_threading' , $no_smart_threading); DI::pConfig()->set(local_user(), 'system', 'hide_dislike' , $hide_dislike); + DI::pConfig()->set(local_user(), 'system', 'display_resharer' , $display_resharer); + DI::pConfig()->set(local_user(), 'system', 'stay_local' , $stay_local); DI::pConfig()->set(local_user(), 'system', 'first_day_of_week' , $first_day_of_week); if (in_array($theme, Theme::getAllowedList())) { @@ -166,6 +170,9 @@ class Display extends BaseSettings $infinite_scroll = DI::pConfig()->get(local_user(), 'system', 'infinite_scroll', 0); $no_smart_threading = DI::pConfig()->get(local_user(), 'system', 'no_smart_threading', 0); $hide_dislike = DI::pConfig()->get(local_user(), 'system', 'hide_dislike', 0); + $display_resharer = DI::pConfig()->get(local_user(), 'system', 'display_resharer', 0); + $stay_local = DI::pConfig()->get(local_user(), 'system', 'stay_local', 0); + $first_day_of_week = DI::pConfig()->get(local_user(), 'system', 'first_day_of_week', 0); $weekdays = [0 => DI::l10n()->t("Sunday"), 1 => DI::l10n()->t("Monday")]; @@ -202,6 +209,8 @@ class Display extends BaseSettings '$infinite_scroll' => ['infinite_scroll' , DI::l10n()->t('Infinite scroll'), $infinite_scroll, DI::l10n()->t('Automatic fetch new items when reaching the page end.')], '$no_smart_threading' => ['no_smart_threading' , DI::l10n()->t('Disable Smart Threading'), $no_smart_threading, DI::l10n()->t('Disable the automatic suppression of extraneous thread indentation.')], '$hide_dislike' => ['hide_dislike' , DI::l10n()->t('Hide the Dislike feature'), $hide_dislike, DI::l10n()->t('Hides the Dislike button and dislike reactions on posts and comments.')], + '$display_resharer' => ['display_resharer' , DI::l10n()->t('Display the resharer'), $display_resharer, DI::l10n()->t('Display the first resharer as icon and text on a reshared item.')], + '$stay_local' => ['stay_local' , DI::l10n()->t('Stay local'), $stay_local, DI::l10n()->t("Don't go to a remote system when following a contact link.")], '$first_day_of_week' => ['first_day_of_week', DI::l10n()->t('Beginning of week:'), $first_day_of_week, '', $weekdays, false], ]); diff --git a/src/Module/Settings/Profile/Index.php b/src/Module/Settings/Profile/Index.php index 1335a8211e..f4c902b829 100644 --- a/src/Module/Settings/Profile/Index.php +++ b/src/Module/Settings/Profile/Index.php @@ -31,7 +31,6 @@ use Friendica\Core\Worker; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; -use Friendica\Model\GContact; use Friendica\Model\Profile; use Friendica\Model\ProfileField; use Friendica\Model\User; @@ -134,9 +133,7 @@ class Index extends BaseSettings ['uid' => local_user()] ); - if ($result) { - info(DI::l10n()->t('Profile updated.')); - } else { + if (!$result) { notice(DI::l10n()->t('Profile couldn\'t be updated.')); return; } @@ -153,9 +150,6 @@ class Index extends BaseSettings } Worker::add(PRIORITY_LOW, 'ProfileUpdate', local_user()); - - // Update the global contact for the user - GContact::updateForUser(local_user()); } public static function content(array $parameters = []) diff --git a/src/Module/Settings/Profile/Photo/Crop.php b/src/Module/Settings/Profile/Photo/Crop.php index 00657b9a30..53676eca27 100644 --- a/src/Module/Settings/Profile/Photo/Crop.php +++ b/src/Module/Settings/Profile/Photo/Crop.php @@ -187,7 +187,7 @@ class Crop extends BaseSettings Worker::add(PRIORITY_LOW, 'Directory', Session::get('my_url')); } - notice(DI::l10n()->t('Profile picture successfully updated.')); + info(DI::l10n()->t('Profile picture successfully updated.')); DI::baseUrl()->redirect('profile/' . DI::app()->user['nickname']); } diff --git a/src/Module/Settings/Profile/Photo/Index.php b/src/Module/Settings/Profile/Photo/Index.php index 3e4f9b8a4e..df9622f2e9 100644 --- a/src/Module/Settings/Profile/Photo/Index.php +++ b/src/Module/Settings/Profile/Photo/Index.php @@ -93,9 +93,7 @@ class Index extends BaseSettings $filename = ''; - if (Photo::store($Image, local_user(), 0, $resource_id, $filename, DI::l10n()->t('Profile Photos'), 0)) { - info(DI::l10n()->t('Image uploaded successfully.')); - } else { + if (!Photo::store($Image, local_user(), 0, $resource_id, $filename, DI::l10n()->t('Profile Photos'), 0)) { notice(DI::l10n()->t('Image upload failed.')); } diff --git a/src/Module/Settings/TwoFactor/AppSpecific.php b/src/Module/Settings/TwoFactor/AppSpecific.php index a654fe3573..db094a8855 100644 --- a/src/Module/Settings/TwoFactor/AppSpecific.php +++ b/src/Module/Settings/TwoFactor/AppSpecific.php @@ -74,13 +74,13 @@ class AppSpecific extends BaseSettings DI::baseUrl()->redirect('settings/2fa/app_specific?t=' . self::getFormSecurityToken('settings_2fa_password')); } else { self::$appSpecificPassword = AppSpecificPassword::generateForUser(local_user(), $_POST['description'] ?? ''); - notice(DI::l10n()->t('New app-specific password generated.')); + info(DI::l10n()->t('New app-specific password generated.')); } break; case 'revoke_all' : AppSpecificPassword::deleteAllForUser(local_user()); - notice(DI::l10n()->t('App-specific passwords successfully revoked.')); + info(DI::l10n()->t('App-specific passwords successfully revoked.')); DI::baseUrl()->redirect('settings/2fa/app_specific?t=' . self::getFormSecurityToken('settings_2fa_password')); break; } @@ -90,7 +90,7 @@ class AppSpecific extends BaseSettings self::checkFormSecurityTokenRedirectOnError('settings/2fa/app_specific', 'settings_2fa_app_specific'); if (AppSpecificPassword::deleteForUser(local_user(), $_POST['revoke_id'])) { - notice(DI::l10n()->t('App-specific password successfully revoked.')); + info(DI::l10n()->t('App-specific password successfully revoked.')); } DI::baseUrl()->redirect('settings/2fa/app_specific?t=' . self::getFormSecurityToken('settings_2fa_password')); diff --git a/src/Module/Settings/TwoFactor/Index.php b/src/Module/Settings/TwoFactor/Index.php index 60cfb516e1..8cc04787f5 100644 --- a/src/Module/Settings/TwoFactor/Index.php +++ b/src/Module/Settings/TwoFactor/Index.php @@ -64,7 +64,7 @@ class Index extends BaseSettings DI::pConfig()->delete(local_user(), '2fa', 'verified'); Session::remove('2fa'); - notice(DI::l10n()->t('Two-factor authentication successfully disabled.')); + info(DI::l10n()->t('Two-factor authentication successfully disabled.')); DI::baseUrl()->redirect('settings/2fa'); } break; @@ -125,7 +125,7 @@ class Index extends BaseSettings '$app_specific_passwords_message' => DI::l10n()->t('

    These randomly generated passwords allow you to authenticate on apps not supporting two-factor authentication.

    '), '$action_title' => DI::l10n()->t('Actions'), - '$password' => ['password', DI::l10n()->t('Current password:'), '', DI::l10n()->t('You need to provide your current password to change two-factor authentication settings.'), 'required', 'autofocus'], + '$password' => ['password', DI::l10n()->t('Current password:'), '', DI::l10n()->t('You need to provide your current password to change two-factor authentication settings.'), DI::l10n()->t('Required'), 'autofocus'], '$enable_label' => DI::l10n()->t('Enable two-factor authentication'), '$disable_label' => DI::l10n()->t('Disable two-factor authentication'), '$recovery_codes_label' => DI::l10n()->t('Show recovery codes'), diff --git a/src/Module/Settings/TwoFactor/Recovery.php b/src/Module/Settings/TwoFactor/Recovery.php index b5420be892..7b0d28534f 100644 --- a/src/Module/Settings/TwoFactor/Recovery.php +++ b/src/Module/Settings/TwoFactor/Recovery.php @@ -63,7 +63,7 @@ class Recovery extends BaseSettings if ($_POST['action'] == 'regenerate') { RecoveryCode::regenerateForUser(local_user()); - notice(DI::l10n()->t('New recovery codes successfully generated.')); + info(DI::l10n()->t('New recovery codes successfully generated.')); DI::baseUrl()->redirect('settings/2fa/recovery?t=' . self::getFormSecurityToken('settings_2fa_password')); } } diff --git a/src/Module/Settings/TwoFactor/Verify.php b/src/Module/Settings/TwoFactor/Verify.php index 65d9d7372c..423c341ecb 100644 --- a/src/Module/Settings/TwoFactor/Verify.php +++ b/src/Module/Settings/TwoFactor/Verify.php @@ -75,7 +75,7 @@ class Verify extends BaseSettings DI::pConfig()->set(local_user(), '2fa', 'verified', true); Session::set('2fa', true); - notice(DI::l10n()->t('Two-factor authentication successfully activated.')); + info(DI::l10n()->t('Two-factor authentication successfully activated.')); DI::baseUrl()->redirect('settings/2fa'); } else { @@ -132,13 +132,13 @@ class Verify extends BaseSettings '$help_label' => DI::l10n()->t('Help'), '$message' => DI::l10n()->t('

    Please scan this QR Code with your authenticator app and submit the provided code.

    '), '$qrcode_image' => $qrcode_image, - '$qrcode_url_message' => DI::l10n()->t('

    Or you can open the following URL in your mobile devicde:

    %s

    ', $otpauthUrl, $shortOtpauthUrl), + '$qrcode_url_message' => DI::l10n()->t('

    Or you can open the following URL in your mobile device:

    %s

    ', $otpauthUrl, $shortOtpauthUrl), '$manual_message' => $manual_message, '$company' => $company, '$holder' => $holder, '$secret' => $secret, - '$verify_code' => ['verify_code', DI::l10n()->t('Please enter a code from your authentication app'), '', '', 'required', 'autofocus placeholder="000000"'], + '$verify_code' => ['verify_code', DI::l10n()->t('Please enter a code from your authentication app'), '', '', DI::l10n()->t('Required'), 'autofocus autocomplete="off" placeholder="000000"'], '$verify_label' => DI::l10n()->t('Verify code and enable two-factor authentication'), ]); } diff --git a/src/Module/Settings/UserExport.php b/src/Module/Settings/UserExport.php index 0eaa72ffe6..a07116785f 100644 --- a/src/Module/Settings/UserExport.php +++ b/src/Module/Settings/UserExport.php @@ -27,6 +27,7 @@ use Friendica\Core\Renderer; use Friendica\Database\DBA; use Friendica\Database\DBStructure; use Friendica\DI; +use Friendica\Model\Item; use Friendica\Module\BaseSettings; /** @@ -114,14 +115,14 @@ class UserExport extends BaseSettings $rows = DBA::p($query); while ($row = DBA::fetch($rows)) { $p = []; - foreach ($row as $k => $v) { - switch ($dbStructure[$table]['fields'][$k]['type']) { - case 'datetime': - $p[$k] = $v ?? DBA::NULL_DATETIME; - break; - default: - $p[$k] = $v; - break; + foreach ($dbStructure[$table]['fields'] as $column => $field) { + if (!isset($row[$column])) { + continue; + } + if ($field['type'] == 'datetime') { + $p[$column] = $row[$column] ?? DBA::NULL_DATETIME; + } else { + $p[$column] = $row[$column]; } } $result[] = $p; @@ -143,6 +144,9 @@ class UserExport extends BaseSettings foreach ($r as $rr) { foreach ($rr as $k => $v) { + if (empty($dbStructure[$table]['fields'][$k])) { + continue; + } switch ($dbStructure[$table]['fields'][$k]['type']) { case 'datetime': $result[$k] = $v ?? DBA::NULL_DATETIME; @@ -165,9 +169,9 @@ class UserExport extends BaseSettings // write the table header (like Mastodon) echo "Account address, Show boosts\n"; // get all the contacts - $contacts = DBA::select('contact', ['addr'], ['uid' => $_SESSION['uid'], 'self' => false, 'rel' => [1,3], 'deleted' => false]); + $contacts = DBA::select('contact', ['addr', 'url'], ['uid' => $_SESSION['uid'], 'self' => false, 'rel' => [1,3], 'deleted' => false]); while ($contact = DBA::fetch($contacts)) { - echo $contact['addr'] . ", true\n"; + echo ($contact['addr'] ?: $contact['url']) . ", true\n"; } DBA::close($contacts); } @@ -241,13 +245,8 @@ class UserExport extends BaseSettings // chunk the output to avoid exhausting memory for ($x = 0; $x < $total; $x += 500) { - $r = q("SELECT * FROM `item` WHERE `uid` = %d LIMIT %d, %d", - intval(local_user()), - intval($x), - intval(500) - ); - - $output = ['item' => $r]; + $items = Item::selectToArray(Item::ITEM_FIELDLIST, ['uid' => local_user()], ['limit' => [$x, 500]]); + $output = ['item' => $items]; echo json_encode($output, JSON_PARTIAL_OUTPUT_ON_ERROR). "\n"; } } diff --git a/src/Module/Special/HTTPException.php b/src/Module/Special/HTTPException.php index ed962a4238..1bfae2a36e 100644 --- a/src/Module/Special/HTTPException.php +++ b/src/Module/Special/HTTPException.php @@ -69,9 +69,15 @@ class HTTPException $message = $explanation[$e->getCode()] ?? ''; } - $vars = ['$title' => $title, '$message' => $message, '$back' => DI::l10n()->t('Go back')]; + $vars = [ + '$title' => $title, + '$message' => $message, + '$back' => DI::l10n()->t('Go back'), + '$stack_trace' => DI::l10n()->t('Stack trace:'), + ]; if (is_site_admin()) { + $vars['$thrown'] = DI::l10n()->t('Exception thrown in %s:%d', $e->getFile(), $e->getLine()); $vars['$trace'] = $e->getTraceAsString(); } diff --git a/src/Module/Theme.php b/src/Module/Theme.php index c904f1defd..63004c9280 100644 --- a/src/Module/Theme.php +++ b/src/Module/Theme.php @@ -32,19 +32,18 @@ class Theme extends BaseModule { public static function rawContent(array $parameters = []) { - header("Content-Type: text/css"); + header('Content-Type: text/css'); - $a = DI::app(); + $theme = Strings::sanitizeFilePathItem($parameters['theme']); - if ($a->argc == 4) { - $theme = $a->argv[2]; - $theme = Strings::sanitizeFilePathItem($theme); + if (file_exists("view/theme/$theme/theme.php")) { + require_once "view/theme/$theme/theme.php"; + } - // set the path for later use in the theme styles - $THEMEPATH = "view/theme/$theme"; - if (file_exists("view/theme/$theme/style.php")) { - require_once("view/theme/$theme/style.php"); - } + // set the path for later use in the theme styles + $THEMEPATH = "view/theme/$theme"; + if (file_exists("view/theme/$theme/style.php")) { + require_once "view/theme/$theme/style.php"; } exit(); diff --git a/src/Module/Update/Network.php b/src/Module/Update/Network.php new file mode 100644 index 0000000000..81bdf834e4 --- /dev/null +++ b/src/Module/Update/Network.php @@ -0,0 +1,61 @@ +get($profile_uid, 'system', 'no_auto_update') || ($_GET['force'] == 1)) { + if (!empty($_GET['item'])) { + $item = Item::selectFirst(['parent'], ['id' => $_GET['item']]); + $parent = $item['parent'] ?? 0; + } else { + $parent = 0; + } + + $conditionFields = []; + if (!empty($parent)) { + // Load only a single thread + $conditionFields['parent'] = $parent; + } elseif (self::$order === 'received') { + // Only load new toplevel posts + $conditionFields['unseen'] = true; + $conditionFields['gravity'] = GRAVITY_PARENT; + } else { + // Load all unseen items + $conditionFields['unseen'] = true; + } + + $params = ['limit' => 100]; + $table = 'network-item-view'; + + $items = self::getItems($table, $params, $conditionFields); + + if (self::$order === 'received') { + $ordering = '`received`'; + } else { + $ordering = '`commented`'; + } + + $o = conversation(DI::app(), $items, 'network', $profile_uid, false, $ordering, local_user()); + } + + System::htmlUpdateExit($o); + } +} diff --git a/src/Module/WellKnown/XSocialRelay.php b/src/Module/WellKnown/XSocialRelay.php index 1876de8b8b..ba67e6283c 100644 --- a/src/Module/WellKnown/XSocialRelay.php +++ b/src/Module/WellKnown/XSocialRelay.php @@ -67,8 +67,9 @@ class XSocialRelay extends BaseModule 'scope' => $scope, 'tags' => $tagList, 'protocols' => [ - 'diaspora' => [ - 'receive' => DI::baseUrl()->get() . '/receive/public' + 'activitypub' => [ + 'actor' => DI::baseUrl()->get() . '/friendica', + 'receive' => DI::baseUrl()->get() . '/inbox' ], 'dfrn' => [ 'receive' => DI::baseUrl()->get() . '/dfrn_notify' @@ -76,6 +77,10 @@ class XSocialRelay extends BaseModule ] ]; + if (DI::config()->get("system", "diaspora_enabled")) { + $relay['protocols']['diaspora'] = ['receive' => DI::baseUrl()->get() . '/receive/public']; + } + header('Content-type: application/json; charset=utf-8'); echo json_encode($relay, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); exit; diff --git a/src/Module/Worker.php b/src/Module/Worker.php deleted file mode 100644 index bd06c40296..0000000000 --- a/src/Module/Worker.php +++ /dev/null @@ -1,86 +0,0 @@ -. - * - */ - -namespace Friendica\Module; - -use Friendica\BaseModule; -use Friendica\Core\System; -use Friendica\Core\Worker as WorkerCore; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Util\DateTimeFormat; - -/** - * Module for starting the backend worker through a frontend call - */ -class Worker extends BaseModule -{ - public static function rawContent(array $parameters = []) - { - if (!DI::config()->get("system", "frontend_worker")) { - return; - } - - // Ensure that all "strtotime" operations do run timezone independent - date_default_timezone_set('UTC'); - - // We don't need the following lines if we can execute background jobs. - // So we just wake up the worker if it sleeps. - if (function_exists("proc_open")) { - WorkerCore::executeIfIdle(); - return; - } - - WorkerCore::clearProcesses(); - - $workers = DBA::count('process', ['command' => 'worker.php']); - - if ($workers > DI::config()->get("system", "worker_queues", 4)) { - return; - } - - WorkerCore::startProcess(); - - DI::logger()->notice('Front end worker started.', ['pid' => getmypid()]); - - WorkerCore::callWorker(); - - if ($r = WorkerCore::workerProcess()) { - // On most configurations this parameter wouldn't have any effect. - // But since it doesn't destroy anything, we just try to get more execution time in any way. - set_time_limit(0); - - $fields = ['executed' => DateTimeFormat::utcNow(), 'pid' => getmypid(), 'done' => false]; - $condition = ['id' => $r[0]["id"], 'pid' => 0]; - if (DBA::update('workerqueue', $fields, $condition)) { - WorkerCore::execute($r[0]); - } - } - - WorkerCore::callWorker(); - - WorkerCore::unclaimProcess(); - - WorkerCore::endProcess(); - - System::httpExit(200, 'Frontend worker stopped.'); - } -} diff --git a/src/Module/Xrd.php b/src/Module/Xrd.php index 1a7b0712f5..be6a3bf9c5 100644 --- a/src/Module/Xrd.php +++ b/src/Module/Xrd.php @@ -24,6 +24,7 @@ namespace Friendica\Module; use Friendica\BaseModule; use Friendica\Core\Hook; use Friendica\Core\Renderer; +use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Photo; @@ -77,19 +78,30 @@ class Xrd extends BaseModule $name = substr($local, 0, strpos($local, '@')); } - $user = User::getByNickname($name); + if ($name == User::getActorName()) { + $owner = User::getSystemAccount(); + if (empty($owner)) { + throw new \Friendica\Network\HTTPException\NotFoundException(); + } + self::printSystemJSON($owner); + } else { + $user = User::getByNickname($name); + if (empty($user)) { + throw new \Friendica\Network\HTTPException\NotFoundException(); + } - if (empty($user)) { - throw new \Friendica\Network\HTTPException\NotFoundException(); + $owner = User::getOwnerDataById($user['uid']); + if (empty($owner)) { + DI::logger()->warning('No owner data for user id', ['uri' => $uri, 'name' => $name, 'user' => $user]); + throw new \Friendica\Network\HTTPException\NotFoundException(); + } + + $alias = str_replace('/profile/', '/~', $owner['url']); + + $avatar = Photo::selectFirst(['type'], ['uid' => $owner['uid'], 'profile' => true]); } - $owner = User::getOwnerDataById($user['uid']); - - $alias = str_replace('/profile/', '/~', $owner['url']); - - $avatar = Photo::selectFirst(['type'], ['uid' => $owner['uid'], 'profile' => true]); - - if (!DBA::isResult($avatar)) { + if (empty($avatar)) { $avatar = ['type' => 'image/jpeg']; } @@ -100,6 +112,32 @@ class Xrd extends BaseModule } } + private static function printSystemJSON(array $owner) + { + $json = [ + 'subject' => 'acct:' . $owner['addr'], + 'aliases' => [$owner['url']], + 'links' => [ + [ + 'rel' => 'http://webfinger.net/rel/profile-page', + 'type' => 'text/html', + 'href' => $owner['url'], + ], + [ + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => $owner['url'], + ], + [ + 'rel' => 'http://ostatus.org/schema/1.0/subscribe', + 'template' => DI::baseUrl()->get() . '/follow?url={uri}', + ], + ] + ]; + header('Access-Control-Allow-Origin: *'); + System::jsonExit($json, 'application/jrd+json; charset=utf-8'); + } + private static function printJSON($alias, $baseURL, $owner, $avatar) { $salmon_key = Salmon::salmonKey($owner['spubkey']); diff --git a/src/Network/CurlResult.php b/src/Network/CurlResult.php index 44263716bc..9f52edfad6 100644 --- a/src/Network/CurlResult.php +++ b/src/Network/CurlResult.php @@ -22,6 +22,7 @@ namespace Friendica\Network; use Friendica\Core\Logger; +use Friendica\Core\System; use Friendica\Network\HTTPException\InternalServerErrorException; use Friendica\Util\Network; @@ -130,7 +131,7 @@ class CurlResult $this->errorNumber = $errorNumber; $this->error = $error; - Logger::log($url . ': ' . $this->returnCode . " " . $result, Logger::DATA); + Logger::debug('construct', ['url' => $url, 'returncode' => $this->returnCode, 'result' => $result]); $this->parseBodyHeader($result); $this->checkSuccess(); @@ -166,8 +167,8 @@ class CurlResult } if (!$this->isSuccess) { - Logger::log('error: ' . $this->url . ': ' . $this->returnCode . ' - ' . $this->error, Logger::INFO); - Logger::log('debug: ' . print_r($this->info, true), Logger::DATA); + Logger::notice('http error', ['url' => $this->url, 'code' => $this->returnCode, 'error' => $this->error, 'callstack' => System::callstack(20)]); + Logger::debug('debug', ['info' => $this->info]); } if (!$this->isSuccess && $this->errorNumber == CURLE_OPERATION_TIMEDOUT) { diff --git a/src/Network/HTTPRequest.php b/src/Network/HTTPRequest.php new file mode 100644 index 0000000000..0aab36142f --- /dev/null +++ b/src/Network/HTTPRequest.php @@ -0,0 +1,489 @@ +. + * + */ + +namespace Friendica\Network; + +use DOMDocument; +use DomXPath; +use Friendica\App; +use Friendica\Core\Config\IConfig; +use Friendica\Core\System; +use Friendica\Util\Network; +use Friendica\Util\Profiler; +use Psr\Log\LoggerInterface; + +/** + * Performs HTTP requests to a given URL + */ +class HTTPRequest implements IHTTPRequest +{ + /** @var LoggerInterface */ + private $logger; + /** @var Profiler */ + private $profiler; + /** @var IConfig */ + private $config; + /** @var string */ + private $baseUrl; + + public function __construct(LoggerInterface $logger, Profiler $profiler, IConfig $config, App\BaseURL $baseUrl) + { + $this->logger = $logger; + $this->profiler = $profiler; + $this->config = $config; + $this->baseUrl = $baseUrl->get(); + } + + /** {@inheritDoc} + * + * @throws HTTPException\InternalServerErrorException + */ + public function head(string $url, array $opts = []) + { + $opts['nobody'] = true; + + return $this->get($url, $opts); + } + + /** + * {@inheritDoc} + * + * @param int $redirects The recursion counter for internal use - default 0 + * + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function get(string $url, array $opts = [], int &$redirects = 0) + { + $stamp1 = microtime(true); + + if (strlen($url) > 1000) { + $this->logger->debug('URL is longer than 1000 characters.', ['url' => $url, 'callstack' => System::callstack(20)]); + $this->profiler->saveTimestamp($stamp1, 'network'); + return CurlResult::createErrorCurl(substr($url, 0, 200)); + } + + $parts2 = []; + $parts = parse_url($url); + $path_parts = explode('/', $parts['path'] ?? ''); + foreach ($path_parts as $part) { + if (strlen($part) <> mb_strlen($part)) { + $parts2[] = rawurlencode($part); + } else { + $parts2[] = $part; + } + } + $parts['path'] = implode('/', $parts2); + $url = Network::unparseURL($parts); + + if (Network::isUrlBlocked($url)) { + $this->logger->info('Domain is blocked.', ['url' => $url]); + $this->profiler->saveTimestamp($stamp1, 'network'); + return CurlResult::createErrorCurl($url); + } + + $ch = @curl_init($url); + + if (($redirects > 8) || (!$ch)) { + $this->profiler->saveTimestamp($stamp1, 'network'); + return CurlResult::createErrorCurl($url); + } + + @curl_setopt($ch, CURLOPT_HEADER, true); + + if (!empty($opts['cookiejar'])) { + curl_setopt($ch, CURLOPT_COOKIEJAR, $opts["cookiejar"]); + curl_setopt($ch, CURLOPT_COOKIEFILE, $opts["cookiejar"]); + } + + // These settings aren't needed. We're following the location already. + // @curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + // @curl_setopt($ch, CURLOPT_MAXREDIRS, 5); + + if (!empty($opts['accept_content'])) { + curl_setopt( + $ch, + CURLOPT_HTTPHEADER, + ['Accept: ' . $opts['accept_content']] + ); + } + + if (!empty($opts['header'])) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $opts['header']); + } + + @curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + @curl_setopt($ch, CURLOPT_USERAGENT, $this->getUserAgent()); + + $range = intval($this->config->get('system', 'curl_range_bytes', 0)); + + if ($range > 0) { + @curl_setopt($ch, CURLOPT_RANGE, '0-' . $range); + } + + // Without this setting it seems as if some webservers send compressed content + // This seems to confuse curl so that it shows this uncompressed. + /// @todo We could possibly set this value to "gzip" or something similar + curl_setopt($ch, CURLOPT_ENCODING, ''); + + if (!empty($opts['headers'])) { + $this->logger->notice('Wrong option \'headers\' used.'); + @curl_setopt($ch, CURLOPT_HTTPHEADER, $opts['headers']); + } + + if (!empty($opts['nobody'])) { + @curl_setopt($ch, CURLOPT_NOBODY, $opts['nobody']); + } + + @curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + + if (!empty($opts['timeout'])) { + @curl_setopt($ch, CURLOPT_TIMEOUT, $opts['timeout']); + } else { + $curl_time = $this->config->get('system', 'curl_timeout', 60); + @curl_setopt($ch, CURLOPT_TIMEOUT, intval($curl_time)); + } + + // by default we will allow self-signed certs + // but you can override this + + $check_cert = $this->config->get('system', 'verifyssl'); + @curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, (($check_cert) ? true : false)); + + if ($check_cert) { + @curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + } + + $proxy = $this->config->get('system', 'proxy'); + + if (!empty($proxy)) { + @curl_setopt($ch, CURLOPT_HTTPPROXYTUNNEL, 1); + @curl_setopt($ch, CURLOPT_PROXY, $proxy); + $proxyuser = $this->config->get('system', 'proxyuser'); + + if (!empty($proxyuser)) { + @curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxyuser); + } + } + + if ($this->config->get('system', 'ipv4_resolve', false)) { + curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); + } + + $s = @curl_exec($ch); + $curl_info = @curl_getinfo($ch); + + // Special treatment for HTTP Code 416 + // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/416 + if (($curl_info['http_code'] == 416) && ($range > 0)) { + @curl_setopt($ch, CURLOPT_RANGE, ''); + $s = @curl_exec($ch); + $curl_info = @curl_getinfo($ch); + } + + $curlResponse = new CurlResult($url, $s, $curl_info, curl_errno($ch), curl_error($ch)); + + if (!Network::isRedirectBlocked($url) && $curlResponse->isRedirectUrl()) { + $redirects++; + $this->logger->notice('Curl redirect.', ['url' => $url, 'to' => $curlResponse->getRedirectUrl()]); + @curl_close($ch); + $this->profiler->saveTimestamp($stamp1, 'network'); + return $this->get($curlResponse->getRedirectUrl(), $opts, $redirects); + } + + @curl_close($ch); + + $this->profiler->saveTimestamp($stamp1, 'network'); + + return $curlResponse; + } + + /** + * {@inheritDoc} + * + * @param int $redirects The recursion counter for internal use - default 0 + * + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function post(string $url, $params, array $headers = [], int $timeout = 0, int &$redirects = 0) + { + $stamp1 = microtime(true); + + if (Network::isUrlBlocked($url)) { + $this->logger->info('Domain is blocked.' . ['url' => $url]); + $this->profiler->saveTimestamp($stamp1, 'network'); + return CurlResult::createErrorCurl($url); + } + + $ch = curl_init($url); + + if (($redirects > 8) || (!$ch)) { + $this->profiler->saveTimestamp($stamp1, 'network'); + return CurlResult::createErrorCurl($url); + } + + $this->logger->debug('Post_url: start.', ['url' => $url]); + + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, $params); + curl_setopt($ch, CURLOPT_USERAGENT, $this->getUserAgent()); + + if ($this->config->get('system', 'ipv4_resolve', false)) { + curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); + } + + @curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + + if (intval($timeout)) { + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + } else { + $curl_time = $this->config->get('system', 'curl_timeout', 60); + curl_setopt($ch, CURLOPT_TIMEOUT, intval($curl_time)); + } + + if (!empty($headers)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + } + + $check_cert = $this->config->get('system', 'verifyssl'); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, (($check_cert) ? true : false)); + + if ($check_cert) { + @curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + } + + $proxy = $this->config->get('system', 'proxy'); + + if (!empty($proxy)) { + curl_setopt($ch, CURLOPT_HTTPPROXYTUNNEL, 1); + curl_setopt($ch, CURLOPT_PROXY, $proxy); + $proxyuser = $this->config->get('system', 'proxyuser'); + if (!empty($proxyuser)) { + curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxyuser); + } + } + + // don't let curl abort the entire application + // if it throws any errors. + + $s = @curl_exec($ch); + + $curl_info = curl_getinfo($ch); + + $curlResponse = new CurlResult($url, $s, $curl_info, curl_errno($ch), curl_error($ch)); + + if (!Network::isRedirectBlocked($url) && $curlResponse->isRedirectUrl()) { + $redirects++; + $this->logger->info('Post redirect.', ['url' => $url, 'to' => $curlResponse->getRedirectUrl()]); + curl_close($ch); + $this->profiler->saveTimestamp($stamp1, 'network'); + return $this->post($curlResponse->getRedirectUrl(), $params, $headers, $redirects, $timeout); + } + + curl_close($ch); + + $this->profiler->saveTimestamp($stamp1, 'network'); + + // Very old versions of Lighttpd don't like the "Expect" header, so we remove it when needed + if ($curlResponse->getReturnCode() == 417) { + $redirects++; + + if (empty($headers)) { + $headers = ['Expect:']; + } else { + if (!in_array('Expect:', $headers)) { + array_push($headers, 'Expect:'); + } + } + $this->logger->info('Server responds with 417, applying workaround', ['url' => $url]); + return $this->post($url, $params, $headers, $redirects, $timeout); + } + + $this->logger->debug('Post_url: End.', ['url' => $url]); + + return $curlResponse; + } + + /** + * {@inheritDoc} + */ + public function finalUrl(string $url, int $depth = 1, bool $fetchbody = false) + { + if (Network::isUrlBlocked($url)) { + $this->logger->info('Domain is blocked.', ['url' => $url]); + return $url; + } + + if (Network::isRedirectBlocked($url)) { + $this->logger->info('Domain should not be redirected.', ['url' => $url]); + return $url; + } + + $url = Network::stripTrackingQueryParams($url); + + if ($depth > 10) { + return $url; + } + + $url = trim($url, "'"); + + $stamp1 = microtime(true); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HEADER, 1); + curl_setopt($ch, CURLOPT_NOBODY, 1); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERAGENT, $this->getUserAgent()); + + curl_exec($ch); + $curl_info = @curl_getinfo($ch); + $http_code = $curl_info['http_code']; + curl_close($ch); + + $this->profiler->saveTimestamp($stamp1, "network"); + + if ($http_code == 0) { + return $url; + } + + if (in_array($http_code, ['301', '302'])) { + if (!empty($curl_info['redirect_url'])) { + return $this->finalUrl($curl_info['redirect_url'], ++$depth, $fetchbody); + } elseif (!empty($curl_info['location'])) { + return $this->finalUrl($curl_info['location'], ++$depth, $fetchbody); + } + } + + // Check for redirects in the meta elements of the body if there are no redirects in the header. + if (!$fetchbody) { + return $this->finalUrl($url, ++$depth, true); + } + + // if the file is too large then exit + if ($curl_info["download_content_length"] > 1000000) { + return $url; + } + + // if it isn't a HTML file then exit + if (!empty($curl_info["content_type"]) && !strstr(strtolower($curl_info["content_type"]), "html")) { + return $url; + } + + $stamp1 = microtime(true); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HEADER, 0); + curl_setopt($ch, CURLOPT_NOBODY, 0); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERAGENT, $this->getUserAgent()); + + $body = curl_exec($ch); + curl_close($ch); + + $this->profiler->saveTimestamp($stamp1, "network"); + + if (trim($body) == "") { + return $url; + } + + // Check for redirect in meta elements + $doc = new DOMDocument(); + @$doc->loadHTML($body); + + $xpath = new DomXPath($doc); + + $list = $xpath->query("//meta[@content]"); + foreach ($list as $node) { + $attr = []; + if ($node->attributes->length) { + foreach ($node->attributes as $attribute) { + $attr[$attribute->name] = $attribute->value; + } + } + + if (@$attr["http-equiv"] == 'refresh') { + $path = $attr["content"]; + $pathinfo = explode(";", $path); + foreach ($pathinfo as $value) { + if (substr(strtolower($value), 0, 4) == "url=") { + return $this->finalUrl(substr($value, 4), ++$depth); + } + } + } + } + + return $url; + } + + /** + * {@inheritDoc} + * + * @param int $redirects The recursion counter for internal use - default 0 + * + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function fetch(string $url, int $timeout = 0, string $accept_content = '', string $cookiejar = '', int &$redirects = 0) + { + $ret = $this->fetchFull($url, $timeout, $accept_content, $cookiejar, $redirects); + + return $ret->getBody(); + } + + /** + * {@inheritDoc} + * + * @param int $redirects The recursion counter for internal use - default 0 + * + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function fetchFull(string $url, int $timeout = 0, string $accept_content = '', string $cookiejar = '', int &$redirects = 0) + { + return $this->get( + $url, + [ + 'timeout' => $timeout, + 'accept_content' => $accept_content, + 'cookiejar' => $cookiejar + ], + $redirects + ); + } + + /** + * {@inheritDoc} + */ + public function getUserAgent() + { + return + FRIENDICA_PLATFORM . " '" . + FRIENDICA_CODENAME . "' " . + FRIENDICA_VERSION . '-' . + DB_UPDATE_VERSION . '; ' . + $this->baseUrl; + } +} diff --git a/src/Network/IHTTPRequest.php b/src/Network/IHTTPRequest.php new file mode 100644 index 0000000000..8927941e81 --- /dev/null +++ b/src/Network/IHTTPRequest.php @@ -0,0 +1,123 @@ +. + * + */ + +namespace Friendica\Network; + +/** + * Interface for calling HTTP requests and returning their responses + */ +interface IHTTPRequest +{ + /** + * Fetches the content of an URL + * + * Set the cookiejar argument to a string (e.g. "/tmp/friendica-cookies.txt") + * to preserve cookies from one request to the next. + * + * @param string $url URL to fetch + * @param int $timeout Timeout in seconds, default system config value or 60 seconds + * @param string $accept_content supply Accept: header with 'accept_content' as the value + * @param string $cookiejar Path to cookie jar file + * + * @return string The fetched content + */ + public function fetch(string $url, int $timeout = 0, string $accept_content = '', string $cookiejar = ''); + + /** + * Fetches the whole response of an URL. + * + * Inner workings and parameters are the same as @ref fetchUrl but returns an array with + * all the information collected during the fetch. + * + * @param string $url URL to fetch + * @param int $timeout Timeout in seconds, default system config value or 60 seconds + * @param string $accept_content supply Accept: header with 'accept_content' as the value + * @param string $cookiejar Path to cookie jar file + * + * @return CurlResult With all relevant information, 'body' contains the actual fetched content. + */ + public function fetchFull(string $url, int $timeout = 0, string $accept_content = '', string $cookiejar = ''); + + /** + * Send a HEAD to an URL. + * + * @param string $url URL to fetch + * @param array $opts (optional parameters) assoziative array with: + * 'accept_content' => supply Accept: header with 'accept_content' as the value + * 'timeout' => int Timeout in seconds, default system config value or 60 seconds + * 'cookiejar' => path to cookie jar file + * 'header' => header array + * + * @return CurlResult + */ + public function head(string $url, array $opts = []); + + /** + * Send a GET to an URL. + * + * @param string $url URL to fetch + * @param array $opts (optional parameters) assoziative array with: + * 'accept_content' => supply Accept: header with 'accept_content' as the value + * 'timeout' => int Timeout in seconds, default system config value or 60 seconds + * 'cookiejar' => path to cookie jar file + * 'header' => header array + * + * @return CurlResult + */ + public function get(string $url, array $opts = []); + + /** + * Send POST request to an URL + * + * @param string $url URL to post + * @param mixed $params array of POST variables + * @param array $headers HTTP headers + * @param int $timeout The timeout in seconds, default system config value or 60 seconds + * + * @return CurlResult The content + */ + public function post(string $url, $params, array $headers = [], int $timeout = 0); + + /** + * Returns the original URL of the provided URL + * + * This function strips tracking query params and follows redirections, either + * through HTTP code or meta refresh tags. Stops after 10 redirections. + * + * @param string $url A user-submitted URL + * @param int $depth The current redirection recursion level (internal) + * @param bool $fetchbody Wether to fetch the body or not after the HEAD requests + * + * @return string A canonical URL + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @see ParseUrl::getSiteinfo + * + * @todo Remove the $fetchbody parameter that generates an extraneous HEAD request + */ + public function finalUrl(string $url, int $depth = 1, bool $fetchbody = false); + + /** + * Returns the current UserAgent as a String + * + * @return string the UserAgent as a String + */ + public function getUserAgent(); +} diff --git a/src/Network/Probe.php b/src/Network/Probe.php index 1e6d8406a1..f4ca0398a8 100644 --- a/src/Network/Probe.php +++ b/src/Network/Probe.php @@ -23,19 +23,22 @@ namespace Friendica\Network; use DOMDocument; use DomXPath; -use Friendica\Core\Cache\Duration; +use Friendica\Core\Hook; 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\Model\GServer; use Friendica\Model\Profile; +use Friendica\Model\User; use Friendica\Protocol\ActivityNamespace; use Friendica\Protocol\ActivityPub; use Friendica\Protocol\Email; use Friendica\Protocol\Feed; use Friendica\Util\Crypto; +use Friendica\Util\DateTimeFormat; use Friendica\Util\Network; use Friendica\Util\Strings; use Friendica\Util\XML; @@ -45,6 +48,8 @@ use Friendica\Util\XML; */ class Probe { + const WEBFINGER = '/.well-known/webfinger?resource={uri}'; + private static $baseurl; private static $istimeout; @@ -84,16 +89,24 @@ class Probe { $fields = ["name", "nick", "guid", "url", "addr", "alias", "photo", "account-type", "community", "keywords", "location", "about", "hide", - "batch", "notify", "poll", "request", "confirm", "poco", + "batch", "notify", "poll", "request", "confirm", "subscribe", "poco", "following", "followers", "inbox", "outbox", "sharedinbox", - "priority", "network", "pubkey", "baseurl"]; + "priority", "network", "pubkey", "manually-approve", "baseurl", "gsid"]; + + $numeric_fields = ["gsid", "hide", "account-type", "manually-approve"]; $newdata = []; foreach ($fields as $field) { if (isset($data[$field])) { - $newdata[$field] = $data[$field]; - } else { + if (in_array($field, $numeric_fields)) { + $newdata[$field] = (int)$data[$field]; + } else { + $newdata[$field] = $data[$field]; + } + } elseif (!in_array($field, $numeric_fields)) { $newdata[$field] = ""; + } else { + $newdata[$field] = null; } } @@ -156,7 +169,7 @@ class Probe Logger::info('Probing', ['host' => $host, 'ssl_url' => $ssl_url, 'url' => $url, 'callstack' => System::callstack(20)]); $xrd = null; - $curlResult = Network::curl($ssl_url, false, ['timeout' => $xrd_timeout, 'accept_content' => 'application/xrd+xml']); + $curlResult = DI::httpRequest()->get($ssl_url, ['timeout' => $xrd_timeout, 'accept_content' => 'application/xrd+xml']); $ssl_connection_error = ($curlResult->getErrorNumber() == CURLE_COULDNT_CONNECT) || ($curlResult->getReturnCode() == 0); if ($curlResult->isSuccess()) { $xml = $curlResult->getBody(); @@ -167,21 +180,21 @@ class Probe $host_url = $host; } } elseif ($curlResult->isTimeout()) { - Logger::info('Probing timeout', ['url' => $ssl_url], Logger::DEBUG); + Logger::info('Probing timeout', ['url' => $ssl_url]); self::$istimeout = true; - return false; + return []; } if (!is_object($xrd) && !empty($url)) { - $curlResult = Network::curl($url, false, ['timeout' => $xrd_timeout, 'accept_content' => 'application/xrd+xml']); + $curlResult = DI::httpRequest()->get($url, ['timeout' => $xrd_timeout, 'accept_content' => 'application/xrd+xml']); $connection_error = ($curlResult->getErrorNumber() == CURLE_COULDNT_CONNECT) || ($curlResult->getReturnCode() == 0); if ($curlResult->isTimeout()) { - Logger::info('Probing timeout', ['url' => $url], Logger::DEBUG); + Logger::info('Probing timeout', ['url' => $url]); self::$istimeout = true; - return false; + return []; } elseif ($connection_error && $ssl_connection_error) { self::$istimeout = true; - return false; + return []; } $xml = $curlResult->getBody(); @@ -189,17 +202,17 @@ class Probe $host_url = 'http://'.$host; } if (!is_object($xrd)) { - Logger::log("No xrd object found for ".$host, Logger::DEBUG); + Logger::info('No xrd object found', ['host' => $host]); return []; } $links = XML::elementToArray($xrd); if (!isset($links["xrd"]["link"])) { - Logger::log("No xrd data found for ".$host, Logger::DEBUG); + Logger::info('No xrd data found', ['host' => $host]); return []; } - $lrdd = ['application/jrd+json' => $host_url . '/.well-known/webfinger?resource={uri}']; + $lrdd = []; foreach ($links["xrd"]["link"] as $value => $link) { if (!empty($link["@attributes"])) { @@ -219,7 +232,7 @@ class Probe self::$baseurl = $host_url; - Logger::log("Probing successful for ".$host, Logger::DEBUG); + Logger::info('Probing successful', ['host' => $host]); return $lrdd; } @@ -245,12 +258,12 @@ class Probe * @return string profile link * @throws HTTPException\InternalServerErrorException */ - public static function webfingerDfrn($webbie, &$hcard_url) + public static function webfingerDfrn(string $webbie, string &$hcard_url) { $profile_link = ''; $links = self::lrdd($webbie); - Logger::log('webfingerDfrn: '.$webbie.':'.print_r($links, true), Logger::DATA); + Logger::debug('Result', ['url' => $webbie, 'links' => $links]); if (!empty($links) && is_array($links)) { foreach ($links as $link) { if ($link['@attributes']['rel'] === ActivityNamespace::DFRN) { @@ -267,110 +280,34 @@ class Probe return $profile_link; } - /** - * Get the link for the remote follow page for a given profile link - * - * @param sting $profile - * @return string Remote follow page link - */ - public static function getRemoteFollowLink(string $profile) - { - $follow_link = ''; - - $links = self::lrdd($profile); - - if (!empty($links) && is_array($links)) { - foreach ($links as $link) { - if ($link['@attributes']['rel'] === ActivityNamespace::OSTATUSSUB) { - $follow_link = $link['@attributes']['template']; - } - } - } - return $follow_link; - } - /** * Check an URI for LRDD data * - * @param string $uri Address that should be probed + * @param string $uri Address that should be probed * * @return array uri data * @throws HTTPException\InternalServerErrorException */ - public static function lrdd($uri) + public static function lrdd(string $uri) { - $lrdd = self::hostMeta($uri); - $webfinger = null; - - if (is_bool($lrdd)) { + $data = self::getWebfingerArray($uri); + if (empty($data)) { return []; } + $webfinger = $data['webfinger']; - if (!$lrdd) { - $parts = @parse_url($uri); - if (!$parts || empty($parts["host"]) || empty($parts["path"])) { - return []; - } - - $host = $parts['scheme'] . '://' . $parts["host"]; - if (!empty($parts["port"])) { - $host .= ':'.$parts["port"]; - } - - $path_parts = explode("/", trim($parts["path"], "/")); - - $nick = array_pop($path_parts); - - do { - $lrdd = self::hostMeta($host); - $host .= "/".array_shift($path_parts); - } while (!$lrdd && (sizeof($path_parts) > 0)); - } - - if (!$lrdd) { - Logger::log("No lrdd data found for ".$uri, Logger::DEBUG); + if (empty($webfinger["links"])) { + Logger::info('No webfinger links found', ['uri' => $uri]); return []; } - foreach ($lrdd as $type => $template) { - if ($webfinger) { - continue; - } - - $path = str_replace('{uri}', urlencode($uri), $template); - $webfinger = self::webfinger($path, $type); - - if (!$webfinger && (strstr($uri, "@"))) { - $path = str_replace('{uri}', urlencode("acct:".$uri), $template); - $webfinger = self::webfinger($path, $type); - } - - // Special treatment for Mastodon - // Problem is that Mastodon uses an URL format like http://domain.tld/@nick - // But the webfinger for this format fails. - if (!$webfinger && !empty($nick)) { - // Mastodon uses a "@" as prefix for usernames in their url format - $nick = ltrim($nick, '@'); - - $addr = $nick."@".$host; - - $path = str_replace('{uri}', urlencode("acct:".$addr), $template); - $webfinger = self::webfinger($path, $type); - } - } - - if (!is_array($webfinger["links"])) { - Logger::log("No webfinger links found for ".$uri, Logger::DEBUG); - return false; - } - $data = []; foreach ($webfinger["links"] as $link) { $data[] = ["@attributes" => $link]; } - if (is_array($webfinger["aliases"])) { + if (!empty($webfinger["aliases"]) && is_array($webfinger["aliases"])) { foreach ($webfinger["aliases"] as $alias) { $data[] = ["@attributes" => ["rel" => "alias", @@ -393,12 +330,13 @@ class Probe * @throws HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function uri($uri, $network = '', $uid = -1, $cache = true) + public static function uri($uri, $network = '', $uid = -1) { - if ($cache) { - $result = DI::cache()->get('Probe::uri:' . $network . ':' . $uri); - if (!is_null($result)) { - return $result; + // Local profiles aren't probed via network + if (empty($network) && strpos($uri, DI::baseUrl()->getHostname())) { + $data = self::localProbe($uri); + if (!empty($data)) { + return $data; } } @@ -406,19 +344,19 @@ class Probe $uid = local_user(); } + if (empty($network) || ($network == Protocol::ACTIVITYPUB)) { + $ap_profile = ActivityPub::probeProfile($uri); + } else { + $ap_profile = []; + } + self::$istimeout = false; if ($network != Protocol::ACTIVITYPUB) { - $data = self::detect($uri, $network, $uid); - } else { - $data = null; - } - - // When the previous detection process had got a time out - // we could falsely detect a Friendica profile as AP profile. - if (!self::$istimeout) { - $ap_profile = ActivityPub::probeProfile($uri); - + $data = self::detect($uri, $network, $uid, $ap_profile); + if (!is_array($data)) { + $data = []; + } if (empty($data) || (!empty($ap_profile) && empty($network) && (($data['network'] ?? '') != Protocol::DFRN))) { $data = $ap_profile; } elseif (!empty($ap_profile)) { @@ -426,17 +364,15 @@ class Probe $data = array_merge($ap_profile, $data); } } else { - Logger::notice('Time out detected. AP will not be probed.', ['uri' => $uri]); + $data = $ap_profile; } if (!isset($data['url'])) { $data['url'] = $uri; } - if (!empty($data['photo']) && !empty($data['baseurl'])) { - $data['baseurl'] = Network::getUrlMatch(Strings::normaliseLink($data['baseurl']), Strings::normaliseLink($data['photo'])); - } elseif (empty($data['photo'])) { - $data['photo'] = DI::baseUrl() . '/images/person-300.jpg'; + if (empty($data['photo'])) { + $data['photo'] = DI::baseUrl() . Contact::DEFAULT_AVATAR_PHOTO; } if (empty($data['name'])) { @@ -457,8 +393,8 @@ class Probe } } - if (!empty(self::$baseurl)) { - $data['baseurl'] = self::$baseurl; + if (!empty($data['baseurl']) && empty($data['gsid'])) { + $data['gsid'] = GServer::getID($data['baseurl']); } if (empty($data['network'])) { @@ -474,14 +410,7 @@ class Probe $data['hide'] = self::getHideStatus($data['url']); } - $data = self::rearrangeData($data); - - // Only store into the cache if the value seems to be valid - if (!in_array($data['network'], [Protocol::PHANTOM, Protocol::MAIL])) { - DI::cache()->set('Probe::uri:' . $network . ':' . $uri, $data, Duration::DAY); - } - - return $data; + return self::rearrangeData($data); } @@ -494,7 +423,7 @@ class Probe */ private static function getHideStatus($url) { - $curlResult = Network::curl($url); + $curlResult = DI::httpRequest()->get($url); if (!$curlResult->isSuccess()) { return false; } @@ -549,96 +478,219 @@ class Probe } /** - * Checks if a profile url should be OStatus but only provides partial information + * Fetch the "subscribe" and add it to the result * - * @param array $webfinger Webfinger data - * @param string $lrdd Path template for webfinger request - * @param string $type type - * - * @return array fixed webfinger data - * @throws HTTPException\InternalServerErrorException + * @param array $result + * @param array $webfinger + * @return array result */ - private static function fixOStatus($webfinger, $lrdd, $type) + private static function getSubscribeLink(array $result, array $webfinger) { - if (empty($webfinger['links']) || empty($webfinger['subject'])) { - return $webfinger; + if (empty($webfinger['links'])) { + return $result; } - $is_ostatus = false; - $has_key = false; - foreach ($webfinger['links'] as $link) { - if ($link['rel'] == ActivityNamespace::OSTATUSSUB) { - $is_ostatus = true; - } - if ($link['rel'] == 'magic-public-key') { - $has_key = true; + if (!empty($link['template']) && ($link['rel'] === ActivityNamespace::OSTATUSSUB)) { + $result['subscribe'] = $link['template']; } } - if (!$is_ostatus || $has_key) { - return $webfinger; + return $result; + } + + /** + * Get webfinger data from a given URI + * + * @param string $uri + * @return array Webfinger array + */ + private static function getWebfingerArray(string $uri) + { + $parts = parse_url($uri); + + if (!empty($parts['scheme']) && !empty($parts['host'])) { + $host = $parts['host']; + if (!empty($parts['port'])) { + $host .= ':'.$parts['port']; + } + + $baseurl = $parts['scheme'] . '://' . $host; + + $nick = ''; + $addr = ''; + + $path_parts = explode("/", trim($parts['path'] ?? '', "/")); + if (!empty($path_parts)) { + $nick = ltrim(end($path_parts), '@'); + // When the last part of the URI is numeric then it is most likely an ID and not a nick name + if (!is_numeric($nick)) { + $addr = $nick."@".$host; + } else { + $nick = ''; + } + } + + $webfinger = self::getWebfinger($parts['scheme'] . '://' . $host . self::WEBFINGER, 'application/jrd+json', $uri, $addr); + if (empty($webfinger)) { + $lrdd = self::hostMeta($host); + } + + if (empty($webfinger) && empty($lrdd)) { + while (empty($lrdd) && empty($webfinger) && (sizeof($path_parts) > 1)) { + $host .= "/".array_shift($path_parts); + $baseurl = $parts['scheme'] . '://' . $host; + + if (!empty($nick)) { + $addr = $nick."@".$host; + } + + $webfinger = self::getWebfinger($parts['scheme'] . '://' . $host . self::WEBFINGER, 'application/jrd+json', $uri, $addr); + if (empty($webfinger)) { + $lrdd = self::hostMeta($host); + } + } + + if (empty($lrdd) && empty($webfinger)) { + return []; + } + } + } elseif (strstr($uri, '@')) { + // Remove "acct:" from the URI + $uri = str_replace('acct:', '', $uri); + + $host = substr($uri, strpos($uri, '@') + 1); + $nick = substr($uri, 0, strpos($uri, '@')); + $addr = $uri; + + $webfinger = self::getWebfinger('https://' . $host . self::WEBFINGER, 'application/jrd+json', $uri, $addr); + if (self::$istimeout) { + return []; + } + + if (empty($webfinger)) { + $webfinger = self::getWebfinger('http://' . $host . self::WEBFINGER, 'application/jrd+json', $uri, $addr); + if (self::$istimeout) { + return []; + } + } else { + $baseurl = 'https://' . $host; + } + + if (empty($webfinger)) { + $lrdd = self::hostMeta($host); + if (self::$istimeout) { + return []; + } + $baseurl = self::$baseurl; + } else { + $baseurl = 'http://' . $host; + } + } else { + Logger::info('URI was not detectable', ['uri' => $uri]); + return []; } - $url = Network::switchScheme($webfinger['subject']); - $path = str_replace('{uri}', urlencode($url), $lrdd); - $webfinger2 = self::webfinger($path, $type); + if (empty($webfinger)) { + foreach ($lrdd as $type => $template) { + if ($webfinger) { + continue; + } - // Is the new webfinger detectable as OStatus? - if (self::ostatus($webfinger2, true)) { - $webfinger = $webfinger2; + $webfinger = self::getWebfinger($template, $type, $uri, $addr); + } } + if (empty($webfinger)) { + return []; + } + + if ($webfinger['detected'] == $addr) { + $webfinger['nick'] = $nick; + $webfinger['addr'] = $addr; + } + + $webfinger['baseurl'] = $baseurl; + return $webfinger; } + /** + * Perform network request for webfinger data + * + * @param string $template + * @param string $type + * @param string $uri + * @param string $addr + * @return array webfinger results + */ + private static function getWebfinger(string $template, string $type, string $uri, string $addr) + { + // First try the address because this is the primary purpose of webfinger + if (!empty($addr)) { + $detected = $addr; + $path = str_replace('{uri}', urlencode("acct:" . $addr), $template); + $webfinger = self::webfinger($path, $type); + if (self::$istimeout) { + return []; + } + } + + // Then try the URI + if (empty($webfinger) && $uri != $addr) { + $detected = $uri; + $path = str_replace('{uri}', urlencode($uri), $template); + $webfinger = self::webfinger($path, $type); + if (self::$istimeout) { + return []; + } + } + + if (empty($webfinger)) { + return []; + } + + return ['webfinger' => $webfinger, 'detected' => $detected]; + } + /** * Fetch information (protocol endpoints and user information) about a given uri * * This function is only called by the "uri" function that adds caching and rearranging of data. * - * @param string $uri Address that should be probed - * @param string $network Test for this specific network - * @param integer $uid User ID for the probe (only used for mails) + * @param string $uri Address that should be probed + * @param string $network Test for this specific network + * @param integer $uid User ID for the probe (only used for mails) + * @param array $ap_profile Previously probed AP profile * * @return array uri data * @throws HTTPException\InternalServerErrorException */ - private static function detect($uri, $network, $uid) + private static function detect(string $uri, string $network, int $uid, array $ap_profile) { + $hookData = [ + 'uri' => $uri, + 'network' => $network, + 'uid' => $uid, + 'result' => [], + ]; + + Hook::callAll('probe_detect', $hookData); + + if ($hookData['result']) { + if (!is_array($hookData['result'])) { + return []; + } else { + return $hookData['result']; + } + } + $parts = parse_url($uri); - if (!empty($parts["scheme"]) && !empty($parts["host"])) { - $host = $parts["host"]; - if (!empty($parts["port"])) { - $host .= ':'.$parts["port"]; - } - - if ($host == 'twitter.com') { + if (!empty($parts['scheme']) && !empty($parts['host'])) { + if (in_array($parts['host'], ['twitter.com', 'mobile.twitter.com'])) { return self::twitter($uri); } - $lrdd = self::hostMeta($host); - - if (is_bool($lrdd)) { - return []; - } - - $path_parts = explode("/", trim($parts['path'] ?? '', "/")); - - while (!$lrdd && (sizeof($path_parts) > 1)) { - $host .= "/".array_shift($path_parts); - $lrdd = self::hostMeta($host); - } - if (!$lrdd) { - Logger::log('No XRD data was found for '.$uri, Logger::DEBUG); - return self::feed($uri); - } - $nick = array_pop($path_parts); - - // Mastodon uses a "@" as prefix for usernames in their url format - $nick = ltrim($nick, '@'); - - $addr = $nick."@".$host; } elseif (strstr($uri, '@')) { // If the URI starts with "mailto:" then jump directly to the mail detection if (strpos($uri, 'mailto:') !== false) { @@ -649,73 +701,43 @@ class Probe if ($network == Protocol::MAIL) { return self::mail($uri, $uid); } - // Remove "acct:" from the URI - $uri = str_replace('acct:', '', $uri); - $host = substr($uri, strpos($uri, '@') + 1); - $nick = substr($uri, 0, strpos($uri, '@')); - - if (strpos($uri, '@twitter.com')) { + if (Strings::endsWith($uri, '@twitter.com') + || Strings::endsWith($uri, '@mobile.twitter.com') + ) { return self::twitter($uri); } - $lrdd = self::hostMeta($host); + } else { + Logger::info('URI was not detectable', ['uri' => $uri]); + return []; + } - if (is_bool($lrdd)) { + Logger::info('Probing start', ['uri' => $uri]); + + if (!empty($ap_profile['addr']) && ($ap_profile['addr'] != $uri)) { + $data = self::getWebfingerArray($ap_profile['addr']); + } + + if (empty($data)) { + $data = self::getWebfingerArray($uri); + } + + if (empty($data)) { + if (!empty($parts['scheme'])) { + return self::feed($uri); + } elseif (!empty($uid)) { + return self::mail($uri, $uid); + } else { return []; } - - if (!$lrdd) { - Logger::log('No XRD data was found for '.$uri, Logger::DEBUG); - return self::mail($uri, $uid); - } - $addr = $uri; - } else { - Logger::log("Uri ".$uri." was not detectable", Logger::DEBUG); - return false; } - $webfinger = false; + $webfinger = $data['webfinger']; + $nick = $data['nick'] ?? ''; + $addr = $data['addr'] ?? ''; + $baseurl = $data['baseurl'] ?? ''; - /// @todo Do we need the prefix "acct:" or "acct://"? - - foreach ($lrdd as $type => $template) { - if ($webfinger) { - continue; - } - - // At first try it with the given uri - $path = str_replace('{uri}', urlencode($uri), $template); - $webfinger = self::webfinger($path, $type); - - // Fix possible problems with GNU Social probing to wrong scheme - $webfinger = self::fixOStatus($webfinger, $template, $type); - - // We cannot be sure that the detected address was correct, so we don't use the values - if ($webfinger && ($uri != $addr)) { - $nick = ""; - $addr = ""; - } - - // Try webfinger with the address (user@domain.tld) - if (!$webfinger) { - $path = str_replace('{uri}', urlencode($addr), $template); - $webfinger = self::webfinger($path, $type); - } - - // Mastodon needs to have it with "acct:" - if (!$webfinger) { - $path = str_replace('{uri}', urlencode("acct:".$addr), $template); - $webfinger = self::webfinger($path, $type); - } - } - - if (!$webfinger) { - return self::feed($uri); - } - - $result = false; - - Logger::log("Probing ".$uri, Logger::DEBUG); + $result = []; if (in_array($network, ["", Protocol::DFRN])) { $result = self::dfrn($webfinger); @@ -727,12 +749,12 @@ class Probe $result = self::ostatus($webfinger); } if (in_array($network, ['', Protocol::ZOT])) { - $result = self::zot($webfinger, $result); + $result = self::zot($webfinger, $result, $baseurl); } if ((!$result && ($network == "")) || ($network == Protocol::PUMPIO)) { $result = self::pumpio($webfinger, $addr); } - if ((!$result && ($network == "")) || ($network == Protocol::FEED)) { + if (empty($result['network']) && empty($ap_profile['network']) || ($network == Protocol::FEED)) { $result = self::feed($uri); } else { // We overwrite the detected nick with our try if the previois routines hadn't detected it. @@ -746,22 +768,22 @@ class Probe } } + $result = self::getSubscribeLink($result, $webfinger); + if (empty($result["network"])) { $result["network"] = Protocol::PHANTOM; } + if (empty($result['baseurl']) && !empty($baseurl)) { + $result['baseurl'] = $baseurl; + } + if (empty($result["url"])) { $result["url"] = $uri; } - Logger::log($uri." is ".$result["network"], Logger::DEBUG); + Logger::info('Probing done', ['uri' => $uri, 'network' => $result["network"]]); - if (empty($result["baseurl"]) && ($result["network"] != Protocol::PHANTOM)) { - $pos = strpos($result["url"], $host); - if ($pos) { - $result["baseurl"] = substr($result["url"], 0, $pos).$host; - } - } return $result; } @@ -774,7 +796,7 @@ class Probe * @return array Zot data * @throws HTTPException\InternalServerErrorException */ - private static function zot($webfinger, $data) + private static function zot($webfinger, $data, $baseurl) { if (!empty($webfinger["aliases"]) && is_array($webfinger["aliases"])) { foreach ($webfinger["aliases"] as $alias) { @@ -795,12 +817,12 @@ class Probe } } - if (empty($zot_url) && !empty($data['addr']) && !empty(self::$baseurl)) { - $condition = ['nurl' => Strings::normaliseLink(self::$baseurl), 'platform' => ['hubzilla']]; + if (empty($zot_url) && !empty($data['addr']) && !empty($baseurl)) { + $condition = ['nurl' => Strings::normaliseLink($baseurl), 'platform' => ['hubzilla']]; if (!DBA::exists('gserver', $condition)) { return $data; } - $zot_url = self::$baseurl . '/.well-known/zot-info?address=' . $data['addr']; + $zot_url = $baseurl . '/.well-known/zot-info?address=' . $data['addr']; } if (empty($zot_url)) { @@ -822,7 +844,7 @@ class Probe public static function pollZot($url, $data) { - $curlResult = Network::curl($url); + $curlResult = DI::httpRequest()->get($url); if ($curlResult->isTimeout()) { return $data; } @@ -873,7 +895,7 @@ class Probe } if (!empty($json['public_forum'])) { $data['community'] = $json['public_forum']; - $data['account-type'] = Contact::PAGE_COMMUNITY; + $data['account-type'] = User::PAGE_FLAGS_COMMUNITY; } if (!empty($json['profile'])) { @@ -915,22 +937,22 @@ class Probe * @return array webfinger data * @throws HTTPException\InternalServerErrorException */ - private static function webfinger($url, $type) + public static function webfinger($url, $type) { $xrd_timeout = DI::config()->get('system', 'xrd_timeout', 20); - $curlResult = Network::curl($url, false, ['timeout' => $xrd_timeout, 'accept_content' => $type]); + $curlResult = DI::httpRequest()->get($url, ['timeout' => $xrd_timeout, 'accept_content' => $type]); if ($curlResult->isTimeout()) { self::$istimeout = true; - return false; + return []; } $data = $curlResult->getBody(); $webfinger = json_decode($data, true); - if (is_array($webfinger)) { + if (!empty($webfinger)) { if (!isset($webfinger["links"])) { - Logger::log("No json webfinger links for ".$url, Logger::DEBUG); - return false; + Logger::info('No json webfinger links', ['url' => $url]); + return []; } return $webfinger; } @@ -938,14 +960,14 @@ class Probe // If it is not JSON, maybe it is XML $xrd = XML::parseString($data, true); if (!is_object($xrd)) { - Logger::log("No webfinger data retrievable for ".$url, Logger::DEBUG); - return false; + Logger::info('No webfinger data retrievable', ['url' => $url]); + return []; } $xrd_arr = XML::elementToArray($xrd); if (!isset($xrd_arr["xrd"]["link"])) { - Logger::log("No XML webfinger links for ".$url, Logger::DEBUG); - return false; + Logger::info('No XML webfinger links', ['url' => $url]); + return []; } $webfinger = []; @@ -988,21 +1010,21 @@ class Probe */ private static function pollNoscrape($noscrape_url, $data) { - $curlResult = Network::curl($noscrape_url); + $curlResult = DI::httpRequest()->get($noscrape_url); if ($curlResult->isTimeout()) { self::$istimeout = true; - return false; + return []; } $content = $curlResult->getBody(); if (!$content) { - Logger::log("Empty body for ".$noscrape_url, Logger::DEBUG); - return false; + Logger::info('Empty body', ['url' => $noscrape_url]); + return []; } $json = json_decode($content, true); if (!is_array($json)) { - Logger::log("No json data for ".$noscrape_url, Logger::DEBUG); - return false; + Logger::info('No json data', ['url' => $noscrape_url]); + return []; } if (!empty($json["fn"])) { @@ -1115,7 +1137,7 @@ class Probe { $data = []; - Logger::log("Check profile ".$profile_link, Logger::DEBUG); + Logger::info('Check profile', ['link' => $profile_link]); // Fetch data via noscrape - this is faster $noscrape_url = str_replace(["/hcard/", "/profile/"], "/noscrape/", $profile_link); @@ -1149,7 +1171,7 @@ class Probe $prof_data["fn"] = $data['name'] ?? null; $prof_data["key"] = $data['pubkey'] ?? null; - Logger::log("Result for profile ".$profile_link.": ".print_r($prof_data, true), Logger::DEBUG); + Logger::debug('Result', ['link' => $profile_link, 'data' => $prof_data]); return $prof_data; } @@ -1212,7 +1234,7 @@ class Probe } if (!isset($data["network"]) || ($hcard_url == "")) { - return false; + return []; } // Fetch data via noscrape - this is faster @@ -1246,26 +1268,26 @@ class Probe */ private static function pollHcard($hcard_url, $data, $dfrn = false) { - $curlResult = Network::curl($hcard_url); + $curlResult = DI::httpRequest()->get($hcard_url); if ($curlResult->isTimeout()) { self::$istimeout = true; - return false; + return []; } $content = $curlResult->getBody(); if (!$content) { - return false; + return []; } $doc = new DOMDocument(); if (!@$doc->loadHTML($content)) { - return false; + return []; } $xpath = new DomXPath($doc); $vcards = $xpath->query("//div[contains(concat(' ', @class, ' '), ' vcard ')]"); if (!is_object($vcards)) { - return false; + return []; } if (!isset($data["baseurl"])) { @@ -1403,7 +1425,7 @@ class Probe } if (empty($data["url"]) || empty($hcard_url)) { - return false; + return []; } if (!empty($webfinger["aliases"]) && is_array($webfinger["aliases"])) { @@ -1424,7 +1446,7 @@ class Probe $data = self::pollHcard($hcard_url, $data); if (!$data) { - return false; + return []; } if (!empty($data["url"]) @@ -1434,6 +1456,7 @@ class Probe && !empty($hcard_url) ) { $data["network"] = Protocol::DIASPORA; + $data["manually-approve"] = false; // The Diaspora handle must always be lowercase if (!empty($data["addr"])) { @@ -1444,7 +1467,7 @@ class Probe $data["notify"] = $data["baseurl"] . "/receive/users/" . $data["guid"]; $data["batch"] = $data["baseurl"] . "/receive/public"; } else { - return false; + return []; } return $data; @@ -1477,7 +1500,7 @@ class Probe $data["addr"] = str_replace('acct:', '', $webfinger["subject"]); } - if (is_array($webfinger["links"])) { + if (!empty($webfinger["links"])) { // The array is reversed to take into account the order of preference for same-rel links // See: https://tools.ietf.org/html/rfc7033#section-4.4.4 foreach (array_reverse($webfinger["links"]) as $link) { @@ -1485,7 +1508,7 @@ class Probe && (($link["type"] ?? "") == "text/html") && ($link["href"] != "") ) { - $data["url"] = $link["href"]; + $data["url"] = $data["alias"] = $link["href"]; } elseif (($link["rel"] == "salmon") && !empty($link["href"])) { $data["notify"] = $link["href"]; } elseif (($link["rel"] == ActivityNamespace::FEED) && !empty($link["href"])) { @@ -1500,10 +1523,10 @@ class Probe $pubkey = substr($pubkey, 5); } } elseif (Strings::normaliseLink($pubkey) == 'http://') { - $curlResult = Network::curl($pubkey); + $curlResult = DI::httpRequest()->get($pubkey); if ($curlResult->isTimeout()) { self::$istimeout = true; - return false; + return $short ? false : []; } $pubkey = $curlResult->getBody(); } @@ -1524,8 +1547,9 @@ class Probe && isset($data["url"]) ) { $data["network"] = Protocol::OSTATUS; + $data["manually-approve"] = false; } else { - return false; + return $short ? false : []; } if ($short) { @@ -1533,15 +1557,15 @@ class Probe } // Fetch all additional data from the feed - $curlResult = Network::curl($data["poll"]); + $curlResult = DI::httpRequest()->get($data["poll"]); if ($curlResult->isTimeout()) { self::$istimeout = true; - return false; + return []; } $feed = $curlResult->getBody(); $feed_data = Feed::import($feed); if (!$feed_data) { - return false; + return []; } if (!empty($feed_data["header"]["author-name"])) { @@ -1568,8 +1592,7 @@ class Probe $data["url"] = $feed_data["header"]["author-link"]; } - if (($data['poll'] == $data['url']) && ($data["alias"] != '')) { - $data['url'] = $data["alias"]; + if ($data["url"] == $data["alias"]) { $data["alias"] = ''; } @@ -1586,14 +1609,14 @@ class Probe */ private static function pumpioProfileData($profile_link) { - $curlResult = Network::curl($profile_link); + $curlResult = DI::httpRequest()->get($profile_link); if (!$curlResult->isSuccess()) { - return false; + return []; } $doc = new DOMDocument(); if (!@$doc->loadHTML($curlResult->getBody())) { - return false; + return []; } $xpath = new DomXPath($doc); @@ -1674,13 +1697,13 @@ class Probe $data["network"] = Protocol::PUMPIO; } else { - return false; + return []; } $profile_data = self::pumpioProfileData($data["url"]); if (!$profile_data) { - return false; + return []; } $data = array_merge($data, $profile_data); @@ -1704,9 +1727,9 @@ class Probe */ private static function twitter($uri) { - if (preg_match('=(.*)@twitter.com=i', $uri, $matches)) { + if (preg_match('=([^@]+)@(?:mobile\.)?twitter\.com$=i', $uri, $matches)) { $nick = $matches[1]; - } elseif (preg_match('=https?://twitter.com/(.*)=i', $uri, $matches)) { + } elseif (preg_match('=^https?://(?:mobile\.)?twitter\.com/(.+)=i', $uri, $matches)) { $nick = $matches[1]; } else { return []; @@ -1719,87 +1742,96 @@ class Probe $data['network'] = Protocol::TWITTER; $data['baseurl'] = 'https://twitter.com'; - $curlResult = Network::curl($data['url'], false); - if (!$curlResult->isSuccess()) { - return []; - } - - $body = $curlResult->getBody(); - $doc = new DOMDocument(); - @$doc->loadHTML($body); - $xpath = new DOMXPath($doc); - - $list = $xpath->query('//img[@class]'); - foreach ($list as $node) { - $img_attr = []; - if ($node->attributes->length) { - foreach ($node->attributes as $attribute) { - $img_attr[$attribute->name] = $attribute->value; - } - } - - if (empty($img_attr['class'])) { - continue; - } - - if (strpos($img_attr['class'], 'ProfileAvatar-image') !== false) { - if (!empty($img_attr['src'])) { - $data['photo'] = $img_attr['src']; - } - if (!empty($img_attr['alt'])) { - $data['name'] = $img_attr['alt']; - } - } - } - return $data; } /** - * Check page for feed link + * Checks HTML page for RSS feed link * - * @param string $url Page link - * - * @return string feed link + * @param string $url Page link + * @param string $body Page body string + * @return string|false Feed link or false if body was invalid HTML document */ - private static function getFeedLink($url) + public static function getFeedLink(string $url, string $body) { - $curlResult = Network::curl($url); - if (!$curlResult->isSuccess()) { - return false; - } - $doc = new DOMDocument(); - if (!@$doc->loadHTML($curlResult->getBody())) { + if (!@$doc->loadHTML($body)) { return false; } - $xpath = new DomXPath($doc); + $xpath = new DOMXPath($doc); - //$feeds = $xpath->query("/html/head/link[@type='application/rss+xml']"); - $feeds = $xpath->query("/html/head/link[@type='application/rss+xml' and @rel='alternate']"); - if (!is_object($feeds)) { - return false; + $feedUrl = $xpath->evaluate('string(/html/head/link[@type="application/rss+xml" and @rel="alternate"]/@href)'); + + $feedUrl = $feedUrl ? self::ensureAbsoluteLinkFromHTMLDoc($feedUrl, $url, $xpath) : ''; + + return $feedUrl; + } + + /** + * Return an absolute URL in the context of a HTML document retrieved from the provided URL. + * + * Loosely based on RFC 1808 + * + * @see https://tools.ietf.org/html/rfc1808 + * + * @param string $href The potential relative href found in the HTML document + * @param string $base The HTML document URL + * @param DOMXPath $xpath The HTML document XPath + * @return string + */ + private static function ensureAbsoluteLinkFromHTMLDoc(string $href, string $base, DOMXPath $xpath) + { + if (filter_var($href, FILTER_VALIDATE_URL)) { + return $href; } - if ($feeds->length == 0) { - return false; + $base = $xpath->evaluate('string(/html/head/base/@href)') ?: $base; + + $baseParts = parse_url($base); + if (empty($baseParts['host'])) { + return $href; } - $feed_url = ""; + // Naked domain case (scheme://basehost) + $path = $baseParts['path'] ?? '/'; - foreach ($feeds as $feed) { - $attr = []; - foreach ($feed->attributes as $attribute) { - $attr[$attribute->name] = trim($attribute->value); - } + // Remove the filename part of the path if it exists (/base/path/file) + $path = implode('/', array_slice(explode('/', $path), 0, -1)); - if (empty($feed_url) && !empty($attr['href'])) { - $feed_url = $attr["href"]; + $hrefParts = parse_url($href); + + if (!empty($hrefParts['path'])) { + // Root path case (/path) including relative scheme case (//host/path) + if ($hrefParts['path'] && $hrefParts['path'][0] == '/') { + $path = $hrefParts['path']; + } else { + $path = $path . '/' . $hrefParts['path']; + + // Resolve arbitrary relative path + // Lifted from https://www.php.net/manual/en/function.realpath.php#84012 + $parts = array_filter(explode('/', $path), 'strlen'); + $absolutes = array(); + foreach ($parts as $part) { + if ('.' == $part) continue; + if ('..' == $part) { + array_pop($absolutes); + } else { + $absolutes[] = $part; + } + } + + $path = '/' . implode('/', $absolutes); } } - return $feed_url; + // Relative scheme case (//host/path) + $baseParts['host'] = $hrefParts['host'] ?? $baseParts['host']; + $baseParts['path'] = $path; + unset($baseParts['query']); + unset($baseParts['fragment']); + + return Network::unparseURL($baseParts); } /** @@ -1813,23 +1845,23 @@ class Probe */ private static function feed($url, $probe = true) { - $curlResult = Network::curl($url); + $curlResult = DI::httpRequest()->get($url); if ($curlResult->isTimeout()) { self::$istimeout = true; - return false; + return []; } $feed = $curlResult->getBody(); $feed_data = Feed::import($feed); if (!$feed_data) { if (!$probe) { - return false; + return []; } - $feed_url = self::getFeedLink($url); + $feed_url = self::getFeedLink($url, $feed); if (!$feed_url) { - return false; + return []; } return self::feed($feed_url, false); @@ -1854,12 +1886,6 @@ class Probe $data["url"] = $url; $data["poll"] = $url; - if (!empty($feed_data["header"]["author-link"])) { - $data["baseurl"] = $feed_data["header"]["author-link"]; - } else { - $data["baseurl"] = $data["url"]; - } - $data["network"] = Protocol::FEED; return $data; @@ -1877,11 +1903,11 @@ class Probe private static function mail($uri, $uid) { if (!Network::isEmailDomainValid($uri)) { - return false; + return []; } if ($uid == 0) { - return false; + return []; } $user = DBA::selectFirst('user', ['prvkey'], ['uid' => $uid]); @@ -1891,7 +1917,7 @@ class Probe $mailacct = DBA::selectFirst('mailacct', $fields, $condition); if (!DBA::isResult($user) || !DBA::isResult($mailacct)) { - return false; + return []; } $mailbox = Email::constructMailboxName($mailacct); @@ -1899,14 +1925,14 @@ class Probe openssl_private_decrypt(hex2bin($mailacct['pass']), $password, $user['prvkey']); $mbox = Email::connect($mailbox, $mailacct['user'], $password); if (!$mbox) { - return false; + return []; } $msgs = Email::poll($mbox, $uri); - Logger::log('searching '.$uri.', '.count($msgs).' messages found.', Logger::DEBUG); + Logger::info('Messages found', ['uri' => $uri, 'count' => count($msgs)]); if (!count($msgs)) { - return false; + return []; } $phost = substr($uri, strpos($uri, '@') + 1); @@ -1986,8 +2012,220 @@ class Probe $fixed = $scheme.$host.$port.$path.$query.$fragment; - Logger::log('Base: '.$base.' - Avatar: '.$avatar.' - Fixed: '.$fixed, Logger::DATA); + Logger::debug('Avatar fixed', ['base' => $base, 'avatar' => $avatar, 'fixed' => $fixed]); return $fixed; } + + /** + * Fetch the last date that the contact had posted something (publically) + * + * @param string $data probing result + * @return string last activity + */ + public static function getLastUpdate(array $data) + { + $uid = User::getIdForURL($data['url']); + if (!empty($uid)) { + $contact = Contact::selectFirst(['url', 'last-item'], ['self' => true, 'uid' => $uid]); + if (!empty($contact['last-item'])) { + return $contact['last-item']; + } + } + + if ($lastUpdate = self::updateFromNoScrape($data)) { + return $lastUpdate; + } + + if (!empty($data['outbox'])) { + return self::updateFromOutbox($data['outbox'], $data); + } elseif (!empty($data['poll']) && ($data['network'] == Protocol::ACTIVITYPUB)) { + return self::updateFromOutbox($data['poll'], $data); + } elseif (!empty($data['poll'])) { + return self::updateFromFeed($data); + } + + return ''; + } + + /** + * Fetch the last activity date from the "noscrape" endpoint + * + * @param array $data Probing result + * @return string last activity + * + * @return bool 'true' if update was successful or the server was unreachable + */ + private static function updateFromNoScrape(array $data) + { + if (empty($data['baseurl'])) { + return ''; + } + + // Check the 'noscrape' endpoint when it is a Friendica server + $gserver = DBA::selectFirst('gserver', ['noscrape'], ["`nurl` = ? AND `noscrape` != ''", + Strings::normaliseLink($data['baseurl'])]); + if (!DBA::isResult($gserver)) { + return ''; + } + + $curlResult = DI::httpRequest()->get($gserver['noscrape'] . '/' . $data['nick']); + + if ($curlResult->isSuccess() && !empty($curlResult->getBody())) { + $noscrape = json_decode($curlResult->getBody(), true); + if (!empty($noscrape) && !empty($noscrape['updated'])) { + return DateTimeFormat::utc($noscrape['updated'], DateTimeFormat::MYSQL); + } + } + + return ''; + } + + /** + * Fetch the last activity date from an ActivityPub Outbox + * + * @param string $feed + * @param array $data Probing result + * @return string last activity + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + private static function updateFromOutbox(string $feed, array $data) + { + $outbox = ActivityPub::fetchContent($feed); + if (empty($outbox)) { + return ''; + } + + if (!empty($outbox['orderedItems'])) { + $items = $outbox['orderedItems']; + } elseif (!empty($outbox['first']['orderedItems'])) { + $items = $outbox['first']['orderedItems']; + } elseif (!empty($outbox['first']['href']) && ($outbox['first']['href'] != $feed)) { + return self::updateFromOutbox($outbox['first']['href'], $data); + } elseif (!empty($outbox['first'])) { + if (is_string($outbox['first']) && ($outbox['first'] != $feed)) { + return self::updateFromOutbox($outbox['first'], $data); + } else { + Logger::warning('Unexpected data', ['outbox' => $outbox]); + } + return ''; + } else { + $items = []; + } + + $last_updated = ''; + foreach ($items as $activity) { + if (!empty($activity['published'])) { + $published = DateTimeFormat::utc($activity['published']); + } elseif (!empty($activity['object']['published'])) { + $published = DateTimeFormat::utc($activity['object']['published']); + } else { + continue; + } + + if ($last_updated < $published) { + $last_updated = $published; + } + } + + if (!empty($last_updated)) { + return $last_updated; + } + + return ''; + } + + /** + * Fetch the last activity date from an XML feed + * + * @param array $data Probing result + * @return string last activity + */ + private static function updateFromFeed(array $data) + { + // Search for the newest entry in the feed + $curlResult = DI::httpRequest()->get($data['poll']); + if (!$curlResult->isSuccess()) { + return ''; + } + + $doc = new DOMDocument(); + @$doc->loadXML($curlResult->getBody()); + + $xpath = new DOMXPath($doc); + $xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom'); + + $entries = $xpath->query('/atom:feed/atom:entry'); + + $last_updated = ''; + + foreach ($entries as $entry) { + $published_item = $xpath->query('atom:published/text()', $entry)->item(0); + $updated_item = $xpath->query('atom:updated/text()' , $entry)->item(0); + $published = !empty($published_item->nodeValue) ? DateTimeFormat::utc($published_item->nodeValue) : null; + $updated = !empty($updated_item->nodeValue) ? DateTimeFormat::utc($updated_item->nodeValue) : null; + + if (empty($published) || empty($updated)) { + Logger::notice('Invalid entry for XPath.', ['entry' => $entry, 'url' => $data['url']]); + continue; + } + + if ($last_updated < $published) { + $last_updated = $published; + } + + if ($last_updated < $updated) { + $last_updated = $updated; + } + } + + if (!empty($last_updated)) { + return $last_updated; + } + + return ''; + } + + /** + * Probe data from local profiles without network traffic + * + * @param string $url + * @return array probed data + */ + private static function localProbe(string $url) + { + $uid = User::getIdForURL($url); + if (empty($uid)) { + return []; + } + + $profile = User::getOwnerDataById($uid); + if (empty($profile)) { + return []; + } + + $approfile = ActivityPub\Transmitter::getProfile($uid); + if (empty($approfile)) { + return []; + } + + if (empty($profile['gsid'])) { + $profile['gsid'] = GServer::getID($approfile['generator']['url']); + } + + $data = ['name' => $profile['name'], 'nick' => $profile['nick'], 'guid' => $approfile['diaspora:guid'] ?? '', + 'url' => $profile['url'], 'addr' => $profile['addr'], 'alias' => $profile['alias'], + 'photo' => $profile['photo'], 'account-type' => $profile['contact-type'], + 'community' => ($profile['contact-type'] == User::ACCOUNT_TYPE_COMMUNITY), + 'keywords' => $profile['keywords'], 'location' => $profile['location'], 'about' => $profile['about'], + 'hide' => !$profile['net-publish'], 'batch' => '', 'notify' => $profile['notify'], + 'poll' => $profile['poll'], 'request' => $profile['request'], 'confirm' => $profile['confirm'], + 'subscribe' => $approfile['generator']['url'] . '/follow?url={uri}', 'poco' => $profile['poco'], + 'following' => $approfile['following'], 'followers' => $approfile['followers'], + 'inbox' => $approfile['inbox'], 'outbox' => $approfile['outbox'], + 'sharedinbox' => $approfile['endpoints']['sharedInbox'], 'network' => Protocol::DFRN, + 'pubkey' => $profile['upubkey'], 'baseurl' => $approfile['generator']['url'], 'gsid' => $profile['gsid'], + 'manually-approve' => in_array($profile['page-flags'], [User::PAGE_FLAGS_NORMAL, User::PAGE_FLAGS_PRVGROUP])]; + return self::rearrangeData($data); + } } diff --git a/src/Object/Api/Mastodon/Account.php b/src/Object/Api/Mastodon/Account.php index 99cee205ed..587c6ce6d4 100644 --- a/src/Object/Api/Mastodon/Account.php +++ b/src/Object/Api/Mastodon/Account.php @@ -46,14 +46,14 @@ class Account extends BaseEntity protected $display_name; /** @var bool */ protected $locked; - /** @var string (Datetime) */ + /** @var bool|null */ + protected $bot = null; + /** @var bool */ + protected $discoverable; + /** @var bool */ + protected $group; + /** @var string|null (Datetime) */ protected $created_at; - /** @var int */ - protected $followers_count; - /** @var int */ - protected $following_count; - /** @var int */ - protected $statuses_count; /** @var string */ protected $note; /** @var string (URL)*/ @@ -66,20 +66,20 @@ class Account extends BaseEntity protected $header; /** @var string (URL) */ protected $header_static; + /** @var int */ + protected $followers_count; + /** @var int */ + protected $following_count; + /** @var int */ + protected $statuses_count; + /** @var string|null (Datetime) */ + protected $last_status_at = null; /** @var Emoji[] */ protected $emojis; /** @var Account|null */ protected $moved = null; /** @var Field[]|null */ protected $fields = null; - /** @var bool|null */ - protected $bot = null; - /** @var bool */ - protected $group; - /** @var bool */ - protected $discoverable; - /** @var string|null (Datetime) */ - protected $last_status_at = null; /** * Creates an account record from a public contact record. Expects all contact table fields to be set. @@ -92,18 +92,24 @@ class Account extends BaseEntity */ public function __construct(BaseURL $baseUrl, array $publicContact, Fields $fields, array $apcontact = [], array $userContact = []) { - $this->id = $publicContact['id']; + $this->id = (string)$publicContact['id']; $this->username = $publicContact['nick']; $this->acct = strpos($publicContact['url'], $baseUrl->get() . '/') === 0 ? $publicContact['nick'] : $publicContact['addr']; $this->display_name = $publicContact['name']; - $this->locked = !empty($apcontact['manually-approve']); - $this->created_at = DateTimeFormat::utc($publicContact['created'], DateTimeFormat::ATOM); - $this->followers_count = $apcontact['followers_count'] ?? 0; - $this->following_count = $apcontact['following_count'] ?? 0; - $this->statuses_count = $apcontact['statuses_count'] ?? 0; + $this->locked = (bool)$publicContact['manually-approve'] ?? !empty($apcontact['manually-approve']); + $this->bot = ($publicContact['contact-type'] == Contact::TYPE_NEWS); + $this->discoverable = !$publicContact['unsearchable']; + $this->group = ($publicContact['contact-type'] == Contact::TYPE_COMMUNITY); + + $publicContactCreated = $publicContact['created'] ?: DBA::NULL_DATETIME; + $userContactCreated = $userContact['created'] ?? DBA::NULL_DATETIME; + + $created = $userContactCreated < $publicContactCreated && ($userContactCreated != DBA::NULL_DATETIME) ? $userContactCreated : $publicContactCreated; + $this->created_at = DateTimeFormat::utc($created, DateTimeFormat::ATOM); + $this->note = BBCode::convert($publicContact['about'], false); $this->url = $publicContact['url']; $this->avatar = $userContact['avatar'] ?? $publicContact['avatar']; @@ -111,18 +117,35 @@ class Account extends BaseEntity // No header picture in Friendica $this->header = ''; $this->header_static = ''; - // No custom emojis per account in Friendica - $this->emojis = []; - // No metadata fields in Friendica - $this->fields = $fields->getArrayCopy(); - $this->bot = ($publicContact['contact-type'] == Contact::TYPE_NEWS); - $this->group = ($publicContact['contact-type'] == Contact::TYPE_COMMUNITY); - $this->discoverable = !$publicContact['unsearchable']; + $this->followers_count = $apcontact['followers_count'] ?? 0; + $this->following_count = $apcontact['following_count'] ?? 0; + $this->statuses_count = $apcontact['statuses_count'] ?? 0; $publicContactLastItem = $publicContact['last-item'] ?: DBA::NULL_DATETIME; $userContactLastItem = $userContact['last-item'] ?? DBA::NULL_DATETIME; $lastItem = $userContactLastItem > $publicContactLastItem ? $userContactLastItem : $publicContactLastItem; - $this->last_status_at = $lastItem != DBA::NULL_DATETIME ? DateTimeFormat::utc($lastItem, DateTimeFormat::ATOM) : null; + $this->last_status_at = $lastItem != DBA::NULL_DATETIME ? DateTimeFormat::utc($lastItem, 'Y-m-d') : null; + + // No custom emojis per account in Friendica + $this->emojis = []; + $this->fields = $fields->getArrayCopy(); + + } + + /** + * Returns the current entity as an array + * + * @return array + */ + public function toArray() + { + $account = parent::toArray(); + + if (empty($account['moved'])) { + unset($account['moved']); + } + + return $account; } } diff --git a/src/Object/Api/Mastodon/Activity.php b/src/Object/Api/Mastodon/Activity.php new file mode 100644 index 0000000000..a73307eb47 --- /dev/null +++ b/src/Object/Api/Mastodon/Activity.php @@ -0,0 +1,55 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\BaseEntity; + +/** + * Class Activity + * + * @see https://docs.joinmastodon.org/entities/activity + */ +class Activity extends BaseEntity +{ + /** @var string (UNIX Timestamp) */ + protected $week; + /** @var string */ + protected $statuses; + /** @var string */ + protected $logins; + /** @var string */ + protected $registrations; + + /** + * Creates an activity + * + * @param array $item + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(int $week, int $statuses, int $logins, int $registrations) + { + $this->week = (string)$week; + $this->statuses = (string)$statuses; + $this->logins = (string)$logins; + $this->registrations = (string)$registrations; + } +} diff --git a/src/Object/Api/Mastodon/Application.php b/src/Object/Api/Mastodon/Application.php new file mode 100644 index 0000000000..d26d270d98 --- /dev/null +++ b/src/Object/Api/Mastodon/Application.php @@ -0,0 +1,46 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\BaseEntity; + +/** + * Class Application + * + * @see https://docs.joinmastodon.org/entities/application + */ +class Application extends BaseEntity +{ + /** @var string */ + protected $name; + + /** + * Creates an application entry + * + * @param array $item + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(string $name) + { + $this->name = $name; + } +} diff --git a/src/Object/Api/Mastodon/Attachment.php b/src/Object/Api/Mastodon/Attachment.php new file mode 100644 index 0000000000..1651e9c40c --- /dev/null +++ b/src/Object/Api/Mastodon/Attachment.php @@ -0,0 +1,80 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\BaseEntity; + +/** + * Class Attachment + * + * @see https://docs.joinmastodon.org/entities/attachment + */ +class Attachment extends BaseEntity +{ + /** @var string */ + protected $id; + /** @var string */ + protected $type; + /** @var string */ + protected $url; + /** @var string */ + protected $preview_url; + /** @var string */ + protected $remote_url; + /** @var string */ + protected $text_url; + /** @var string */ + protected $description; + + /** + * Creates an attachment + * + * @param array $attachment + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(array $attachment, string $type, string $url, string $preview, string $remote) + { + $this->id = (string)$attachment['id']; + $this->type = $type; + $this->url = $url; + $this->preview_url = $preview; + $this->remote_url = $remote; + $this->text_url = $this->remote_url ?? $this->url; + $this->description = $attachment['description']; + } + + /** + * Returns the current entity as an array + * + * @return array + */ + public function toArray() + { + $attachment = parent::toArray(); + + if (empty($attachment['remote_url'])) { + $attachment['remote_url'] = null; + } + + return $attachment; + } +} diff --git a/src/Object/Api/Mastodon/Card.php b/src/Object/Api/Mastodon/Card.php new file mode 100644 index 0000000000..2f46e4779d --- /dev/null +++ b/src/Object/Api/Mastodon/Card.php @@ -0,0 +1,78 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\BaseEntity; + +/** + * Class Card + * + * @see https://docs.joinmastodon.org/entities/card + */ +class Card extends BaseEntity +{ + /** @var string */ + protected $url; + /** @var string */ + protected $title; + /** @var string */ + protected $description; + /** @var string */ + protected $type; + /** @var string */ + protected $provider_name; + /** @var string */ + protected $provider_url; + /** @var string */ + protected $image; + + /** + * Creates a card record from an attachment array. + * + * @param array $attachment Attachment record + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(array $attachment) + { + $this->url = $attachment['url'] ?? ''; + $this->title = $attachment['title'] ?? ''; + $this->description = $attachment['description'] ?? ''; + $this->type = $attachment['type'] ?? ''; + $this->image = $attachment['image'] ?? ''; + $this->provider_name = $attachment['provider_name'] ?? ''; + $this->provider_url = $attachment['provider_url'] ?? ''; + } + + /** + * Returns the current entity as an array + * + * @return array + */ + public function toArray() + { + if (empty($this->url)) { + return null; + } + + return parent::toArray(); + } +} diff --git a/src/Object/Api/Mastodon/Error.php b/src/Object/Api/Mastodon/Error.php new file mode 100644 index 0000000000..0bfd1826c6 --- /dev/null +++ b/src/Object/Api/Mastodon/Error.php @@ -0,0 +1,66 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\BaseEntity; + +/** + * Class Error + * + * @see https://docs.joinmastodon.org/entities/error + */ +class Error extends BaseEntity +{ + /** @var string */ + protected $error; + /** @var string */ + protected $error_description; + + /** + * Creates an error record + * + * @param string $error + * @param string error_description + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(string $error, string $error_description) + { + $this->error = $error; + $this->error_description = $error_description; + } + + /** + * Returns the current entity as an array + * + * @return array + */ + public function toArray() + { + $error = parent::toArray(); + + if (empty($error['error_description'])) { + unset($error['error_description']); + } + + return $error; + } +} diff --git a/src/Object/Api/Mastodon/Mention.php b/src/Object/Api/Mastodon/Mention.php new file mode 100644 index 0000000000..22e623e604 --- /dev/null +++ b/src/Object/Api/Mastodon/Mention.php @@ -0,0 +1,66 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\App\BaseURL; +use Friendica\BaseEntity; + +/** + * Class Mention + * + * @see https://docs.joinmastodon.org/entities/mention + */ +class Mention extends BaseEntity +{ + /** @var string */ + protected $id; + /** @var string */ + protected $username; + /** @var string */ + protected $url = null; + /** @var string */ + protected $acct = null; + + /** + * Creates a mention record from an tag-view record. + * + * @param BaseURL $baseUrl + * @param array $tag tag-view record + * @param array $contact contact table record + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(BaseURL $baseUrl, array $tag, array $contact) + { + $this->id = $contact['id'] ?? 0; + $this->username = $tag['name']; + $this->url = $tag['url']; + + if (!empty($contact)) { + $this->acct = + strpos($contact['url'], $baseUrl->get() . '/') === 0 ? + $contact['nick'] : + $contact['addr']; + } else { + $this->acct = ''; + } + } +} diff --git a/src/Object/Api/Mastodon/Stats.php b/src/Object/Api/Mastodon/Stats.php index 8677cf0425..6ead526729 100644 --- a/src/Object/Api/Mastodon/Stats.php +++ b/src/Object/Api/Mastodon/Stats.php @@ -51,7 +51,7 @@ class Stats extends BaseEntity if (!empty(DI::config()->get('system', 'nodeinfo'))) { $stats->user_count = intval(DI::config()->get('nodeinfo', 'total_users')); $stats->status_count = DI::config()->get('nodeinfo', 'local_posts') + DI::config()->get('nodeinfo', 'local_comments'); - $stats->domain_count = DBA::count('gserver', ["`network` in (?, ?) AND `last_contact` >= `last_failure`", Protocol::DFRN, Protocol::ACTIVITYPUB]); + $stats->domain_count = DBA::count('gserver', ["`network` in (?, ?) AND NOT `failed`", Protocol::DFRN, Protocol::ACTIVITYPUB]); } return $stats; } diff --git a/src/Object/Api/Mastodon/Status.php b/src/Object/Api/Mastodon/Status.php new file mode 100644 index 0000000000..d14eb4efa3 --- /dev/null +++ b/src/Object/Api/Mastodon/Status.php @@ -0,0 +1,164 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\BaseEntity; +use Friendica\Content\Text\BBCode; +use Friendica\Object\Api\Mastodon\Status\Counts; +use Friendica\Object\Api\Mastodon\Status\UserAttributes; +use Friendica\Util\DateTimeFormat; + +/** + * Class Status + * + * @see https://docs.joinmastodon.org/entities/status + */ +class Status extends BaseEntity +{ + /** @var string */ + protected $id; + /** @var string (Datetime) */ + protected $created_at; + /** @var string|null */ + protected $in_reply_to_id = null; + /** @var string|null */ + protected $in_reply_to_account_id = null; + /** @var bool */ + protected $sensitive = false; + /** @var string */ + protected $spoiler_text = ""; + /** @var string (Enum of public, unlisted, private, direct)*/ + protected $visibility; + /** @var string|null */ + protected $language = null; + /** @var string */ + protected $uri; + /** @var string|null (URL)*/ + protected $url = null; + /** @var int */ + protected $replies_count = 0; + /** @var int */ + protected $reblogs_count = 0; + /** @var int */ + protected $favourites_count = 0; + /** @var bool */ + protected $favourited = false; + /** @var bool */ + protected $reblogged = false; + /** @var bool */ + protected $muted = false; + /** @var bool */ + protected $bookmarked = false; + /** @var bool */ + protected $pinned = false; + /** @var string */ + protected $content; + /** @var Status|null */ + protected $reblog = null; + /** @var Application */ + protected $application = null; + /** @var Account */ + protected $account; + /** @var Attachment */ + protected $media_attachments = []; + /** @var Mention */ + protected $mentions = []; + /** @var Tag */ + protected $tags = []; + /** @var Emoji[] */ + protected $emojis = []; + /** @var Card|null */ + protected $card = null; + /** @var Poll|null */ + protected $poll = null; + + /** + * Creates a status record from an item record. + * + * @param array $item + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(array $item, Account $account, Counts $counts, UserAttributes $userAttributes, bool $sensitive, Application $application, array $mentions, array $tags, Card $card, array $attachments, array $reblog) + { + $this->id = (string)$item['uri-id']; + $this->created_at = DateTimeFormat::utc($item['created'], DateTimeFormat::ATOM); + + if ($item['gravity'] == GRAVITY_COMMENT) { + $this->in_reply_to_id = (string)$item['thr-parent-id']; + $this->in_reply_to_account_id = (string)$item['parent-author-id']; + } + + $this->sensitive = $sensitive; + $this->spoiler_text = $item['title']; + + $visibility = ['public', 'private', 'unlisted']; + $this->visibility = $visibility[$item['private']]; + + $languages = json_decode($item['language'], true); + $this->language = is_array($languages) ? array_key_first($languages) : null; + + $this->uri = $item['uri']; + $this->url = $item['plink'] ?? null; + $this->replies_count = $counts->replies; + $this->reblogs_count = $counts->reblogs; + $this->favourites_count = $counts->favourites; + $this->favourited = $userAttributes->favourited; + $this->reblogged = $userAttributes->reblogged; + $this->muted = $userAttributes->muted; + $this->bookmarked = $userAttributes->bookmarked; + $this->pinned = $userAttributes->pinned; + $this->content = BBCode::convert($item['raw-body'] ?? $item['body'], false); + $this->reblog = $reblog; + $this->application = $application->toArray(); + $this->account = $account->toArray(); + $this->media_attachments = $attachments; + $this->mentions = $mentions; + $this->tags = $tags; + $this->emojis = []; + $this->card = $card->toArray(); + $this->poll = null; + } + + /** + * Returns the current entity as an array + * + * @return array + */ + public function toArray() + { + $status = parent::toArray(); + + if (!$status['pinned']) { + unset($status['pinned']); + } + + if (empty($status['application']['name'])) { + unset($status['application']); + } + + if (empty($status['reblog'])) { + $status['reblog'] = null; + } + + return $status; + } +} diff --git a/src/Object/Api/Mastodon/Status/Counts.php b/src/Object/Api/Mastodon/Status/Counts.php new file mode 100644 index 0000000000..a0af517dfb --- /dev/null +++ b/src/Object/Api/Mastodon/Status/Counts.php @@ -0,0 +1,56 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon\Status; + +/** + * Class Counts + * + * @see https://docs.joinmastodon.org/entities/status + */ +class Counts +{ + /** @var int */ + protected $replies; + /** @var int */ + protected $reblogs; + /** @var int */ + protected $favourites; + + /** + * Creates a status count object + * + * @param int $replies + * @param int $reblogs + * @param int $favourites + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(int $replies, int $reblogs, int $favourites) + { + $this->replies = $replies; + $this->reblogs = $reblogs; + $this->favourites = $favourites; + } + + public function __get($name) { + return $this->$name; + } +} diff --git a/src/Object/Api/Mastodon/Status/UserAttributes.php b/src/Object/Api/Mastodon/Status/UserAttributes.php new file mode 100644 index 0000000000..c1201c931f --- /dev/null +++ b/src/Object/Api/Mastodon/Status/UserAttributes.php @@ -0,0 +1,64 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon\Status; + +/** + * Class UserAttributes + * + * @see https://docs.joinmastodon.org/entities/status + */ +class UserAttributes +{ + /** @var bool */ + protected $favourited; + /** @var bool */ + protected $reblogged; + /** @var bool */ + protected $muted; + /** @var bool */ + protected $bookmarked; + /** @var bool */ + protected $pinned; + + /** + * Creates a authorized user attributes object + * + * @param bool $favourited + * @param bool $reblogged + * @param bool $muted + * @param bool $bookmarked + * @param bool $pinned + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(bool $favourited, bool $reblogged, bool $muted, bool $bookmarked, bool $pinned) + { + $this->favourited = $favourited; + $this->reblogged = $reblogged; + $this->muted = $muted; + $this->bookmarked = $bookmarked; + $this->pinned = $pinned; + } + + public function __get($name) { + return $this->$name; + } +} diff --git a/src/Object/Api/Mastodon/Tag.php b/src/Object/Api/Mastodon/Tag.php new file mode 100644 index 0000000000..1e74eae00f --- /dev/null +++ b/src/Object/Api/Mastodon/Tag.php @@ -0,0 +1,51 @@ +. + * + */ + +namespace Friendica\Object\Api\Mastodon; + +use Friendica\App\BaseURL; +use Friendica\BaseEntity; + +/** + * Class Tag + * + * @see https://docs.joinmastodon.org/entities/tag + */ +class Tag extends BaseEntity +{ + /** @var string */ + protected $name; + /** @var string */ + protected $url = null; + + /** + * Creates a hashtag record from an tag-view record. + * + * @param BaseURL $baseUrl + * @param array $tag tag-view record + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(BaseURL $baseUrl, array $tag) + { + $this->name = strtolower($tag['name']); + $this->url = $baseUrl . '/search?tag=' . urlencode($this->name); + } +} diff --git a/src/Object/Api/Twitter/User.php b/src/Object/Api/Twitter/User.php new file mode 100644 index 0000000000..1cdd699de8 --- /dev/null +++ b/src/Object/Api/Twitter/User.php @@ -0,0 +1,153 @@ +. + * + */ + +namespace Friendica\Object\Api\Twitter; + +use Friendica\BaseEntity; +use Friendica\Content\ContactSelector; +use Friendica\Content\Text\BBCode; + +/** + * @see https://developer.twitter.com/en/docs/tweets/data-dictionary/overview/user-object + */ +class User extends BaseEntity +{ + /** @var int */ + protected $id; + /** @var string */ + protected $id_str; + /** @var string */ + protected $name; + /** @var string */ + protected $screen_name; + /** @var string|null */ + protected $location; + /** @var array */ + protected $derived; + /** @var string|null */ + protected $url; + /** @var array */ + protected $entities; + /** @var string|null */ + protected $description; + /** @var bool */ + protected $protected; + /** @var bool */ + protected $verified; + /** @var int */ + protected $followers_count; + /** @var int */ + protected $friends_count; + /** @var int */ + protected $listed_count; + /** @var int */ + protected $favourites_count; + /** @var int */ + protected $statuses_count; + /** @var string */ + protected $created_at; + /** @var string */ + protected $profile_banner_url; + /** @var string */ + protected $profile_image_url_https; + /** @var bool */ + protected $default_profile; + /** @var bool */ + protected $default_profile_image; + /** @var Status */ + protected $status; + /** @var array */ + protected $withheld_in_countries; + /** @var string */ + protected $withheld_scope; + + /** + * @param array $publicContact Full contact table record with uid = 0 + * @param array $apcontact Optional full apcontact table record + * @param array $userContact Optional full contact table record with uid != 0 + * @param bool $skip_status Whether to remove the last status property, currently unused + * @param bool $include_user_entities Whether to add the entities property + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public function __construct(array $publicContact, array $apcontact = [], array $userContact = [], $skip_status = false, $include_user_entities = true) + { + $this->id = (int)$publicContact['id']; + $this->id_str = (string) $publicContact['id']; + $this->name = $publicContact['name']; + $this->screen_name = $publicContact['nick'] ?: $publicContact['name']; + $this->location = $publicContact['location'] ?: + ContactSelector::networkToName($publicContact['network'], $publicContact['url'], $publicContact['protocol']); + $this->derived = []; + $this->url = $publicContact['url']; + // No entities needed since we don't perform any shortening in the URL or description + $this->entities = [ + 'url' => ['urls' => []], + 'description' => ['urls' => []], + ]; + if (!$include_user_entities) { + unset($this->entities); + } + $this->description = BBCode::toPlaintext($publicContact['about']); + $this->profile_image_url_https = $userContact['avatar'] ?? $publicContact['avatar']; + $this->protected = false; + $this->followers_count = $apcontact['followers_count'] ?? 0; + $this->friends_count = $apcontact['following_count'] ?? 0; + $this->listed_count = 0; + $this->created_at = api_date($publicContact['created']); + $this->favourites_count = 0; + $this->verified = false; + $this->statuses_count = $apcontact['statuses_count'] ?? 0; + $this->profile_banner_url = ''; + $this->default_profile = false; + $this->default_profile_image = false; + + // @TODO Replace skip_status parameter with an optional Status parameter + unset($this->status); + + // Unused optional fields + unset($this->withheld_in_countries); + unset($this->withheld_scope); + + // Deprecated + $this->profile_image_url = $userContact['avatar'] ?? $publicContact['avatar']; + $this->profile_image_url_profile_size = $publicContact['thumb']; + $this->profile_image_url_large = $publicContact['photo']; + $this->utc_offset = 0; + $this->time_zone = 'UTC'; + $this->geo_enabled = false; + $this->lang = null; + $this->contributors_enabled = false; + $this->is_translator = false; + $this->is_translation_enabled = false; + $this->following = false; + $this->follow_request_sent = false; + $this->statusnet_blocking = false; + $this->notifications = false; + + // Friendica-specific + $this->uid = (int)$userContact['uid'] ?? 0; + $this->cid = (int)$userContact['id'] ?? 0; + $this->pid = (int)$publicContact['id']; + $this->self = (boolean)$userContact['self'] ?? false; + $this->network = $publicContact['network']; + $this->statusnet_profile_url = $publicContact['url']; + } +} diff --git a/src/Object/EMail/IEmail.php b/src/Object/EMail/IEmail.php index 77b5901f37..31384c395c 100644 --- a/src/Object/EMail/IEmail.php +++ b/src/Object/EMail/IEmail.php @@ -83,11 +83,18 @@ interface IEmail extends JsonSerializable function getMessage(bool $plain = false); /** - * Gets any additional mail header + * Gets the additional mail header array + * + * @return string[][] + */ + function getAdditionalMailHeader(); + + /** + * Gets the additional mail header as string - EOL separated * * @return string */ - function getAdditionalMailHeader(); + function getAdditionalMailHeaderString(); /** * Returns the current email with a new recipient diff --git a/src/Object/Email.php b/src/Object/Email.php index 96a7ad88cb..62a6d46224 100644 --- a/src/Object/Email.php +++ b/src/Object/Email.php @@ -47,14 +47,14 @@ class Email implements IEmail /** @var string */ private $msgText; - /** @var string */ - private $additionalMailHeader = ''; + /** @var string[][] */ + private $additionalMailHeader; /** @var int|null */ - private $toUid = null; + private $toUid; public function __construct(string $fromName, string $fromAddress, string $replyTo, string $toAddress, string $subject, string $msgHtml, string $msgText, - string $additionalMailHeader = '', int $toUid = null) + array $additionalMailHeader = [], int $toUid = null) { $this->fromName = $fromName; $this->fromAddress = $fromAddress; @@ -127,6 +127,25 @@ class Email implements IEmail return $this->additionalMailHeader; } + /** + * {@inheritDoc} + */ + public function getAdditionalMailHeaderString() + { + $headerString = ''; + + foreach ($this->additionalMailHeader as $name => $values) { + if (is_array($values)) { + foreach ($values as $value) { + $headerString .= "$name: $value\n"; + } + } else { + $headerString .= "$name: $values\n"; + } + } + return $headerString; + } + /** * {@inheritDoc} */ diff --git a/src/Object/Image.php b/src/Object/Image.php index 4d064f3c3a..d69b01ad4d 100644 --- a/src/Object/Image.php +++ b/src/Object/Image.php @@ -22,7 +22,6 @@ namespace Friendica\Object; use Exception; -use Friendica\Core\System; use Friendica\DI; use Friendica\Util\Images; use Imagick; @@ -123,7 +122,11 @@ class Image $this->image->setFormat($format); // Always coalesce, if it is not a multi-frame image it won't hurt anyway - $this->image = $this->image->coalesceImages(); + try { + $this->image = $this->image->coalesceImages(); + } catch (Exception $e) { + return false; + } /* * setup the compression here, so we'll do it only once @@ -456,7 +459,6 @@ class Image break; } - // Logger::log('exif: ' . print_r($exif,true)); return $exif; } @@ -626,7 +628,7 @@ class Image $stamp1 = microtime(true); file_put_contents($path, $string); - DI::profiler()->saveTimestamp($stamp1, "file", System::callstack()); + DI::profiler()->saveTimestamp($stamp1, "file"); } /** diff --git a/src/Object/Post.php b/src/Object/Post.php index 8488df000f..21b2e4ce14 100644 --- a/src/Object/Post.php +++ b/src/Object/Post.php @@ -38,7 +38,6 @@ use Friendica\Model\User; use Friendica\Protocol\Activity; use Friendica\Util\Crypto; use Friendica\Util\DateTimeFormat; -use Friendica\Util\Proxy as ProxyUtils; use Friendica\Util\Strings; use Friendica\Util\Temporal; @@ -152,9 +151,10 @@ class Post } $sparkle = ''; $buttons = [ - 'like' => null, - 'dislike' => null, - 'share' => null, + 'like' => null, + 'dislike' => null, + 'share' => null, + 'announce' => null, ]; $dropping = false; $pinned = ''; @@ -176,6 +176,12 @@ class Post : false); $shareable = in_array($conv->getProfileOwner(), [0, local_user()]) && $item['private'] != Item::PRIVATE; + $announceable = $shareable && in_array($item['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA, Protocol::TWITTER]); + + // On Diaspora only toplevel posts can be reshared + if ($announceable && ($item['network'] == Protocol::DIASPORA) && ($item['gravity'] != GRAVITY_PARENT)) { + $announceable = false; + } $edpost = false; @@ -214,7 +220,7 @@ class Post $pinned = DI::l10n()->t('pinned item'); } - if ($origin && ($item['id'] != $item['parent']) && ($item['network'] == Protocol::ACTIVITYPUB)) { + if ($origin && ($item['gravity'] != GRAVITY_PARENT) && ($item['network'] == Protocol::ACTIVITYPUB)) { // ActivityPub doesn't allow removal of remote comments $delete = DI::l10n()->t('Delete locally'); } else { @@ -222,15 +228,14 @@ class Post $delete = $origin ? DI::l10n()->t('Delete globally') : DI::l10n()->t('Remove locally'); } - $drop = [ - 'dropping' => $dropping, - 'pagedrop' => $item['pagedrop'], - 'select' => DI::l10n()->t('Select'), - 'delete' => $delete, - ]; - - if (!local_user()) { - $drop = false; + $drop = false; + if (local_user()) { + $drop = [ + 'dropping' => $dropping, + 'pagedrop' => $item['pagedrop'], + 'select' => DI::l10n()->t('Select'), + 'delete' => $delete, + ]; } $filer = (($conv->getProfileOwner() == local_user() && ($item['uid'] != 0)) ? DI::l10n()->t("save to folder") : false); @@ -255,7 +260,7 @@ class Post $locate = ['location' => $item['location'], 'coord' => $item['coord'], 'html' => '']; Hook::callAll('render_location', $locate); - $location = ((strlen($locate['html'])) ? $locate['html'] : render_location_dummy($locate)); + $location_html = $locate['html'] ?: Strings::escapeHtml($locate['location'] ?: $locate['coord'] ?: ''); // process action responses - e.g. like/dislike/attend/agree/whatever $response_verbs = ['like', 'dislike', 'announce']; @@ -275,8 +280,8 @@ class Post $responses = []; foreach ($response_verbs as $value => $verb) { $responses[$verb] = [ - 'self' => $conv_responses[$verb][$item['uri'] . '-self'] ?? 0, - 'output' => !empty($conv_responses[$verb][$item['uri']]) ? format_like($conv_responses[$verb][$item['uri']], $conv_responses[$verb][$item['uri'] . '-l'], $verb, $item['uri']) : '', + 'self' => $conv_responses[$verb][$item['uri']]['self'] ?? 0, + 'output' => !empty($conv_responses[$verb][$item['uri']]) ? format_activity($conv_responses[$verb][$item['uri']]['links'], $verb, $item['uri']) : '', ]; } @@ -346,11 +351,15 @@ class Post $buttons['like'] = [DI::l10n()->t("I like this \x28toggle\x29") , DI::l10n()->t("like")]; $buttons['dislike'] = [DI::l10n()->t("I don't like this \x28toggle\x29"), DI::l10n()->t("dislike")]; if ($shareable) { - $buttons['share'] = [DI::l10n()->t('Share this'), DI::l10n()->t('share')]; + $buttons['share'] = [DI::l10n()->t('Quote share this'), DI::l10n()->t('Quote Share')]; + } + if ($announceable) { + $buttons['announce'] = [DI::l10n()->t('Reshare this'), DI::l10n()->t('Reshare')]; + $buttons['unannounce'] = [DI::l10n()->t('Cancel your Reshare'), DI::l10n()->t('Unshare')]; } } - $comment = $this->getCommentBox($indent); + $comment_html = $this->getCommentBox($indent); if (strcmp(DateTimeFormat::utc($item['created']), DateTimeFormat::utc('now - 12 hours')) > 0) { $shiny = 'shiny'; @@ -358,30 +367,26 @@ class Post localize_item($item); - $body = Item::prepareBody($item, true); + $body_html = Item::prepareBody($item, true); list($categories, $folders) = DI::contentItem()->determineCategoriesTerms($item); - $body_e = $body; - $text_e = strip_tags($body); - $name_e = $profile_name; - if (!empty($item['content-warning']) && DI::pConfig()->get(local_user(), 'system', 'disable_cw', false)) { - $title_e = ucfirst($item['content-warning']); + $title = ucfirst($item['content-warning']); } else { - $title_e = $item['title']; + $title = $item['title']; } - $location_e = $location; - $owner_name_e = $this->getOwnerName(); - if (DI::pConfig()->get(local_user(), 'system', 'hide_dislike')) { $buttons['dislike'] = false; } // Disable features that aren't available in several networks - if ($buttons["dislike"] && !in_array($item["network"], [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA])) { - $buttons["dislike"] = false; + if (!in_array($item["network"], [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA])) { + if ($buttons["dislike"]) { + $buttons["dislike"] = false; + } + $isevent = false; $tagger = ''; } @@ -407,14 +412,21 @@ class Post } $direction = []; - if (DI::config()->get('debug', 'show_direction')) { + if (!empty($item['direction'])) { + $direction = $item['direction']; + } elseif (DI::config()->get('debug', 'show_direction')) { $conversation = DBA::selectFirst('conversation', ['direction'], ['item-uri' => $item['uri']]); if (!empty($conversation['direction']) && in_array($conversation['direction'], [1, 2])) { - $title = [1 => DI::l10n()->t('Pushed'), 2 => DI::l10n()->t('Pulled')]; - $direction = ['direction' => $conversation['direction'], 'title' => $title[$conversation['direction']]]; + $direction_title = [1 => DI::l10n()->t('Pushed'), 2 => DI::l10n()->t('Pulled')]; + $direction = ['direction' => $conversation['direction'], 'title' => $direction_title[$conversation['direction']]]; } } + $languages = []; + if (!empty($item['language'])) { + $languages = [DI::l10n()->t('Languages'), Item::getLanguageMessage($item)]; + } + $tmp_item = [ 'template' => $this->getTemplate(), 'type' => implode("", array_slice(explode("/", $item['verb']), -1)), @@ -429,8 +441,8 @@ class Post 'has_folders' => ((count($folders)) ? 'true' : ''), 'categories' => $categories, 'folders' => $folders, - 'body' => $body_e, - 'text' => $text_e, + 'body_html' => $body_html, + 'text' => strip_tags($body_html), 'id' => $this->getId(), 'guid' => urlencode($item['guid']), 'isevent' => $isevent, @@ -442,24 +454,24 @@ class Post 'wall' => DI::l10n()->t('Wall-to-Wall'), 'vwall' => DI::l10n()->t('via Wall-To-Wall:'), 'profile_url' => $profile_link, - 'item_photo_menu' => item_photo_menu($item), - 'name' => $name_e, - 'thumb' => DI::baseUrl()->remove(ProxyUtils::proxifyUrl($item['author-avatar'], false, ProxyUtils::SIZE_THUMB)), + 'name' => $profile_name, + 'item_photo_menu_html' => item_photo_menu($item), + 'thumb' => DI::baseUrl()->remove($item['author-avatar']), 'osparkle' => $osparkle, 'sparkle' => $sparkle, - 'title' => $title_e, + 'title' => $title, 'localtime' => DateTimeFormat::local($item['created'], 'r'), 'ago' => $item['app'] ? DI::l10n()->t('%s from %s', $ago, $item['app']) : $ago, 'app' => $item['app'], 'created' => $ago, 'lock' => $lock, - 'location' => $location_e, + 'location_html' => $location_html, 'indent' => $indent, 'shiny' => $shiny, 'owner_self' => $item['author-link'] == Session::get('my_url'), 'owner_url' => $this->getOwnerUrl(), - 'owner_photo' => DI::baseUrl()->remove(ProxyUtils::proxifyUrl($item['owner-avatar'], false, ProxyUtils::SIZE_THUMB)), - 'owner_name' => $owner_name_e, + 'owner_photo' => DI::baseUrl()->remove($item['owner-avatar']), + 'owner_name' => $this->getOwnerName(), 'plink' => Item::getPlink($item), 'edpost' => $edpost, 'ispinned' => $ispinned, @@ -470,14 +482,15 @@ class Post 'ignore' => $ignore, 'tagger' => $tagger, 'filer' => $filer, + 'language' => $languages, 'drop' => $drop, 'vote' => $buttons, - 'like' => $responses['like']['output'], - 'dislike' => $responses['dislike']['output'], + 'like_html' => $responses['like']['output'], + 'dislike_html' => $responses['dislike']['output'], 'responses' => $responses, 'switchcomment' => DI::l10n()->t('Comment'), - 'reply_label' => DI::l10n()->t('Reply to %s', $name_e), - 'comment' => $comment, + 'reply_label' => DI::l10n()->t('Reply to %s', $profile_name), + 'comment_html' => $comment_html, 'remote_comment' => $remote_comment, 'menu' => DI::l10n()->t('More'), 'previewing' => $conv->isPreview() ? ' preview ' : '', @@ -490,8 +503,10 @@ class Post 'received' => $item['received'], 'commented' => $item['commented'], 'created_date' => $item['created'], + 'uriid' => $item['uri-id'], 'return' => (DI::args()->getCommand()) ? bin2hex(DI::args()->getCommand()) : '', 'direction' => $direction, + 'reshared' => $item['reshared'] ?? '', 'delivery' => [ 'queue_count' => $item['delivery_queue_count'], 'queue_done' => $item['delivery_queue_done'] + $item['delivery_queue_failed'], /// @todo Possibly display it separately in the future @@ -874,7 +889,7 @@ class Post $terms = Tag::getByURIId($item['uri-id'], [Tag::MENTION, Tag::IMPLICIT_MENTION, Tag::EXCLUSIVE_MENTION]); foreach ($terms as $term) { - $profile = Contact::getDetailsByURL($term['url']); + $profile = Contact::getByURL($term['url'], false, ['addr', 'contact-type']); if (!empty($profile['addr']) && ((($profile['contact-type'] ?? '') ?: Contact::TYPE_UNKNOWN) != Contact::TYPE_COMMUNITY) && ($profile['addr'] != $owner['addr']) && !strstr($text, $profile['addr'])) { $text .= '@' . $profile['addr'] . ' '; @@ -899,21 +914,16 @@ class Post $comment_box = ''; $conv = $this->getThread(); - $ww = ''; - if (($conv->getMode() === 'network') && $this->isWallToWall()) { - $ww = 'ww'; - } if ($conv->isWritable() && $this->isWritable()) { - $qcomment = null; - /* * Hmmm, code depending on the presence of a particular addon? * This should be better if done by a hook */ + $qcomment = null; if (Addon::isEnabled('qcomment')) { - $qc = ((local_user()) ? DI::pConfig()->get(local_user(), 'qcomment', 'words') : null); - $qcomment = (($qc) ? explode("\n", $qc) : null); + $words = DI::pConfig()->get(local_user(), 'qcomment', 'words'); + $qcomment = $words ? explode("\n", $words) : []; } // Fetch the user id from the parent when the owner user is empty @@ -955,7 +965,6 @@ class Post '$preview' => DI::l10n()->t('Preview'), '$indent' => $indent, '$sourceapp' => DI::l10n()->t($a->sourcename), - '$ww' => $conv->getMode() === 'network' ? $ww : '', '$rand_num' => Crypto::randomDigits(12) ]); } @@ -985,7 +994,7 @@ class Post if ($this->isToplevel()) { if ($conv->getMode() !== 'profile') { - if ($this->getDataValue('wall') && !$this->getDataValue('self')) { + if ($this->getDataValue('wall') && !$this->getDataValue('self') && !empty($a->page_contact)) { // On the network page, I am the owner. On the display page it will be the profile owner. // This will have been stored in $a->page_contact by our calling page. // Put this person as the wall owner of the wall-to-wall notice. diff --git a/src/Object/Thread.php b/src/Object/Thread.php index f62b14c71e..6b31ad7049 100644 --- a/src/Object/Thread.php +++ b/src/Object/Thread.php @@ -25,7 +25,7 @@ use Friendica\Core\Logger; use Friendica\Core\Protocol; use Friendica\DI; use Friendica\Protocol\Activity; -use Friendica\Util\Security; +use Friendica\Security\Security; /** * A list of threads diff --git a/src/Protocol/ActivityPub.php b/src/Protocol/ActivityPub.php index c3168f5509..45279576b7 100644 --- a/src/Protocol/ActivityPub.php +++ b/src/Protocol/ActivityPub.php @@ -21,12 +21,13 @@ namespace Friendica\Protocol; -use Friendica\Util\JsonLD; -use Friendica\Util\Network; use Friendica\Core\Protocol; +use Friendica\Database\DBA; +use Friendica\DI; use Friendica\Model\APContact; use Friendica\Model\User; use Friendica\Util\HTTPSignature; +use Friendica\Util\JsonLD; /** * ActivityPub Protocol class @@ -67,7 +68,7 @@ class ActivityPub 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', 'sensitive' => 'as:sensitive', 'Hashtag' => 'as:Hashtag', 'directMessage' => 'litepub:directMessage']]; - const ACCOUNT_TYPES = ['Person', 'Organization', 'Service', 'Group', 'Application']; + const ACCOUNT_TYPES = ['Person', 'Organization', 'Service', 'Group', 'Application', 'Tombstone']; /** * Checks if the web request is done for the AP protocol * @@ -87,24 +88,9 @@ class ActivityPub * @return array * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - public static function fetchContent($url, $uid = 0) + public static function fetchContent(string $url, int $uid = 0) { - if (!empty($uid)) { - return HTTPSignature::fetch($url, $uid); - } - - $curlResult = Network::curl($url, false, ['accept_content' => 'application/activity+json, application/ld+json']); - if (!$curlResult->isSuccess() || empty($curlResult->getBody())) { - return false; - } - - $content = json_decode($curlResult->getBody(), true); - - if (empty($content) || !is_array($content)) { - return false; - } - - return $content; + return HTTPSignature::fetch($url, $uid); } private static function getAccountType($apcontact) @@ -127,6 +113,9 @@ class ActivityPub case 'Application': $accounttype = User::ACCOUNT_TYPE_RELAY; break; + case 'Tombstone': + $accounttype = User::ACCOUNT_TYPE_DELETED; + break; } return $accounttype; @@ -145,7 +134,7 @@ class ActivityPub { $apcontact = APContact::getByURL($url, $update); if (empty($apcontact)) { - return false; + return []; } $profile = ['network' => Protocol::ACTIVITYPUB]; @@ -170,7 +159,10 @@ class ActivityPub $profile['notify'] = $apcontact['inbox']; $profile['poll'] = $apcontact['outbox']; $profile['pubkey'] = $apcontact['pubkey']; + $profile['subscribe'] = $apcontact['subscribe']; + $profile['manually-approve'] = $apcontact['manually-approve']; $profile['baseurl'] = $apcontact['baseurl']; + $profile['gsid'] = $apcontact['gsid']; // Remove all "null" fields foreach ($profile as $field => $content) { diff --git a/src/Protocol/ActivityPub/Processor.php b/src/Protocol/ActivityPub/Processor.php index 197fb2baa3..52aaa9741e 100644 --- a/src/Protocol/ActivityPub/Processor.php +++ b/src/Protocol/ActivityPub/Processor.php @@ -21,6 +21,7 @@ namespace Friendica\Protocol\ActivityPub; +use Friendica\Content\PageInfo; use Friendica\Content\Text\BBCode; use Friendica\Content\Text\HTML; use Friendica\Core\Logger; @@ -36,8 +37,10 @@ use Friendica\Model\ItemURI; use Friendica\Model\Mail; use Friendica\Model\Tag; use Friendica\Model\User; +use Friendica\Model\Post; use Friendica\Protocol\Activity; use Friendica\Protocol\ActivityPub; +use Friendica\Protocol\Relay; use Friendica\Util\DateTimeFormat; use Friendica\Util\JsonLD; use Friendica\Util\Strings; @@ -72,13 +75,57 @@ class Processor */ private static function replaceEmojis($body, array $emojis) { - foreach ($emojis as $emoji) { - $replace = '[class=emoji mastodon][img=' . $emoji['href'] . ']' . $emoji['name'] . '[/img][/class]'; - $body = str_replace($emoji['name'], $replace, $body); - } + $body = strtr($body, + array_combine( + array_column($emojis, 'name'), + array_map(function ($emoji) { + return '[class=emoji mastodon][img=' . $emoji['href'] . ']' . $emoji['name'] . '[/img][/class]'; + }, $emojis) + ) + ); + return $body; } + /** + * Store attached media files in the post-media table + * + * @param int $uriid + * @param array $attachment + * @return void + */ + private static function storeAttachmentAsMedia(int $uriid, array $attachment) + { + if (empty($attachment['url'])) { + return; + } + + $data = ['uri-id' => $uriid]; + + $filetype = strtolower(substr($attachment['mediaType'], 0, strpos($attachment['mediaType'], '/'))); + if ($filetype == 'image') { + $data['type'] = Post\Media::IMAGE; + } elseif ($filetype == 'video') { + $data['type'] = Post\Media::VIDEO; + } elseif ($filetype == 'audio') { + $data['type'] = Post\Media::AUDIO; + } elseif (in_array($attachment['mediaType'], ['application/x-bittorrent', 'application/x-bittorrent;x-scheme-handler/magnet'])) { + $data['type'] = Post\Media::TORRENT; + } else { + Logger::info('Unknown type', ['attachment' => $attachment]); + return; + } + + $data['url'] = $attachment['url']; + $data['mimetype'] = $attachment['mediaType']; + $data['height'] = $attachment['height'] ?? null; + $data['size'] = $attachment['size'] ?? null; + $data['preview'] = $attachment['image'] ?? null; + $data['description'] = $attachment['name'] ?? null; + + Post\Media::insert($data); + } + /** * Add attachment data to the item array * @@ -94,39 +141,66 @@ class Processor } foreach ($activity['attachments'] as $attach) { - $filetype = strtolower(substr($attach['mediaType'], 0, strpos($attach['mediaType'], '/'))); - if ($filetype == 'image') { - if (!empty($activity['source']) && strpos($activity['source'], $attach['url'])) { - continue; - } + switch ($attach['type']) { + case 'link': + $data = [ + 'url' => $attach['url'], + 'type' => $attach['type'], + 'title' => $attach['title'] ?? '', + 'text' => $attach['desc'] ?? '', + 'image' => $attach['image'] ?? '', + 'images' => [], + 'keywords' => [], + ]; + $item['body'] = PageInfo::appendDataToBody($item['body'], $data); + break; + default: + self::storeAttachmentAsMedia($item['uri-id'], $attach); - if (empty($attach['name'])) { - $item['body'] .= "\n[img]" . $attach['url'] . '[/img]'; - } else { - $item['body'] .= "\n[img=" . $attach['url'] . ']' . $attach['name'] . '[/img]'; - } - } elseif ($filetype == 'audio') { - if (!empty($activity['source']) && strpos($activity['source'], $attach['url'])) { - continue; - } + $filetype = strtolower(substr($attach['mediaType'], 0, strpos($attach['mediaType'], '/'))); + if ($filetype == 'image') { + if (!empty($activity['source'])) { + foreach ([0, 1, 2] as $size) { + if (preg_match('#/photo/.*-' . $size . '\.#ism', $attach['url']) && + strpos(preg_replace('#(/photo/.*)-[012]\.#ism', '$1-' . $size . '.', $activity['source']), $attach['url'])) { + continue 3; + } + } + if (strpos($activity['source'], $attach['url'])) { + continue 2; + } + } - $item['body'] .= "\n[audio]" . $attach['url'] . '[/audio]'; - } elseif ($filetype == 'video') { - if (!empty($activity['source']) && strpos($activity['source'], $attach['url'])) { - continue; - } + $item['body'] .= "\n"; - $item['body'] .= "\n[video]" . $attach['url'] . '[/video]'; - } else { - if (!empty($item["attach"])) { - $item["attach"] .= ','; - } else { - $item["attach"] = ''; - } - if (!isset($attach['length'])) { - $attach['length'] = "0"; - } - $item["attach"] .= '[attach]href="'.$attach['url'].'" length="'.$attach['length'].'" type="'.$attach['mediaType'].'" title="'.($attach['name'] ?? '') .'"[/attach]'; + // image is the preview/thumbnail URL + if (!empty($attach['image'])) { + $item['body'] .= '[url=' . $attach['url'] . ']'; + $attach['url'] = $attach['image']; + } + + if (empty($attach['name'])) { + $item['body'] .= '[img]' . $attach['url'] . '[/img]'; + } else { + $item['body'] .= '[img=' . $attach['url'] . ']' . $attach['name'] . '[/img]'; + } + + if (!empty($attach['image'])) { + $item['body'] .= '[/url]'; + } + } elseif ($filetype == 'audio') { + if (!empty($activity['source']) && strpos($activity['source'], $attach['url'])) { + continue 2; + } + + $item['body'] .= "\n[audio]" . $attach['url'] . '[/audio]'; + } elseif ($filetype == 'video') { + if (!empty($activity['source']) && strpos($activity['source'], $attach['url'])) { + continue 2; + } + + $item['body'] .= "\n[video]" . $attach['url'] . '[/video]'; + } } } @@ -144,7 +218,8 @@ class Processor $item = Item::selectFirst(['uri', 'uri-id', 'thr-parent', 'gravity'], ['uri' => $activity['id']]); if (!DBA::isResult($item)) { Logger::warning('No existing item, item will be created', ['uri' => $activity['id']]); - self::createItem($activity); + $item = self::createItem($activity); + self::postItem($activity, $item); return; } @@ -152,6 +227,9 @@ class Processor $item['edited'] = DateTimeFormat::utc($activity['updated']); $item = self::processContent($activity, $item); + + $item = self::constructAttachList($activity, $item); + if (empty($item)) { return; } @@ -163,6 +241,7 @@ class Processor * Prepares data for a message * * @param array $activity Activity array + * @return array Internal item * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ @@ -178,9 +257,6 @@ class Processor } else { $item['gravity'] = GRAVITY_COMMENT; $item['object-type'] = Activity\ObjectType::COMMENT; - - // Ensure that the comment reaches all receivers of the referring post - $activity['receiver'] = self::addReceivers($activity); } if (empty($activity['directmessage']) && ($activity['id'] != $activity['reply-to-id']) && !Item::exists(['uri' => $activity['reply-to-id']])) { @@ -190,7 +266,87 @@ class Processor $item['diaspora_signed_text'] = $activity['diaspora:comment'] ?? ''; - self::postItem($activity, $item); + /// @todo What to do with $activity['context']? + if (empty($activity['directmessage']) && ($item['gravity'] != GRAVITY_PARENT) && !Item::exists(['uri' => $item['thr-parent']])) { + Logger::info('Parent not found, message will be discarded.', ['thr-parent' => $item['thr-parent']]); + return []; + } + + $item['network'] = Protocol::ACTIVITYPUB; + $item['author-link'] = $activity['author']; + $item['author-id'] = Contact::getIdForURL($activity['author']); + $item['owner-link'] = $activity['actor']; + $item['owner-id'] = Contact::getIdForURL($activity['actor']); + + if (in_array(0, $activity['receiver']) && !empty($activity['unlisted'])) { + $item['private'] = Item::UNLISTED; + } elseif (in_array(0, $activity['receiver'])) { + $item['private'] = Item::PUBLIC; + } else { + $item['private'] = Item::PRIVATE; + } + + if (!empty($activity['raw'])) { + $item['source'] = $activity['raw']; + $item['protocol'] = Conversation::PARCEL_ACTIVITYPUB; + $item['conversation-href'] = $activity['context'] ?? ''; + $item['conversation-uri'] = $activity['conversation'] ?? ''; + + if (isset($activity['push'])) { + $item['direction'] = $activity['push'] ? Conversation::PUSH : Conversation::PULL; + } + } + + if (!empty($activity['from-relay'])) { + $item['direction'] = Conversation::RELAY; + } + + $item['isForum'] = false; + + if (!empty($activity['thread-completion'])) { + if ($activity['thread-completion'] != $item['owner-id']) { + $actor = Contact::getById($activity['thread-completion'], ['url']); + $item['causer-link'] = $actor['url']; + $item['causer-id'] = $activity['thread-completion']; + Logger::info('Use inherited actor as causer.', ['id' => $item['owner-id'], 'activity' => $activity['thread-completion'], 'owner' => $item['owner-link'], 'actor' => $actor['url']]); + } else { + // Store the original actor in the "causer" fields to enable the check for ignored or blocked contacts + $item['causer-link'] = $item['owner-link']; + $item['causer-id'] = $item['owner-id']; + Logger::info('Use actor as causer.', ['id' => $item['owner-id'], 'actor' => $item['owner-link']]); + } + + $item['owner-link'] = $item['author-link']; + $item['owner-id'] = $item['author-id']; + } else { + $actor = APContact::getByURL($item['owner-link'], false); + $item['isForum'] = ($actor['type'] == 'Group'); + } + + $item['uri'] = $activity['id']; + + $item['created'] = DateTimeFormat::utc($activity['published']); + $item['edited'] = DateTimeFormat::utc($activity['updated']); + $guid = $activity['sc:identifier'] ?: self::getGUIDByURL($item['uri']); + $item['guid'] = $activity['diaspora:guid'] ?: $guid; + + $item['uri-id'] = ItemURI::insert(['uri' => $item['uri'], 'guid' => $item['guid']]); + if (empty($item['uri-id'])) { + Logger::warning('Unable to get a uri-id for an item uri', ['uri' => $item['uri'], 'guid' => $item['guid']]); + return []; + } + + $item = self::processContent($activity, $item); + if (empty($item)) { + Logger::info('Message was not processed'); + return []; + } + + $item['plink'] = $activity['alternate-url'] ?? $item['uri']; + + $item = self::constructAttachList($activity, $item); + + return $item; } /** @@ -204,7 +360,7 @@ class Processor { $owner = Contact::getIdForURL($activity['actor']); - Logger::log('Deleting item ' . $activity['object_id'] . ' from ' . $owner, Logger::DEBUG); + Logger::info('Deleting item', ['object' => $activity['object_id'], 'owner' => $owner]); Item::markForDeletion(['uri' => $activity['object_id'], 'owner-id' => $owner]); } @@ -238,35 +394,6 @@ class Processor } } - /** - * Add users to the receiver list of the given public activity. - * This is used to ensure that the activity will be stored in every thread. - * - * @param array $activity Activity array - * @return array Modified receiver list - */ - private static function addReceivers(array $activity) - { - if (!in_array(0, $activity['receiver'])) { - // Private activities will not be modified - return $activity['receiver']; - } - - // Add all owners of the referring item to the receivers - $original = $receivers = $activity['receiver']; - $items = Item::select(['uid'], ['uri' => $activity['object_id']]); - while ($item = DBA::fetch($items)) { - $receivers['uid:' . $item['uid']] = $item['uid']; - } - DBA::close($items); - - if (count($original) != count($receivers)) { - Logger::info('Improved data', ['id' => $activity['id'], 'object' => $activity['object_id'], 'original' => $original, 'improved' => $receivers]); - } - - return $receivers; - } - /** * Prepare the item array for an activity * @@ -277,7 +404,7 @@ class Processor */ public static function createActivity($activity, $verb) { - $item = []; + $item = self::createItem($activity); $item['verb'] = $verb; $item['thr-parent'] = $activity['object_id']; $item['gravity'] = GRAVITY_ACTIVITY; @@ -285,8 +412,6 @@ class Processor $item['diaspora_signed_text'] = $activity['diaspora:like'] ?? ''; - $activity['receiver'] = self::addReceivers($activity); - self::postItem($activity, $item); } @@ -299,20 +424,24 @@ class Processor */ public static function createEvent($activity, $item) { - $event['summary'] = HTML::toBBCode($activity['name']); - $event['desc'] = HTML::toBBCode($activity['content']); - $event['start'] = $activity['start-time']; - $event['finish'] = $activity['end-time']; - $event['nofinish'] = empty($event['finish']); - $event['location'] = $activity['location']; - $event['adjust'] = true; - $event['cid'] = $item['contact-id']; - $event['uid'] = $item['uid']; - $event['uri'] = $item['uri']; - $event['edited'] = $item['edited']; - $event['private'] = $item['private']; - $event['guid'] = $item['guid']; - $event['plink'] = $item['plink']; + $event['summary'] = HTML::toBBCode($activity['name']); + $event['desc'] = HTML::toBBCode($activity['content']); + $event['start'] = $activity['start-time']; + $event['finish'] = $activity['end-time']; + $event['nofinish'] = empty($event['finish']); + $event['location'] = $activity['location']; + $event['adjust'] = true; + $event['cid'] = $item['contact-id']; + $event['uid'] = $item['uid']; + $event['uri'] = $item['uri']; + $event['edited'] = $item['edited']; + $event['private'] = $item['private']; + $event['guid'] = $item['guid']; + $event['plink'] = $item['plink']; + $event['network'] = $item['network']; + $event['protocol'] = $item['protocol']; + $event['direction'] = $item['direction']; + $event['source'] = $item['source']; $condition = ['uri' => $item['uri'], 'uid' => $item['uid']]; $ev = DBA::selectFirst('event', ['id'], $condition); @@ -321,7 +450,7 @@ class Processor } $event_id = Event::store($event); - Logger::log('Event '.$event_id.' was stored', Logger::DEBUG); + Logger::info('Event was stored', ['id' => $event_id]); } /** @@ -336,17 +465,18 @@ class Processor { $item['title'] = HTML::toBBCode($activity['name']); + $content = HTML::toBBCode($activity['content']); + + if (!empty($activity['emojis'])) { + $content = self::replaceEmojis($content, $activity['emojis']); + } + + $content = self::convertMentions($content); + if (!empty($activity['source'])) { $item['body'] = $activity['source']; + $item['raw-body'] = $content; } else { - $content = HTML::toBBCode($activity['content']); - - if (!empty($activity['emojis'])) { - $content = self::replaceEmojis($content, $activity['emojis']); - } - - $content = self::convertMentions($content); - if (empty($activity['directmessage']) && ($item['thr-parent'] != $item['uri']) && ($item['gravity'] == GRAVITY_COMMENT)) { $item_private = !in_array(0, $activity['item_receiver']); $parent = Item::selectFirst(['id', 'uri-id', 'private', 'author-link', 'alias'], ['uri' => $item['thr-parent']]); @@ -354,17 +484,15 @@ class Processor Logger::warning('Unknown parent item.', ['uri' => $item['thr-parent']]); return false; } - if ($item_private && ($parent['private'] == Item::PRIVATE)) { + if ($item_private && ($parent['private'] != Item::PRIVATE)) { Logger::warning('Item is private but the parent is not. Dropping.', ['item-uri' => $item['uri'], 'thr-parent' => $item['thr-parent']]); return false; } - $potential_implicit_mentions = self::getImplicitMentionList($parent); - $content = self::removeImplicitMentionsFromBody($content, $potential_implicit_mentions); - $activity['tags'] = self::convertImplicitMentionsInTags($activity['tags'], $potential_implicit_mentions); + $content = self::removeImplicitMentionsFromBody($content, $parent); } $item['content-warning'] = HTML::toBBCode($activity['summary']); - $item['body'] = $content; + $item['raw-body'] = $item['body'] = $content; } self::storeFromBody($item); @@ -372,8 +500,8 @@ class Processor $item['location'] = $activity['location']; - if (!empty($item['latitude']) && !empty($item['longitude'])) { - $item['coord'] = $item['latitude'] . ' ' . $item['longitude']; + if (!empty($activity['latitude']) && !empty($activity['longitude'])) { + $item['coord'] = $activity['latitude'] . ' ' . $activity['longitude']; } $item['app'] = $activity['generator']; @@ -422,72 +550,14 @@ class Processor * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function postItem($activity, $item) + public static function postItem(array $activity, array $item) { - /// @todo What to do with $activity['context']? - if (empty($activity['directmessage']) && ($item['gravity'] != GRAVITY_PARENT) && !Item::exists(['uri' => $item['thr-parent']])) { - Logger::info('Parent not found, message will be discarded.', ['thr-parent' => $item['thr-parent']]); - return; - } - - $item['network'] = Protocol::ACTIVITYPUB; - $item['author-link'] = $activity['author']; - $item['author-id'] = Contact::getIdForURL($activity['author'], 0, true); - $item['owner-link'] = $activity['actor']; - $item['owner-id'] = Contact::getIdForURL($activity['actor'], 0, true); - - if (in_array(0, $activity['receiver']) && !empty($activity['unlisted'])) { - $item['private'] = Item::UNLISTED; - } elseif (in_array(0, $activity['receiver'])) { - $item['private'] = Item::PUBLIC; - } else { - $item['private'] = Item::PRIVATE; - } - - if (!empty($activity['raw'])) { - $item['source'] = $activity['raw']; - $item['protocol'] = Conversation::PARCEL_ACTIVITYPUB; - $item['conversation-href'] = $activity['context'] ?? ''; - $item['conversation-uri'] = $activity['conversation'] ?? ''; - - if (isset($activity['push'])) { - $item['direction'] = $activity['push'] ? Conversation::PUSH : Conversation::PULL; - } - } - - $isForum = false; - - if (!empty($activity['thread-completion'])) { - // Store the original actor in the "causer" fields to enable the check for ignored or blocked contacts - $item['causer-link'] = $item['owner-link']; - $item['causer-id'] = $item['owner-id']; - - Logger::info('Ignoring actor because of thread completion.', ['actor' => $item['owner-link']]); - $item['owner-link'] = $item['author-link']; - $item['owner-id'] = $item['author-id']; - } else { - $actor = APContact::getByURL($item['owner-link'], false); - $isForum = ($actor['type'] == 'Group'); - } - - $item['uri'] = $activity['id']; - - $item['created'] = DateTimeFormat::utc($activity['published']); - $item['edited'] = DateTimeFormat::utc($activity['updated']); - $item['guid'] = $activity['diaspora:guid'] ?: $activity['sc:identifier'] ?: self::getGUIDByURL($item['uri']); - - $item['uri-id'] = ItemURI::insert(['uri' => $item['uri'], 'guid' => $item['guid']]); - - $item = self::processContent($activity, $item); if (empty($item)) { return; } - $item['plink'] = $activity['alternate-url'] ?? $item['uri']; - - $item = self::constructAttachList($activity, $item); - $stored = false; + ksort($activity['receiver']); foreach ($activity['receiver'] as $receiver) { if ($receiver == -1) { @@ -496,14 +566,47 @@ class Processor $item['uid'] = $receiver; - if ($isForum) { - $item['contact-id'] = Contact::getIdForURL($activity['actor'], $receiver, true); + $type = $activity['reception_type'][$receiver] ?? Receiver::TARGET_UNKNOWN; + switch($type) { + case Receiver::TARGET_TO: + $item['post-type'] = Item::PT_TO; + break; + case Receiver::TARGET_CC: + $item['post-type'] = Item::PT_CC; + break; + case Receiver::TARGET_BTO: + $item['post-type'] = Item::PT_BTO; + break; + case Receiver::TARGET_BCC: + $item['post-type'] = Item::PT_BCC; + break; + case Receiver::TARGET_FOLLOWER: + $item['post-type'] = Item::PT_FOLLOWER; + break; + case Receiver::TARGET_ANSWER: + $item['post-type'] = Item::PT_COMMENT; + break; + case Receiver::TARGET_GLOBAL: + $item['post-type'] = Item::PT_GLOBAL; + break; + default: + $item['post-type'] = Item::PT_ARTICLE; + } + + if (!empty($activity['from-relay'])) { + $item['post-type'] = Item::PT_RELAY; + } elseif (!empty($activity['thread-completion'])) { + $item['post-type'] = Item::PT_FETCHED; + } + + if ($item['isForum'] ?? false) { + $item['contact-id'] = Contact::getIdForURL($activity['actor'], $receiver); } else { - $item['contact-id'] = Contact::getIdForURL($activity['author'], $receiver, true); + $item['contact-id'] = Contact::getIdForURL($activity['author'], $receiver); } if (($receiver != 0) && empty($item['contact-id'])) { - $item['contact-id'] = Contact::getIdForURL($activity['author'], 0, true); + $item['contact-id'] = Contact::getIdForURL($activity['author']); } if (!empty($activity['directmessage'])) { @@ -514,7 +617,7 @@ class Processor if (DI::pConfig()->get($receiver, 'system', 'accept_only_sharer', false) && ($receiver != 0) && ($item['gravity'] == GRAVITY_PARENT)) { $skip = !Contact::isSharingByURL($activity['author'], $receiver); - if ($skip && (($activity['type'] == 'as:Announce') || $isForum)) { + if ($skip && (($activity['type'] == 'as:Announce') || ($item['isForum'] ?? false))) { $skip = !Contact::isSharingByURL($activity['actor'], $receiver); } @@ -547,7 +650,7 @@ class Processor $author = APContact::getByURL($item['owner-link'], false); // We send automatic follow requests for reshared messages. (We don't need though for forum posts) if ($author['type'] != 'Group') { - Logger::log('Send follow request for ' . $item['uri'] . ' (' . $stored . ') to ' . $item['author-link'], Logger::DEBUG); + Logger::info('Send follow request', ['uri' => $item['uri'], 'stored' => $stored, 'to' => $item['author-link']]); ActivityPub\Transmitter::sendFollowObject($item['uri'], $item['author-link']); } } @@ -646,7 +749,7 @@ class Processor $title = $matches[3]; } - $title = trim(HTML::toPlaintext(BBCode::convert($title, false, 2, true), 0)); + $title = trim(HTML::toPlaintext(BBCode::convert($title, false, BBCode::API, true), 0)); if (strlen($title) > 20) { $title = substr($title, 0, 20) . '...'; @@ -663,12 +766,13 @@ class Processor /** * Fetches missing posts * - * @param string $url message URL - * @param array $child activity array with the child of this message + * @param string $url message URL + * @param array $child activity array with the child of this message + * @param string $relay_actor Relay actor * @return string fetched message URL * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - public static function fetchMissingActivity($url, $child = []) + public static function fetchMissingActivity(string $url, array $child = [], string $relay_actor = '') { if (!empty($child['receiver'])) { $uid = ActivityPub\Receiver::getFirstUserFromReceivers($child['receiver']); @@ -687,15 +791,26 @@ class Processor return ''; } - if (!empty($child['author'])) { - $actor = $child['author']; - } elseif (!empty($object['actor'])) { - $actor = $object['actor']; + if (!empty($object['actor'])) { + $object_actor = $object['actor']; } elseif (!empty($object['attributedTo'])) { - $actor = $object['attributedTo']; + $object_actor = $object['attributedTo']; + if (is_array($object_actor)) { + $compacted = JsonLD::compact($object); + $object_actor = JsonLD::fetchElement($compacted, 'as:attributedTo', '@id'); + } } else { // Shouldn't happen - $actor = ''; + $object_actor = ''; + } + + $signer = [$object_actor]; + + if (!empty($child['author'])) { + $actor = $child['author']; + $signer[] = $actor; + } else { + $actor = $object_actor; } if (!empty($object['published'])) { @@ -707,7 +822,7 @@ class Processor } $activity = []; - $activity['@context'] = $object['@context']; + $activity['@context'] = $object['@context'] ?? ActivityPub::CONTEXT; unset($object['@context']); $activity['id'] = $object['id']; $activity['to'] = $object['to'] ?? []; @@ -719,15 +834,64 @@ class Processor $ldactivity = JsonLD::compact($activity); - $ldactivity['thread-completion'] = true; + if (!empty($relay_actor)) { + $ldactivity['thread-completion'] = $ldactivity['from-relay'] = Contact::getIdForURL($relay_actor); + } elseif (!empty($child['thread-completion'])) { + $ldactivity['thread-completion'] = $child['thread-completion']; + } else { + $ldactivity['thread-completion'] = Contact::getIdForURL($actor); + } - ActivityPub\Receiver::processActivity($ldactivity, json_encode($activity)); + if (!empty($relay_actor) && !self::acceptIncomingMessage($ldactivity, $object['id'])) { + return ''; + } + + ActivityPub\Receiver::processActivity($ldactivity, json_encode($activity), $uid, true, false, $signer); Logger::notice('Activity had been fetched and processed.', ['url' => $url, 'object' => $activity['id']]); return $activity['id']; } + /** + * Test if incoming relay messages should be accepted + * + * @param array $activity activity array + * @param string $id object ID + * @return boolean true if message is accepted + */ + private static function acceptIncomingMessage(array $activity, string $id) + { + if (empty($activity['as:object'])) { + Logger::info('No object field in activity - accepted', ['id' => $id]); + return true; + } + + $replyto = JsonLD::fetchElement($activity['as:object'], 'as:inReplyTo', '@id'); + if (Item::exists(['uri' => $replyto])) { + Logger::info('Post is a reply to an existing post - accepted', ['id' => $id, 'replyto' => $replyto]); + return true; + } + + $attributed_to = JsonLD::fetchElement($activity['as:object'], 'as:attributedTo', '@id'); + $authorid = Contact::getIdForURL($attributed_to); + + $body = HTML::toBBCode(JsonLD::fetchElement($activity['as:object'], 'as:content', '@value')); + + $messageTags = []; + $tags = Receiver::processTags(JsonLD::fetchElementArray($activity['as:object'], 'as:tag') ?? []); + if (!empty($tags)) { + foreach ($tags as $tag) { + if ($tag['type'] != 'Hashtag') { + continue; + } + $messageTags[] = ltrim(mb_strtolower($tag['name']), '#'); + } + } + + return Relay::isSolicitedPost($messageTags, $body, $authorid, $id, Protocol::ACTIVITYPUB); + } + /** * perform a "follow" request * @@ -743,27 +907,25 @@ class Processor } $owner = User::getOwnerDataById($uid); + if (empty($owner)) { + return; + } $cid = Contact::getIdForURL($activity['actor'], $uid); if (!empty($cid)) { self::switchContact($cid); DBA::update('contact', ['hub-verify' => $activity['id'], 'protocol' => Protocol::ACTIVITYPUB], ['id' => $cid]); - $contact = DBA::selectFirst('contact', [], ['id' => $cid, 'network' => Protocol::NATIVE_SUPPORT]); - } else { - $contact = []; } $item = ['author-id' => Contact::getIdForURL($activity['actor']), 'author-link' => $activity['actor']]; - $note = Strings::escapeTags(trim($activity['content'] ?? '')); - // Ensure that the contact has got the right network type self::switchContact($item['author-id']); - $result = Contact::addRelationship($owner, $contact, $item, false, $note); + $result = Contact::addRelationship($owner, [], $item, false, $activity['content'] ?? ''); if ($result === true) { - ActivityPub\Transmitter::sendContactAccept($item['author-link'], $item['author-id'], $owner['uid']); + ActivityPub\Transmitter::sendContactAccept($item['author-link'], $activity['id'], $owner['uid']); } $cid = Contact::getIdForURL($activity['actor'], $uid); @@ -790,8 +952,8 @@ class Processor return; } - Logger::log('Updating profile for ' . $activity['object_id'], Logger::DEBUG); - Contact::updateFromProbeByURL($activity['object_id'], true); + Logger::info('Updating profile', ['object' => $activity['object_id']]); + Contact::updateFromProbeByURL($activity['object_id']); } /** @@ -803,12 +965,12 @@ class Processor public static function deletePerson($activity) { if (empty($activity['object_id']) || empty($activity['actor'])) { - Logger::log('Empty object id or actor.', Logger::DEBUG); + Logger::info('Empty object id or actor.'); return; } if ($activity['object_id'] != $activity['actor']) { - Logger::log('Object id does not match actor.', Logger::DEBUG); + Logger::info('Object id does not match actor.'); return; } @@ -818,7 +980,7 @@ class Processor } DBA::close($contacts); - Logger::log('Deleted contact ' . $activity['object_id'], Logger::DEBUG); + Logger::info('Deleted contact', ['object' => $activity['object_id']]); } /** @@ -837,7 +999,7 @@ class Processor $cid = Contact::getIdForURL($activity['actor'], $uid); if (empty($cid)) { - Logger::log('No contact found for ' . $activity['actor'], Logger::DEBUG); + Logger::info('No contact found', ['actor' => $activity['actor']]); return; } @@ -852,7 +1014,7 @@ class Processor $condition = ['id' => $cid]; DBA::update('contact', $fields, $condition); - Logger::log('Accept contact request from contact ' . $cid . ' for user ' . $uid, Logger::DEBUG); + Logger::info('Accept contact request', ['contact' => $cid, 'user' => $uid]); } /** @@ -871,7 +1033,7 @@ class Processor $cid = Contact::getIdForURL($activity['actor'], $uid); if (empty($cid)) { - Logger::log('No contact found for ' . $activity['actor'], Logger::DEBUG); + Logger::info('No contact found', ['actor' => $activity['actor']]); return; } @@ -879,9 +1041,9 @@ class Processor if (DBA::exists('contact', ['id' => $cid, 'rel' => Contact::SHARING])) { Contact::remove($cid); - Logger::log('Rejected contact request from contact ' . $cid . ' for user ' . $uid . ' - contact had been removed.', Logger::DEBUG); + Logger::info('Rejected contact request - contact removed', ['contact' => $cid, 'user' => $uid]); } else { - Logger::log('Rejected contact request from contact ' . $cid . ' for user ' . $uid . '.', Logger::DEBUG); + Logger::info('Rejected contact request', ['contact' => $cid, 'user' => $uid]); } } @@ -925,10 +1087,13 @@ class Processor } $owner = User::getOwnerDataById($uid); + if (empty($owner)) { + return; + } $cid = Contact::getIdForURL($activity['actor'], $uid); if (empty($cid)) { - Logger::log('No contact found for ' . $activity['actor'], Logger::DEBUG); + Logger::info('No contact found', ['actor' => $activity['actor']]); return; } @@ -940,7 +1105,7 @@ class Processor } Contact::removeFollower($owner, $contact); - Logger::log('Undo following request from contact ' . $cid . ' for user ' . $uid, Logger::DEBUG); + Logger::info('Undo following request', ['contact' => $cid, 'user' => $uid]); } /** @@ -971,16 +1136,12 @@ class Processor */ private static function getImplicitMentionList(array $parent) { - if (DI::config()->get('system', 'disable_implicit_mentions')) { - return []; - } - $parent_terms = Tag::getByURIId($parent['uri-id'], [Tag::MENTION, Tag::IMPLICIT_MENTION, Tag::EXCLUSIVE_MENTION]); - $parent_author = Contact::getDetailsByURL($parent['author-link'], 0); + $parent_author = Contact::getByURL($parent['author-link'], false, ['url', 'nurl', 'alias']); $implicit_mentions = []; - if (empty($parent_author)) { + if (empty($parent_author['url'])) { Logger::notice('Author public contact unknown.', ['author-link' => $parent['author-link'], 'item-id' => $parent['id']]); } else { $implicit_mentions[] = $parent_author['url']; @@ -993,8 +1154,8 @@ class Processor } foreach ($parent_terms as $term) { - $contact = Contact::getDetailsByURL($term['url'], 0); - if (!empty($contact)) { + $contact = Contact::getByURL($term['url'], false, ['url', 'nurl', 'alias']); + if (!empty($contact['url'])) { $implicit_mentions[] = $contact['url']; $implicit_mentions[] = $contact['nurl']; $implicit_mentions[] = $contact['alias']; @@ -1008,15 +1169,17 @@ class Processor * Strips from the body prepended implicit mentions * * @param string $body - * @param array $potential_mentions + * @param array $parent * @return string */ - private static function removeImplicitMentionsFromBody($body, array $potential_mentions) + private static function removeImplicitMentionsFromBody(string $body, array $parent) { if (DI::config()->get('system', 'disable_implicit_mentions')) { return $body; } + $potential_mentions = self::getImplicitMentionList($parent); + $kept_mentions = []; // Extract one prepended mention at a time from the body @@ -1033,24 +1196,4 @@ class Processor return implode('', $kept_mentions); } - - private static function convertImplicitMentionsInTags($activity_tags, array $potential_mentions) - { - if (DI::config()->get('system', 'disable_implicit_mentions')) { - return $activity_tags; - } - - foreach ($activity_tags as $index => $tag) { - if (in_array($tag['href'], $potential_mentions)) { - $activity_tags[$index]['name'] = preg_replace( - '/' . preg_quote(Tag::TAG_CHARACTER[Tag::MENTION], '/') . '/', - Tag::TAG_CHARACTER[Tag::IMPLICIT_MENTION], - $activity_tags[$index]['name'], - 1 - ); - } - } - - return $activity_tags; - } } diff --git a/src/Protocol/ActivityPub/Receiver.php b/src/Protocol/ActivityPub/Receiver.php index 508e5acd53..024d9d4591 100644 --- a/src/Protocol/ActivityPub/Receiver.php +++ b/src/Protocol/ActivityPub/Receiver.php @@ -21,6 +21,7 @@ namespace Friendica\Protocol\ActivityPub; +use Friendica\Content\Text\BBCode; use Friendica\Database\DBA; use Friendica\Content\Text\HTML; use Friendica\Content\Text\Markdown; @@ -32,7 +33,6 @@ use Friendica\Model\Item; use Friendica\Model\User; use Friendica\Protocol\Activity; use Friendica\Protocol\ActivityPub; -use Friendica\Util\DateTimeFormat; use Friendica\Util\HTTPSignature; use Friendica\Util\JsonLD; use Friendica\Util\LDSignature; @@ -58,6 +58,15 @@ class Receiver const CONTENT_TYPES = ['as:Note', 'as:Article', 'as:Video', 'as:Image', 'as:Event', 'as:Audio']; const ACTIVITY_TYPES = ['as:Like', 'as:Dislike', 'as:Accept', 'as:Reject', 'as:TentativeAccept']; + const TARGET_UNKNOWN = 0; + const TARGET_TO = 1; + const TARGET_CC = 2; + const TARGET_BTO = 3; + const TARGET_BCC = 4; + const TARGET_FOLLOWER = 5; + const TARGET_ANSWER = 6; + const TARGET_GLOBAL = 7; + /** * Checks if the web request is done for the AP protocol * @@ -79,16 +88,7 @@ class Receiver */ public static function processInbox($body, $header, $uid) { - $http_signer = HTTPSignature::getSigner($body, $header); - if (empty($http_signer)) { - Logger::warning('Invalid HTTP signature, message will be discarded.'); - return; - } else { - Logger::info('Valid HTTP signature', ['signer' => $http_signer]); - } - $activity = json_decode($body, true); - if (empty($activity)) { Logger::warning('Invalid body.'); return; @@ -98,12 +98,34 @@ class Receiver $actor = JsonLD::fetchElement($ldactivity, 'as:actor', '@id'); + $apcontact = APContact::getByURL($actor); + if (empty($apcontact)) { + Logger::notice('Unable to retrieve AP contact for actor', ['actor' => $actor]); + } elseif ($apcontact['type'] == 'Application' && $apcontact['nick'] == 'relay') { + self::processRelayPost($ldactivity, $actor); + return; + } else { + APContact::unmarkForArchival($apcontact); + } + + $http_signer = HTTPSignature::getSigner($body, $header); + if (empty($http_signer)) { + Logger::warning('Invalid HTTP signature, message will be discarded.'); + return; + } else { + Logger::info('Valid HTTP signature', ['signer' => $http_signer]); + } + + $signer = [$http_signer]; + Logger::info('Message for user ' . $uid . ' is from actor ' . $actor); if (LDSignature::isSigned($activity)) { $ld_signer = LDSignature::getSigner($activity); if (empty($ld_signer)) { Logger::log('Invalid JSON-LD signature from ' . $actor, Logger::DEBUG); + } elseif ($ld_signer != $http_signer) { + $signer[] = $ld_signer; } if (!empty($ld_signer && ($actor == $http_signer))) { Logger::log('The HTTP and the JSON-LD signature belong to ' . $ld_signer, Logger::DEBUG); @@ -126,7 +148,66 @@ class Receiver $trust_source = false; } - self::processActivity($ldactivity, $body, $uid, $trust_source, true); + self::processActivity($ldactivity, $body, $uid, $trust_source, true, $signer); + } + + /** + * Process incoming posts from relays + * + * @param array $activity + * @param string $actor + * @return void + */ + private static function processRelayPost(array $activity, string $actor) + { + $type = JsonLD::fetchElement($activity, '@type'); + if (!$type) { + Logger::info('Empty type', ['activity' => $activity]); + return; + } + + if ($type != 'as:Announce') { + Logger::info('Not an announcement', ['activity' => $activity]); + return; + } + + $object_id = JsonLD::fetchElement($activity, 'as:object', '@id'); + if (empty($object_id)) { + Logger::info('No object id found', ['activity' => $activity]); + return; + } + + $contact = Contact::getByURL($actor); + if (empty($contact)) { + Logger::info('Relay contact not found', ['actor' => $actor]); + return; + } + + if (!in_array($contact['rel'], [Contact::SHARING, Contact::FRIEND])) { + Logger::notice('Relay is no sharer', ['actor' => $actor]); + return; + } + + Logger::info('Got relayed message id', ['id' => $object_id]); + + $item_id = Item::searchByLink($object_id); + if ($item_id) { + Logger::info('Relayed message already exists', ['id' => $object_id, 'item' => $item_id]); + return; + } + + $id = Processor::fetchMissingActivity($object_id, [], $actor); + if (empty($id)) { + Logger::notice('Relayed message had not been fetched', ['id' => $object_id]); + return; + } + + $item_id = Item::searchByLink($object_id); + if ($item_id) { + Logger::info('Relayed message had been fetched and stored', ['id' => $object_id, 'item' => $item_id]); + } else { + Logger::notice('Relayed message had not been stored', ['id' => $object_id]); + } } /** @@ -156,6 +237,7 @@ class Receiver $profile = APContact::getByURL($object_id); if (!empty($profile['type'])) { + APContact::unmarkForArchival($profile); return 'as:' . $profile['type']; } @@ -183,31 +265,55 @@ class Receiver * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function prepareObjectData($activity, $uid, $push, &$trust_source) + public static function prepareObjectData($activity, $uid, $push, &$trust_source) { + $id = JsonLD::fetchElement($activity, '@id'); + if (!empty($id) && !$trust_source) { + $fetched_activity = ActivityPub::fetchContent($id, $uid ?? 0); + if (!empty($fetched_activity)) { + $object = JsonLD::compact($fetched_activity); + $fetched_id = JsonLD::fetchElement($object, '@id'); + if ($fetched_id == $id) { + Logger::info('Activity had been fetched successfully', ['id' => $id]); + $trust_source = true; + $activity = $object; + } else { + Logger::info('Activity id is not equal', ['id' => $id, 'fetched' => $fetched_id]); + } + } else { + Logger::info('Activity could not been fetched', ['id' => $id]); + } + } + $actor = JsonLD::fetchElement($activity, 'as:actor', '@id'); if (empty($actor)) { - Logger::log('Empty actor', Logger::DEBUG); + Logger::info('Empty actor', ['activity' => $activity]); return []; } $type = JsonLD::fetchElement($activity, '@type'); // Fetch all receivers from to, cc, bto and bcc - $receivers = self::getReceivers($activity, $actor); + $receiverdata = self::getReceivers($activity, $actor); + $receivers = $reception_types = []; + foreach ($receiverdata as $key => $data) { + $receivers[$key] = $data['uid']; + $reception_types[$data['uid']] = $data['type'] ?? self::TARGET_UNKNOWN; + } // When it is a delivery to a personal inbox we add that user to the receivers if (!empty($uid)) { - $additional = ['uid:' . $uid => $uid]; - $receivers = array_merge($receivers, $additional); + $additional = [$uid => $uid]; + $receivers = array_replace($receivers, $additional); + if (empty($activity['thread-completion']) && (empty($reception_types[$uid]) || in_array($reception_types[$uid], [self::TARGET_UNKNOWN, self::TARGET_FOLLOWER, self::TARGET_ANSWER, self::TARGET_GLOBAL]))) { + $reception_types[$uid] = self::TARGET_BCC; + } } else { // We possibly need some user to fetch private content, // so we fetch the first out ot the list. $uid = self::getFirstUserFromReceivers($receivers); } - Logger::log('Receivers: ' . $uid . ' - ' . json_encode($receivers), Logger::DEBUG); - $object_id = JsonLD::fetchElement($activity, 'as:object', '@id'); if (empty($object_id)) { Logger::log('No object found', Logger::DEBUG); @@ -223,10 +329,8 @@ class Receiver // Fetch the content only on activities where this matters if (in_array($type, ['as:Create', 'as:Update', 'as:Announce'])) { - if ($type == 'as:Announce') { - $trust_source = false; - } - $object_data = self::fetchObject($object_id, $activity['as:object'], $trust_source, $uid); + // Always fetch on "Announce" + $object_data = self::fetchObject($object_id, $activity['as:object'], $trust_source && ($type != 'as:Announce'), $uid); if (empty($object_data)) { Logger::log("Object data couldn't be processed", Logger::DEBUG); return []; @@ -246,9 +350,6 @@ class Receiver } else { $object_data['directmessage'] = JsonLD::fetchElement($activity, 'litepub:directMessage'); } - - // We had been able to retrieve the object data - so we can trust the source - $trust_source = true; } elseif (in_array($type, array_merge(self::ACTIVITY_TYPES, ['as:Follow'])) && in_array($object_type, self::CONTENT_TYPES)) { // Create a mostly empty array out of the activity data (instead of the object). // This way we later don't have to check for the existence of ech individual array element. @@ -257,6 +358,7 @@ class Receiver $object_data['author'] = JsonLD::fetchElement($activity, 'as:actor', '@id'); $object_data['object_id'] = $object_id; $object_data['object_type'] = ''; // Since we don't fetch the object, we don't know the type + $object_data['push'] = $push; } elseif (in_array($type, ['as:Add'])) { $object_data = []; $object_data['id'] = JsonLD::fetchElement($activity, '@id'); @@ -264,6 +366,7 @@ class Receiver $object_data['object_id'] = JsonLD::fetchElement($activity, 'as:object', '@id'); $object_data['object_type'] = JsonLD::fetchElement($activity['as:object'], '@type'); $object_data['object_content'] = JsonLD::fetchElement($activity['as:object'], 'as:content', '@type'); + $object_data['push'] = $push; } else { $object_data = []; $object_data['id'] = JsonLD::fetchElement($activity, '@id'); @@ -271,9 +374,10 @@ class Receiver $object_data['object_actor'] = JsonLD::fetchElement($activity['as:object'], 'as:actor', '@id'); $object_data['object_object'] = JsonLD::fetchElement($activity['as:object'], 'as:object'); $object_data['object_type'] = JsonLD::fetchElement($activity['as:object'], '@type'); + $object_data['push'] = $push; // An Undo is done on the object of an object, so we need that type as well - if ($type == 'as:Undo') { + if (($type == 'as:Undo') && !empty($object_data['object_object'])) { $object_data['object_object_type'] = self::fetchObjectType([], $object_data['object_object'], $uid); } } @@ -287,7 +391,20 @@ class Receiver $object_data['type'] = $type; $object_data['actor'] = $actor; $object_data['item_receiver'] = $receivers; - $object_data['receiver'] = array_merge($object_data['receiver'] ?? [], $receivers); + $object_data['receiver'] = array_replace($object_data['receiver'] ?? [], $receivers); + $object_data['reception_type'] = array_replace($object_data['reception_type'] ?? [], $reception_types); + + $author = $object_data['author'] ?? $actor; + if (!empty($author) && !empty($object_data['id'])) { + $author_host = parse_url($author, PHP_URL_HOST); + $id_host = parse_url($object_data['id'], PHP_URL_HOST); + if ($author_host == $id_host) { + Logger::info('Valid hosts', ['type' => $type, 'host' => $id_host]); + } else { + Logger::notice('Differing hosts on author and id', ['type' => $type, 'author' => $author_host, 'id' => $id_host]); + $trust_source = false; + } + } Logger::log('Processing ' . $object_data['type'] . ' ' . $object_data['object_type'] . ' ' . $object_data['id'], Logger::DEBUG); @@ -320,44 +437,49 @@ class Receiver * @param boolean $push Message had been pushed to our system * @throws \Exception */ - public static function processActivity($activity, $body = '', $uid = null, $trust_source = false, $push = false) + public static function processActivity($activity, string $body = '', int $uid = null, bool $trust_source = false, bool $push = false, array $signer = []) { $type = JsonLD::fetchElement($activity, '@type'); if (!$type) { - Logger::log('Empty type', Logger::DEBUG); + Logger::info('Empty type', ['activity' => $activity]); return; } if (!JsonLD::fetchElement($activity, 'as:object', '@id')) { - Logger::log('Empty object', Logger::DEBUG); + Logger::info('Empty object', ['activity' => $activity]); return; } - if (!JsonLD::fetchElement($activity, 'as:actor', '@id')) { - Logger::log('Empty actor', Logger::DEBUG); + $actor = JsonLD::fetchElement($activity, 'as:actor', '@id'); + if (empty($actor)) { + Logger::info('Empty actor', ['activity' => $activity]); return; - } - // Don't trust the source if "actor" differs from "attributedTo". The content could be forged. - if ($trust_source && ($type == 'as:Create') && is_array($activity['as:object'])) { - $actor = JsonLD::fetchElement($activity, 'as:actor', '@id'); + if (is_array($activity['as:object'])) { $attributed_to = JsonLD::fetchElement($activity['as:object'], 'as:attributedTo', '@id'); - $trust_source = ($actor == $attributed_to); - if (!$trust_source) { - Logger::log('Not trusting actor: ' . $actor . '. It differs from attributedTo: ' . $attributed_to, Logger::DEBUG); + } else { + $attributed_to = ''; + } + + // Test the provided signatures against the actor and "attributedTo" + if ($trust_source) { + if (!empty($attributed_to) && !empty($actor)) { + $trust_source = (in_array($actor, $signer) && in_array($attributed_to, $signer)); + } else { + $trust_source = in_array($actor, $signer); } } // $trust_source is called by reference and is set to true if the content was retrieved successfully $object_data = self::prepareObjectData($activity, $uid, $push, $trust_source); if (empty($object_data)) { - Logger::log('No object data found', Logger::DEBUG); + Logger::info('No object data found', ['activity' => $activity]); return; } if (!$trust_source) { - Logger::log('No trust for activity type "' . $type . '", so we quit now.', Logger::DEBUG); + Logger::info('Activity trust could not be achieved.', ['id' => $object_data['object_id'], 'type' => $type, 'signer' => $signer, 'actor' => $actor, 'attributedTo' => $attributed_to]); return; } @@ -370,10 +492,16 @@ class Receiver $object_data['thread-completion'] = $activity['thread-completion']; } + // Internal flag for posts that arrived via relay + if (!empty($activity['from-relay'])) { + $object_data['from-relay'] = $activity['from-relay']; + } + switch ($type) { case 'as:Create': if (in_array($object_data['object_type'], self::CONTENT_TYPES)) { - ActivityPub\Processor::createItem($object_data); + $item = ActivityPub\Processor::createItem($object_data); + ActivityPub\Processor::postItem($object_data, $item); } break; @@ -385,28 +513,28 @@ class Receiver case 'as:Announce': if (in_array($object_data['object_type'], self::CONTENT_TYPES)) { - $profile = APContact::getByURL($object_data['actor']); - // Reshared posts from persons appear as summary at the bottom - // If this isn't set, then a single reshare appears on top. This is used for groups. - $object_data['thread-completion'] = ($profile['type'] != 'Group'); + $object_data['thread-completion'] = Contact::getIdForURL($actor); - ActivityPub\Processor::createItem($object_data); - - // Add the bottom reshare information only for persons - if ($profile['type'] != 'Group') { - $announce_object_data = self::processObject($activity); - $announce_object_data['name'] = $type; - $announce_object_data['author'] = JsonLD::fetchElement($activity, 'as:actor', '@id'); - $announce_object_data['object_id'] = $object_data['object_id']; - $announce_object_data['object_type'] = $object_data['object_type']; - $announce_object_data['push'] = $push; - - if (!empty($body)) { - $announce_object_data['raw'] = $body; - } - - ActivityPub\Processor::createActivity($announce_object_data, Activity::ANNOUNCE); + $item = ActivityPub\Processor::createItem($object_data); + if (empty($item)) { + return; } + + $item['post-type'] = Item::PT_ANNOUNCEMENT; + ActivityPub\Processor::postItem($object_data, $item); + + $announce_object_data = self::processObject($activity); + $announce_object_data['name'] = $type; + $announce_object_data['author'] = JsonLD::fetchElement($activity, 'as:actor', '@id'); + $announce_object_data['object_id'] = $object_data['object_id']; + $announce_object_data['object_type'] = $object_data['object_type']; + $announce_object_data['push'] = $push; + + if (!empty($body)) { + $announce_object_data['raw'] = $body; + } + + ActivityPub\Processor::createActivity($announce_object_data, Activity::ANNOUNCE); } break; @@ -501,18 +629,30 @@ class Receiver */ private static function getReceivers($activity, $actor, $tags = [], $fetch_unlisted = false) { - $receivers = []; + $reply = $receivers = []; // When it is an answer, we inherite the receivers from the parent $replyto = JsonLD::fetchElement($activity, 'as:inReplyTo', '@id'); if (!empty($replyto)) { + $reply = [$replyto]; + // Fix possibly wrong item URI (could be an answer to a plink uri) $fixedReplyTo = Item::getURIByLink($replyto); - $replyto = $fixedReplyTo ?: $replyto; + if (!empty($fixedReplyTo)) { + $reply[] = $fixedReplyTo; + } + } - $parents = Item::select(['uid'], ['uri' => $replyto]); + // Fetch all posts that refer to the object id + $object_id = JsonLD::fetchElement($activity, 'as:object', '@id'); + if (!empty($object_id)) { + $reply[] = $object_id; + } + + if (!empty($reply)) { + $parents = Item::select(['uid'], ['uri' => $reply]); while ($parent = Item::fetch($parents)) { - $receivers['uid:' . $parent['uid']] = $parent['uid']; + $receivers[$parent['uid']] = ['uid' => $parent['uid'], 'type' => self::TARGET_ANSWER]; } } @@ -522,10 +662,13 @@ class Receiver Logger::log('Actor: ' . $actor . ' - Followers: ' . $followers, Logger::DEBUG); } else { - Logger::log('Empty actor', Logger::DEBUG); + Logger::info('Empty actor', ['activity' => $activity]); $followers = ''; } + // We have to prevent false follower assumptions upon thread completions + $follower_target = empty($activity['thread-completion']) ? self::TARGET_FOLLOWER : self::TARGET_UNKNOWN; + foreach (['as:to', 'as:cc', 'as:bto', 'as:bcc'] as $element) { $receiver_list = JsonLD::fetchElementArray($activity, $element, '@id'); if (empty($receiver_list)) { @@ -534,29 +677,17 @@ class Receiver foreach ($receiver_list as $receiver) { if ($receiver == self::PUBLIC_COLLECTION) { - $receivers['uid:0'] = 0; + $receivers[0] = ['uid' => 0, 'type' => self::TARGET_GLOBAL]; } // Add receiver "-1" for unlisted posts if ($fetch_unlisted && ($receiver == self::PUBLIC_COLLECTION) && ($element == 'as:cc')) { - $receivers['uid:-1'] = -1; - } - - if (($receiver == self::PUBLIC_COLLECTION) && !empty($actor)) { - // This will most likely catch all OStatus connections to Mastodon - $condition = ['alias' => [$actor, Strings::normaliseLink($actor)], 'rel' => [Contact::SHARING, Contact::FRIEND] - , 'archive' => false, 'pending' => false]; - $contacts = DBA::select('contact', ['uid'], $condition); - while ($contact = DBA::fetch($contacts)) { - if ($contact['uid'] != 0) { - $receivers['uid:' . $contact['uid']] = $contact['uid']; - } - } - DBA::close($contacts); + $receivers[-1] = ['uid' => -1, 'type' => self::TARGET_GLOBAL]; } + // Fetch the receivers for the public and the followers collection if (in_array($receiver, [$followers, self::PUBLIC_COLLECTION]) && !empty($actor)) { - $receivers = array_merge($receivers, self::getReceiverForActor($actor, $tags)); + $receivers = self::getReceiverForActor($actor, $tags, $receivers, $follower_target); continue; } @@ -584,7 +715,25 @@ class Receiver } } - $receivers['uid:' . $contact['uid']] = $contact['uid']; + $type = $receivers[$contact['uid']]['type'] ?? self::TARGET_UNKNOWN; + if (in_array($type, [self::TARGET_UNKNOWN, self::TARGET_FOLLOWER, self::TARGET_ANSWER, self::TARGET_GLOBAL])) { + switch ($element) { + case 'as:to': + $type = self::TARGET_TO; + break; + case 'as:cc': + $type = self::TARGET_CC; + break; + case 'as:bto': + $type = self::TARGET_BTO; + break; + case 'as:bcc': + $type = self::TARGET_BCC; + break; + } + + $receivers[$contact['uid']] = ['uid' => $contact['uid'], 'type' => $type]; + } } } @@ -596,22 +745,34 @@ class Receiver /** * Fetch the receiver list of a given actor * - * @param string $actor - * @param array $tags + * @param string $actor + * @param array $tags + * @param array $receivers + * @param integer $target_type * * @return array with receivers (user id) * @throws \Exception */ - public static function getReceiverForActor($actor, $tags) + private static function getReceiverForActor($actor, $tags, $receivers, $target_type) { - $receivers = []; - $networks = Protocol::FEDERATED; - $condition = ['nurl' => Strings::normaliseLink($actor), 'rel' => [Contact::SHARING, Contact::FRIEND, Contact::FOLLOWER], - 'network' => $networks, 'archive' => false, 'pending' => false]; + $basecondition = ['rel' => [Contact::SHARING, Contact::FRIEND, Contact::FOLLOWER], + 'network' => Protocol::FEDERATED, 'archive' => false, 'pending' => false]; + + $condition = DBA::mergeConditions($basecondition, ["`nurl` = ? AND `uid` != ?", Strings::normaliseLink($actor), 0]); $contacts = DBA::select('contact', ['uid', 'rel'], $condition); while ($contact = DBA::fetch($contacts)) { - if (self::isValidReceiverForActor($contact, $actor, $tags)) { - $receivers['uid:' . $contact['uid']] = $contact['uid']; + if (empty($receivers[$contact['uid']]) && self::isValidReceiverForActor($contact, $tags)) { + $receivers[$contact['uid']] = ['uid' => $contact['uid'], 'type' => $target_type]; + } + } + DBA::close($contacts); + + // The queries are split because of performance issues + $condition = DBA::mergeConditions($basecondition, ["`alias` IN (?, ?) AND `uid` != ?", Strings::normaliseLink($actor), $actor, 0]); + $contacts = DBA::select('contact', ['uid', 'rel'], $condition); + while ($contact = DBA::fetch($contacts)) { + if (empty($receivers[$contact['uid']]) && self::isValidReceiverForActor($contact, $tags)) { + $receivers[$contact['uid']] = ['uid' => $contact['uid'], 'type' => $target_type]; } } DBA::close($contacts); @@ -628,13 +789,8 @@ class Receiver * @return bool with receivers (user id) * @throws \Exception */ - private static function isValidReceiverForActor($contact, $actor, $tags) + private static function isValidReceiverForActor($contact, $tags) { - // Public contacts are no valid receiver - if ($contact['uid'] == 0) { - return false; - } - // Are we following the contact? Then this is a valid receiver if (in_array($contact['rel'], [Contact::SHARING, Contact::FRIEND])) { return true; @@ -652,7 +808,7 @@ class Receiver continue; } - if ($tag['href'] == $owner['url']) { + if (Strings::compareLink($tag['href'], $owner['url'])) { return true; } } @@ -676,7 +832,7 @@ class Receiver return; } - if (Contact::updateFromProbe($cid, '', true)) { + if (Contact::updateFromProbe($cid)) { Logger::info('Update was successful', ['id' => $cid, 'uid' => $uid, 'url' => $url]); } @@ -702,14 +858,14 @@ class Receiver } foreach ($receivers as $receiver) { - $contact = DBA::selectFirst('contact', ['id'], ['uid' => $receiver, 'network' => Protocol::OSTATUS, 'nurl' => Strings::normaliseLink($actor)]); + $contact = DBA::selectFirst('contact', ['id'], ['uid' => $receiver['uid'], 'network' => Protocol::OSTATUS, 'nurl' => Strings::normaliseLink($actor)]); if (DBA::isResult($contact)) { - self::switchContact($contact['id'], $receiver, $actor); + self::switchContact($contact['id'], $receiver['uid'], $actor); } - $contact = DBA::selectFirst('contact', ['id'], ['uid' => $receiver, 'network' => Protocol::OSTATUS, 'alias' => [Strings::normaliseLink($actor), $actor]]); + $contact = DBA::selectFirst('contact', ['id'], ['uid' => $receiver['uid'], 'network' => Protocol::OSTATUS, 'alias' => [Strings::normaliseLink($actor), $actor]]); if (DBA::isResult($contact)) { - self::switchContact($contact['id'], $receiver, $actor); + self::switchContact($contact['id'], $receiver['uid'], $actor); } } } @@ -781,18 +937,29 @@ class Receiver $data = ActivityPub\Transmitter::createNote($item); $object = JsonLD::compact($data); } + + $id = JsonLD::fetchElement($object, '@id'); + if (empty($id)) { + Logger::info('Empty id'); + return false; + } + + if ($id != $object_id) { + Logger::info('Fetched id differs from provided id', ['provided' => $object_id, 'fetched' => $id]); + return false; + } } else { Logger::log('Using original object for url ' . $object_id, Logger::DEBUG); } $type = JsonLD::fetchElement($object, '@type'); - if (empty($type)) { - Logger::log('Empty type', Logger::DEBUG); + Logger::info('Empty type'); return false; } - if (in_array($type, self::CONTENT_TYPES)) { + // We currently don't handle 'pt:CacheFile', but with this step we avoid logging + if (in_array($type, self::CONTENT_TYPES) || ($type == 'pt:CacheFile')) { $object_data = self::processObject($object); if (!empty($data)) { @@ -820,14 +987,10 @@ class Receiver * * @return array with tags in a simplified format */ - private static function processTags($tags) + public static function processTags(array $tags) { $taglist = []; - if (empty($tags)) { - return []; - } - foreach ($tags as $tag) { if (empty($tag)) { continue; @@ -853,17 +1016,13 @@ class Receiver /** * Convert emojis from JSON-LD format into a simplified format * - * @param $emojis + * @param array $emojis * @return array with emojis in a simplified format */ - private static function processEmojis($emojis) + private static function processEmojis(array $emojis) { $emojilist = []; - if (empty($emojis)) { - return []; - } - foreach ($emojis as $emoji) { if (empty($emoji) || (JsonLD::fetchElement($emoji, '@type') != 'toot:Emoji') || empty($emoji['as:icon'])) { continue; @@ -875,6 +1034,7 @@ class Receiver $emojilist[] = $element; } + return $emojilist; } @@ -883,26 +1043,120 @@ class Receiver * * @param array $attachments Attachments in JSON-LD format * - * @return array with attachmants in a simplified format + * @return array Attachments in a simplified format */ - private static function processAttachments($attachments) + private static function processAttachments(array $attachments) { $attachlist = []; - if (empty($attachments)) { - return []; - } + // Removes empty values + $attachments = array_filter($attachments); foreach ($attachments as $attachment) { - if (empty($attachment)) { - continue; - } + switch (JsonLD::fetchElement($attachment, '@type')) { + case 'as:Page': + $pageUrl = null; + $pageImage = null; - $attachlist[] = ['type' => str_replace('as:', '', JsonLD::fetchElement($attachment, '@type')), - 'mediaType' => JsonLD::fetchElement($attachment, 'as:mediaType', '@value'), - 'name' => JsonLD::fetchElement($attachment, 'as:name', '@value'), - 'url' => JsonLD::fetchElement($attachment, 'as:url', '@id')]; + $urls = JsonLD::fetchElementArray($attachment, 'as:url'); + foreach ($urls as $url) { + // Single scalar URL case + if (is_string($url)) { + $pageUrl = $url; + continue; + } + + $href = JsonLD::fetchElement($url, 'as:href', '@id'); + $mediaType = JsonLD::fetchElement($url, 'as:mediaType', '@value'); + if (Strings::startsWith($mediaType, 'image')) { + $pageImage = $href; + } else { + $pageUrl = $href; + } + } + + $attachlist[] = [ + 'type' => 'link', + 'title' => JsonLD::fetchElement($attachment, 'as:name', '@value'), + 'desc' => JsonLD::fetchElement($attachment, 'as:summary', '@value'), + 'url' => $pageUrl, + 'image' => $pageImage, + ]; + break; + case 'as:Link': + $attachlist[] = [ + 'type' => str_replace('as:', '', JsonLD::fetchElement($attachment, '@type')), + 'mediaType' => JsonLD::fetchElement($attachment, 'as:mediaType', '@value'), + 'name' => JsonLD::fetchElement($attachment, 'as:name', '@value'), + 'url' => JsonLD::fetchElement($attachment, 'as:href', '@id') + ]; + break; + case 'as:Image': + $mediaType = JsonLD::fetchElement($attachment, 'as:mediaType', '@value'); + $imageFullUrl = JsonLD::fetchElement($attachment, 'as:url', '@id'); + $imagePreviewUrl = null; + // Multiple URLs? + if (!$imageFullUrl && ($urls = JsonLD::fetchElementArray($attachment, 'as:url'))) { + $imageVariants = []; + $previewVariants = []; + foreach ($urls as $url) { + // Scalar URL, no discrimination possible + if (is_string($url)) { + $imageFullUrl = $url; + continue; + } + + // Not sure what to do with a different Link media type than the base Image, we skip + if ($mediaType != JsonLD::fetchElement($url, 'as:mediaType', '@value')) { + continue; + } + + $href = JsonLD::fetchElement($url, 'as:href', '@id'); + + // Default URL choice if no discriminating width is provided + $imageFullUrl = $href ?? $imageFullUrl; + + $width = intval(JsonLD::fetchElement($url, 'as:width', '@value') ?? 1); + + if ($href && $width) { + $imageVariants[$width] = $href; + // 632 is the ideal width for full screen frio posts, we compute the absolute distance to it + $previewVariants[abs(632 - $width)] = $href; + } + } + + if ($imageVariants) { + // Taking the maximum size image + ksort($imageVariants); + $imageFullUrl = array_pop($imageVariants); + + // Taking the minimum number distance to the target distance + ksort($previewVariants); + $imagePreviewUrl = array_shift($previewVariants); + } + + unset($imageVariants); + unset($previewVariants); + } + + $attachlist[] = [ + 'type' => str_replace('as:', '', JsonLD::fetchElement($attachment, '@type')), + 'mediaType' => $mediaType, + 'name' => JsonLD::fetchElement($attachment, 'as:name', '@value'), + 'url' => $imageFullUrl, + 'image' => $imagePreviewUrl !== $imageFullUrl ? $imagePreviewUrl : null, + ]; + break; + default: + $attachlist[] = [ + 'type' => str_replace('as:', '', JsonLD::fetchElement($attachment, '@type')), + 'mediaType' => JsonLD::fetchElement($attachment, 'as:mediaType', '@value'), + 'name' => JsonLD::fetchElement($attachment, 'as:name', '@value'), + 'url' => JsonLD::fetchElement($attachment, 'as:url', '@id') + ]; + } } + return $attachlist; } @@ -985,24 +1239,36 @@ class Receiver $filetype = strtolower(substr($mediatype, 0, strpos($mediatype, '/'))); if ($filetype == 'audio') { - $attachments[$filetype] = ['type' => $mediatype, 'url' => $href]; + $attachments[$filetype] = ['type' => $mediatype, 'url' => $href, 'height' => null, 'size' => null]; } elseif ($filetype == 'video') { $height = (int)JsonLD::fetchElement($url, 'as:height', '@value'); + $size = (int)JsonLD::fetchElement($url, 'pt:size', '@value'); - // We save bandwidth by using a moderate height + // We save bandwidth by using a moderate height (alt least 480 pixel height) // Peertube normally uses these heights: 240, 360, 480, 720, 1080 if (!empty($attachments[$filetype]['height']) && - (($height > 480) || $height < $attachments[$filetype]['height'])) { + ($height > $attachments[$filetype]['height']) && ($attachments[$filetype]['height'] >= 480)) { continue; } - $attachments[$filetype] = ['type' => $mediatype, 'url' => $href, 'height' => $height]; + $attachments[$filetype] = ['type' => $mediatype, 'url' => $href, 'height' => $height, 'size' => $size]; + } elseif (in_array($mediatype, ['application/x-bittorrent', 'application/x-bittorrent;x-scheme-handler/magnet'])) { + $height = (int)JsonLD::fetchElement($url, 'as:height', '@value'); + + // For Torrent links we always store the highest resolution + if (!empty($attachments[$mediatype]['height']) && ($height < $attachments[$mediatype]['height'])) { + continue; + } + + $attachments[$mediatype] = ['type' => $mediatype, 'url' => $href, 'height' => $height, 'size' => null]; } } foreach ($attachments as $type => $attachment) { $object_data['attachments'][] = ['type' => $type, 'mediaType' => $attachment['type'], + 'height' => $attachment['height'], + 'size' => $attachment['size'], 'name' => '', 'url' => $attachment['url']]; } @@ -1056,6 +1322,16 @@ class Receiver $actor = JsonLD::fetchElement($object, 'as:actor', '@id'); } + $location = JsonLD::fetchElement($object, 'as:location', 'as:name', '@type', 'as:Place'); + $location = JsonLD::fetchElement($location, 'location', '@value'); + if ($location) { + // Some AP software allow formatted text in post location, so we run all the text converters we have to boil + // down to HTML and then finally format to plaintext. + $location = Markdown::convert($location); + $location = BBCode::convert($location); + $location = HTML::toPlaintext($location); + } + $object_data['sc:identifier'] = JsonLD::fetchElement($object, 'sc:identifier', '@value'); $object_data['diaspora:guid'] = JsonLD::fetchElement($object, 'diaspora:guid', '@value'); $object_data['diaspora:comment'] = JsonLD::fetchElement($object, 'diaspora:comment', '@value'); @@ -1070,15 +1346,14 @@ class Receiver $object_data = self::getSource($object, $object_data); $object_data['start-time'] = JsonLD::fetchElement($object, 'as:startTime', '@value'); $object_data['end-time'] = JsonLD::fetchElement($object, 'as:endTime', '@value'); - $object_data['location'] = JsonLD::fetchElement($object, 'as:location', 'as:name', '@type', 'as:Place'); - $object_data['location'] = JsonLD::fetchElement($object_data, 'location', '@value'); + $object_data['location'] = $location; $object_data['latitude'] = JsonLD::fetchElement($object, 'as:location', 'as:latitude', '@type', 'as:Place'); $object_data['latitude'] = JsonLD::fetchElement($object_data, 'latitude', '@value'); $object_data['longitude'] = JsonLD::fetchElement($object, 'as:location', 'as:longitude', '@type', 'as:Place'); $object_data['longitude'] = JsonLD::fetchElement($object_data, 'longitude', '@value'); - $object_data['attachments'] = self::processAttachments(JsonLD::fetchElementArray($object, 'as:attachment')); - $object_data['tags'] = self::processTags(JsonLD::fetchElementArray($object, 'as:tag')); - $object_data['emojis'] = self::processEmojis(JsonLD::fetchElementArray($object, 'as:tag', 'toot:Emoji')); + $object_data['attachments'] = self::processAttachments(JsonLD::fetchElementArray($object, 'as:attachment') ?? []); + $object_data['tags'] = self::processTags(JsonLD::fetchElementArray($object, 'as:tag') ?? []); + $object_data['emojis'] = self::processEmojis(JsonLD::fetchElementArray($object, 'as:tag', null, '@type', 'toot:Emoji') ?? []); $object_data['generator'] = JsonLD::fetchElement($object, 'as:generator', 'as:name', '@type', 'as:Application'); $object_data['generator'] = JsonLD::fetchElement($object_data, 'generator', '@value'); $object_data['alternate-url'] = JsonLD::fetchElement($object, 'as:url', '@id'); @@ -1096,9 +1371,19 @@ class Receiver $object_data = self::processAttachmentUrls($object, $object_data); } - $object_data['receiver'] = self::getReceivers($object, $object_data['actor'], $object_data['tags'], true); + $receiverdata = self::getReceivers($object, $object_data['actor'], $object_data['tags'], true); + $receivers = $reception_types = []; + foreach ($receiverdata as $key => $data) { + $receivers[$key] = $data['uid']; + $reception_types[$data['uid']] = $data['type'] ?? 0; + } + + $object_data['receiver'] = $receivers; + $object_data['reception_type'] = $reception_types; + $object_data['unlisted'] = in_array(-1, $object_data['receiver']); - unset($object_data['receiver']['uid:-1']); + unset($object_data['receiver'][-1]); + unset($object_data['reception_type'][-1]); // Common object data: diff --git a/src/Protocol/ActivityPub/Transmitter.php b/src/Protocol/ActivityPub/Transmitter.php index 16b7b039a9..04e3e14f69 100644 --- a/src/Protocol/ActivityPub/Transmitter.php +++ b/src/Protocol/ActivityPub/Transmitter.php @@ -33,26 +33,25 @@ use Friendica\DI; use Friendica\Model\APContact; use Friendica\Model\Contact; use Friendica\Model\Conversation; +use Friendica\Model\GServer; use Friendica\Model\Item; use Friendica\Model\ItemURI; use Friendica\Model\Profile; use Friendica\Model\Photo; +use Friendica\Model\Post; use Friendica\Model\Tag; use Friendica\Model\User; use Friendica\Protocol\Activity; use Friendica\Protocol\ActivityPub; +use Friendica\Protocol\Relay; use Friendica\Util\DateTimeFormat; use Friendica\Util\HTTPSignature; -use Friendica\Util\Images; use Friendica\Util\JsonLD; use Friendica\Util\LDSignature; use Friendica\Util\Map; use Friendica\Util\Network; use Friendica\Util\XML; -require_once 'include/api.php'; -require_once 'mod/share.php'; - /** * ActivityPub Transmitter Protocol class * @@ -62,74 +61,131 @@ require_once 'mod/share.php'; class Transmitter { /** - * collects the lost of followers of the given owner + * Add relay servers to the list of inboxes * - * @param array $owner Owner array - * @param integer $page Page number - * - * @return array of owners - * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @param array $inboxes + * @return array inboxes with added relay servers */ - public static function getFollowers($owner, $page = null) + public static function addRelayServerInboxes(array $inboxes = []) { - $condition = ['rel' => [Contact::FOLLOWER, Contact::FRIEND], 'network' => Protocol::FEDERATED, 'uid' => $owner['uid'], - 'self' => false, 'deleted' => false, 'hidden' => false, 'archive' => false, 'pending' => false]; - $count = DBA::count('contact', $condition); - - $data = ['@context' => ActivityPub::CONTEXT]; - $data['id'] = DI::baseUrl() . '/followers/' . $owner['nickname']; - $data['type'] = 'OrderedCollection'; - $data['totalItems'] = $count; - - // When we hide our friends we will only show the pure number but don't allow more. - $profile = Profile::getByUID($owner['uid']); - if (!empty($profile['hide-friends'])) { - return $data; + $contacts = DBA::select('apcontact', ['inbox'], + ["`type` = ? AND `url` IN (SELECT `url` FROM `contact` WHERE `uid` = ? AND `rel` = ?)", + 'Application', 0, Contact::FRIEND]); + while ($contact = DBA::fetch($contacts)) { + $inboxes[$contact['inbox']] = $contact['inbox']; } + DBA::close($contacts); - if (empty($page)) { - $data['first'] = DI::baseUrl() . '/followers/' . $owner['nickname'] . '?page=1'; - } else { - $data['type'] = 'OrderedCollectionPage'; - $list = []; - - $contacts = DBA::select('contact', ['url'], $condition, ['limit' => [($page - 1) * 100, 100]]); - while ($contact = DBA::fetch($contacts)) { - $list[] = $contact['url']; - } - DBA::close($contacts); - - if (!empty($list)) { - $data['next'] = DI::baseUrl() . '/followers/' . $owner['nickname'] . '?page=' . ($page + 1); - } - - $data['partOf'] = DI::baseUrl() . '/followers/' . $owner['nickname']; - - $data['orderedItems'] = $list; - } - - return $data; + return $inboxes; } /** - * Create list of following contacts + * Add relay servers to the list of inboxes * - * @param array $owner Owner array - * @param integer $page Page numbe - * - * @return array of following contacts - * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @param array $inboxes + * @return array inboxes with added relay servers */ - public static function getFollowing($owner, $page = null) + public static function addRelayServerInboxesForItem(int $item_id, array $inboxes = []) { - $condition = ['rel' => [Contact::SHARING, Contact::FRIEND], 'network' => Protocol::FEDERATED, 'uid' => $owner['uid'], - 'self' => false, 'deleted' => false, 'hidden' => false, 'archive' => false, 'pending' => false]; - $count = DBA::count('contact', $condition); + $item = Item::selectFirst(['uid'], ['id' => $item_id]); + if (empty($item)) { + return $inboxes; + } + + $relays = Relay::getList($item_id, [], [Protocol::ACTIVITYPUB]); + if (empty($relays)) { + return $inboxes; + } + + foreach ($relays as $relay) { + $contact = Contact::getByURLForUser($relay['url'], $item['uid'], false, ['id']); + $inboxes[$relay['batch']][] = $contact['id'] ?? 0; + } + return $inboxes; + } + + /** + * Subscribe to a relay + * + * @param string $url Subscribe actor url + * @return bool success + */ + public static function sendRelayFollow(string $url) + { + $contact = Contact::getByURL($url); + if (empty($contact)) { + return false; + } + + $activity_id = ActivityPub\Transmitter::activityIDFromContact($contact['id']); + $success = ActivityPub\Transmitter::sendActivity('Follow', $url, 0, $activity_id); + if ($success) { + DBA::update('contact', ['rel' => Contact::FRIEND], ['id' => $contact['id']]); + } + + return $success; + } + + /** + * Unsubscribe from a relay + * + * @param string $url Subscribe actor url + * @param bool $force Set the relay status as non follower even if unsubscribe hadn't worked + * @return bool success + */ + public static function sendRelayUndoFollow(string $url, bool $force = false) + { + $contact = Contact::getByURL($url); + if (empty($contact)) { + return false; + } + + $success = self::sendContactUndo($url, $contact['id'], 0); + if ($success || $force) { + DBA::update('contact', ['rel' => Contact::NOTHING], ['id' => $contact['id']]); + } + + return $success; + } + + /** + * Collects a list of contacts of the given owner + * + * @param array $owner Owner array + * @param int|array $rel The relevant value(s) contact.rel should match + * @param string $module The name of the relevant AP endpoint module (followers|following) + * @param integer $page Page number + * + * @return array of owners + * @throws \Exception + */ + public static function getContacts($owner, $rel, $module, $page = null) + { + $parameters = [ + 'rel' => $rel, + 'uid' => $owner['uid'], + 'self' => false, + 'deleted' => false, + 'hidden' => false, + 'archive' => false, + 'pending' => false, + 'blocked' => false, + ]; + $condition = DBA::buildCondition($parameters); + + $sql = "SELECT COUNT(*) as `count` + FROM `contact` + JOIN `apcontact` ON `apcontact`.`url` = `contact`.`url` + " . $condition; + + $contacts = DBA::fetchFirst($sql, ...$parameters); + + $modulePath = '/' . $module . '/'; $data = ['@context' => ActivityPub::CONTEXT]; - $data['id'] = DI::baseUrl() . '/following/' . $owner['nickname']; + $data['id'] = DI::baseUrl() . $modulePath . $owner['nickname']; $data['type'] = 'OrderedCollection'; - $data['totalItems'] = $count; + $data['totalItems'] = $contacts['count']; // When we hide our friends we will only show the pure number but don't allow more. $profile = Profile::getByUID($owner['uid']); @@ -138,22 +194,31 @@ class Transmitter } if (empty($page)) { - $data['first'] = DI::baseUrl() . '/following/' . $owner['nickname'] . '?page=1'; + $data['first'] = DI::baseUrl() . $modulePath . $owner['nickname'] . '?page=1'; } else { $data['type'] = 'OrderedCollectionPage'; $list = []; - $contacts = DBA::select('contact', ['url'], $condition, ['limit' => [($page - 1) * 100, 100]]); + $sql = "SELECT `contact`.`url` + FROM `contact` + JOIN `apcontact` ON `apcontact`.`url` = `contact`.`url` + " . $condition . " + LIMIT ?, ?"; + + $parameters[] = ($page - 1) * 100; + $parameters[] = 100; + + $contacts = DBA::p($sql, ...$parameters); while ($contact = DBA::fetch($contacts)) { $list[] = $contact['url']; } DBA::close($contacts); if (!empty($list)) { - $data['next'] = DI::baseUrl() . '/following/' . $owner['nickname'] . '?page=' . ($page + 1); + $data['next'] = DI::baseUrl() . $modulePath . $owner['nickname'] . '?page=' . ($page + 1); } - $data['partOf'] = DI::baseUrl() . '/following/' . $owner['nickname']; + $data['partOf'] = DI::baseUrl() . $modulePath . $owner['nickname']; $data['orderedItems'] = $list; } @@ -164,20 +229,37 @@ class Transmitter /** * Public posts for the given owner * - * @param array $owner Owner array - * @param integer $page Page numbe + * @param array $owner Owner array + * @param integer $page Page number + * @param string $requester URL of requesting account * * @return array of posts * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function getOutbox($owner, $page = null) + public static function getOutbox($owner, $page = null, $requester = '') { - $public_contact = Contact::getIdForURL($owner['url'], 0, true); + $public_contact = Contact::getIdForURL($owner['url']); + $condition = ['uid' => 0, 'contact-id' => $public_contact, + 'private' => [Item::PUBLIC, Item::UNLISTED]]; + + if (!empty($requester)) { + $requester_id = Contact::getIdForURL($requester, $owner['uid']); + if (!empty($requester_id)) { + $permissionSets = DI::permissionSet()->selectByContactId($requester_id, $owner['uid']); + if (!empty($permissionSets)) { + $condition = ['uid' => $owner['uid'], 'origin' => true, + 'psid' => array_merge($permissionSets->column('id'), + [DI::permissionSet()->getIdFromACL($owner['uid'], '', '', '', '')])]; + } + } + } + + $condition = array_merge($condition, + ['author-id' => $public_contact, + 'gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT], + 'deleted' => false, 'visible' => true, 'moderated' => false]); - $condition = ['uid' => 0, 'contact-id' => $public_contact, 'author-id' => $public_contact, - 'private' => [Item::PUBLIC, Item::UNLISTED], 'gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT], - 'deleted' => false, 'visible' => true, 'moderated' => false]; $count = DBA::count('item', $condition); $data = ['@context' => ActivityPub::CONTEXT]; @@ -237,39 +319,63 @@ class Transmitter */ public static function getProfile($uid) { - $condition = ['uid' => $uid, 'blocked' => false, 'account_expired' => false, - 'account_removed' => false, 'verified' => true]; - $fields = ['guid', 'nickname', 'pubkey', 'account-type', 'page-flags']; - $user = DBA::selectFirst('user', $fields, $condition); - if (!DBA::isResult($user)) { - return []; - } + if ($uid != 0) { + $condition = ['uid' => $uid, 'blocked' => false, 'account_expired' => false, + 'account_removed' => false, 'verified' => true]; + $fields = ['guid', 'nickname', 'pubkey', 'account-type', 'page-flags']; + $user = DBA::selectFirst('user', $fields, $condition); + if (!DBA::isResult($user)) { + return []; + } - $fields = ['locality', 'region', 'country-name']; - $profile = DBA::selectFirst('profile', $fields, ['uid' => $uid]); - if (!DBA::isResult($profile)) { - return []; - } + $fields = ['locality', 'region', 'country-name']; + $profile = DBA::selectFirst('profile', $fields, ['uid' => $uid]); + if (!DBA::isResult($profile)) { + return []; + } - $fields = ['name', 'url', 'location', 'about', 'avatar', 'photo']; - $contact = DBA::selectFirst('contact', $fields, ['uid' => $uid, 'self' => true]); - if (!DBA::isResult($contact)) { - return []; + $fields = ['name', 'url', 'location', 'about', 'avatar', 'photo']; + $contact = DBA::selectFirst('contact', $fields, ['uid' => $uid, 'self' => true]); + if (!DBA::isResult($contact)) { + return []; + } + } else { + $contact = User::getSystemAccount(); + $user = ['guid' => '', 'nickname' => $contact['nick'], 'pubkey' => $contact['pubkey'], + 'account-type' => $contact['contact-type'], 'page-flags' => User::PAGE_FLAGS_NORMAL]; + $profile = ['locality' => '', 'region' => '', 'country-name' => '']; } $data = ['@context' => ActivityPub::CONTEXT]; $data['id'] = $contact['url']; - $data['diaspora:guid'] = $user['guid']; + + if (!empty($user['guid'])) { + $data['diaspora:guid'] = $user['guid']; + } + $data['type'] = ActivityPub::ACCOUNT_TYPES[$user['account-type']]; - $data['following'] = DI::baseUrl() . '/following/' . $user['nickname']; - $data['followers'] = DI::baseUrl() . '/followers/' . $user['nickname']; - $data['inbox'] = DI::baseUrl() . '/inbox/' . $user['nickname']; - $data['outbox'] = DI::baseUrl() . '/outbox/' . $user['nickname']; + + if ($uid != 0) { + $data['following'] = DI::baseUrl() . '/following/' . $user['nickname']; + $data['followers'] = DI::baseUrl() . '/followers/' . $user['nickname']; + $data['inbox'] = DI::baseUrl() . '/inbox/' . $user['nickname']; + $data['outbox'] = DI::baseUrl() . '/outbox/' . $user['nickname']; + } else { + $data['inbox'] = DI::baseUrl() . '/friendica/inbox'; + } + $data['preferredUsername'] = $user['nickname']; $data['name'] = $contact['name']; - $data['vcard:hasAddress'] = ['@type' => 'vcard:Home', 'vcard:country-name' => $profile['country-name'], - 'vcard:region' => $profile['region'], 'vcard:locality' => $profile['locality']]; - $data['summary'] = BBCode::convert($contact['about'], false); + + if (!empty($profile['country-name'] . $profile['region'] . $profile['locality'])) { + $data['vcard:hasAddress'] = ['@type' => 'vcard:Home', 'vcard:country-name' => $profile['country-name'], + 'vcard:region' => $profile['region'], 'vcard:locality' => $profile['locality']]; + } + + if (!empty($contact['about'])) { + $data['summary'] = BBCode::convert($contact['about'], false); + } + $data['url'] = $contact['url']; $data['manuallyApprovesFollowers'] = in_array($user['page-flags'], [User::PAGE_FLAGS_NORMAL, User::PAGE_FLAGS_PRVGROUP]); $data['publicKey'] = ['id' => $contact['url'] . '#main-key', @@ -326,7 +432,12 @@ class Transmitter $activity = json_decode($conversation['source'], true); $actor = JsonLD::fetchElement($activity, 'actor', 'id'); - $profile = APContact::getByURL($actor); + if (!empty($actor)) { + $permissions['to'][] = $actor; + $profile = APContact::getByURL($actor); + } else { + $profile = []; + } $item_profile = APContact::getByURL($item['author-link']); $exclude[] = $item['author-link']; @@ -335,8 +446,6 @@ class Transmitter $exclude[] = $item['owner-link']; } - $permissions['to'][] = $actor; - foreach (['to', 'cc', 'bto', 'bcc'] as $element) { if (empty($activity[$element])) { continue; @@ -360,6 +469,21 @@ class Transmitter return $permissions; } + /** + * Check if the given item id is from ActivityPub + * + * @param integer $item_id + * @return boolean "true" if the post is from ActivityPub + */ + private static function isAPPost(int $item_id) + { + if (empty($item_id)) { + return false; + } + + return Item::exists(['id' => $item_id, 'network' => Protocol::ACTIVITYPUB]); + } + /** * Creates an array of permissions from an item thread * @@ -382,7 +506,7 @@ class Transmitter // Check if we should always deliver our stuff via BCC if (!empty($item['uid'])) { - $profile = Profile::getByUID($item['uid']); + $profile = User::getOwnerDataById($item['uid']); if (!empty($profile)) { $always_bcc = $profile['hide-friends']; } @@ -392,7 +516,7 @@ class Transmitter $always_bcc = true; } - if (self::isAnnounce($item) || DI::config()->get('debug', 'total_ap_delivery')) { + if (self::isAnnounce($item) || DI::config()->get('debug', 'total_ap_delivery') || self::isAPPost($last_id)) { // Will be activated in a later step $networks = Protocol::FEDERATED; } else { @@ -441,8 +565,8 @@ class Transmitter foreach ($terms as $term) { $cid = Contact::getIdForURL($term['url'], $item['uid']); if (!empty($cid) && in_array($cid, $receiver_list)) { - $contact = DBA::selectFirst('contact', ['url', 'network', 'protocol'], ['id' => $cid]); - if (!DBA::isResult($contact) || (!in_array($contact['network'], $networks) && ($contact['protocol'] != Protocol::ACTIVITYPUB))) { + $contact = DBA::selectFirst('contact', ['url', 'network', 'protocol', 'gsid'], ['id' => $cid, 'network' => Protocol::FEDERATED]); + if (!DBA::isResult($contact) || !self::isAPContact($contact, $networks)) { continue; } @@ -453,8 +577,8 @@ class Transmitter } foreach ($receiver_list as $receiver) { - $contact = DBA::selectFirst('contact', ['url', 'hidden', 'network', 'protocol'], ['id' => $receiver]); - if (!DBA::isResult($contact) || (!in_array($contact['network'], $networks) && ($contact['protocol'] != Protocol::ACTIVITYPUB))) { + $contact = DBA::selectFirst('contact', ['url', 'hidden', 'network', 'protocol', 'gsid'], ['id' => $receiver, 'network' => Protocol::FEDERATED]); + if (!DBA::isResult($contact) || !self::isAPContact($contact, $networks)) { continue; } @@ -477,7 +601,9 @@ class Transmitter if ($item['gravity'] != GRAVITY_PARENT) { // Comments to forums are directed to the forum // But comments to forums aren't directed to the followers collection - if ($profile['type'] == 'Group') { + // This rule is only valid when the actor isn't the forum. + // The forum needs to transmit their content to their followers. + if (($profile['type'] == 'Group') && ($profile['url'] != $actor_profile['url'])) { $data['to'][] = $profile['url']; } else { $data['cc'][] = $profile['url']; @@ -559,26 +685,53 @@ class Transmitter * * @return boolean "true" if inbox is archived */ - private static function archivedInbox($url) + public static function archivedInbox($url) { return DBA::exists('inbox-status', ['url' => $url, 'archive' => true]); } + /** + * Check if a given contact should be delivered via AP + * + * @param array $contact + * @param array $networks + * @return bool + * @throws Exception + */ + private static function isAPContact(array $contact, array $networks) + { + if (in_array($contact['network'], $networks) || ($contact['protocol'] == Protocol::ACTIVITYPUB)) { + return true; + } + + return GServer::getProtocol($contact['gsid'] ?? 0) == Post\DeliveryData::ACTIVITYPUB; + } + /** * Fetches a list of inboxes of followers of a given user * * @param integer $uid User ID * @param boolean $personal fetch personal inboxes + * @param boolean $all_ap Retrieve all AP enabled inboxes * * @return array of follower inboxes * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function fetchTargetInboxesforUser($uid, $personal = false) + public static function fetchTargetInboxesforUser($uid, $personal = false, bool $all_ap = false) { $inboxes = []; - if (DI::config()->get('debug', 'total_ap_delivery')) { + $isforum = false; + + if (!empty($item['uid'])) { + $profile = User::getOwnerDataById($item['uid']); + if (!empty($profile)) { + $isforum = $profile['account-type'] == User::ACCOUNT_TYPE_COMMUNITY; + } + } + + if (DI::config()->get('debug', 'total_ap_delivery') || $all_ap) { // Will be activated in a later step $networks = Protocol::FEDERATED; } else { @@ -586,19 +739,23 @@ class Transmitter $networks = [Protocol::ACTIVITYPUB, Protocol::OSTATUS]; } - $condition = ['uid' => $uid, 'archive' => false, 'pending' => false]; + $condition = ['uid' => $uid, 'archive' => false, 'pending' => false, 'blocked' => false, 'network' => Protocol::FEDERATED]; if (!empty($uid)) { $condition['rel'] = [Contact::FOLLOWER, Contact::FRIEND]; } - $contacts = DBA::select('contact', ['url', 'network', 'protocol'], $condition); + $contacts = DBA::select('contact', ['id', 'url', 'network', 'protocol', 'gsid'], $condition); while ($contact = DBA::fetch($contacts)) { if (Contact::isLocal($contact['url'])) { continue; } - if (!in_array($contact['network'], $networks) && ($contact['protocol'] != Protocol::ACTIVITYPUB)) { + if (!self::isAPContact($contact, $networks)) { + continue; + } + + if ($isforum && ($contact['network'] == Protocol::DFRN)) { continue; } @@ -614,7 +771,7 @@ class Transmitter $target = $profile['sharedinbox']; } if (!self::archivedInbox($target)) { - $inboxes[$target] = $target; + $inboxes[$target][] = $contact['id']; } } } @@ -650,6 +807,12 @@ class Transmitter $item_profile = APContact::getByURL($item['owner-link'], false); } + if (empty($item_profile)) { + return []; + } + + $profile_uid = User::getIdForURL($item_profile['url']); + foreach (['to', 'cc', 'bto', 'bcc'] as $element) { if (empty($permissions[$element])) { continue; @@ -662,8 +825,8 @@ class Transmitter continue; } - if ($receiver == $item_profile['followers']) { - $inboxes = array_merge($inboxes, self::fetchTargetInboxesforUser($uid, $personal)); + if ($item_profile && ($receiver == $item_profile['followers']) && ($uid == $profile_uid)) { + $inboxes = array_merge($inboxes, self::fetchTargetInboxesforUser($uid, $personal, self::isAPPost($last_id))); } else { if (Contact::isLocal($receiver)) { continue; @@ -671,13 +834,15 @@ class Transmitter $profile = APContact::getByURL($receiver, false); if (!empty($profile)) { + $contact = Contact::getByURLForUser($receiver, $uid, false, ['id']); + if (empty($profile['sharedinbox']) || $personal || $blindcopy) { $target = $profile['inbox']; } else { $target = $profile['sharedinbox']; } if (!self::archivedInbox($target)) { - $inboxes[$target] = $target; + $inboxes[$target][] = $contact['id'] ?? 0; } } } @@ -725,7 +890,6 @@ class Transmitter $mail['gravity'] = ($mail['reply'] ? GRAVITY_COMMENT: GRAVITY_PARENT); $mail['event-type'] = ''; - $mail['attach'] = ''; $mail['parent'] = 0; @@ -744,6 +908,9 @@ class Transmitter public static function createActivityFromMail($mail_id, $object_mode = false) { $mail = self::ItemArrayFromMail($mail_id); + if (empty($mail)) { + return []; + } $object = self::createNote($mail); if (!$object_mode) { @@ -752,7 +919,7 @@ class Transmitter $data = []; } - $data['id'] = $mail['uri'] . '#Create'; + $data['id'] = $mail['uri'] . '/Create'; $data['type'] = 'Create'; $data['actor'] = $mail['author-link']; $data['published'] = DateTimeFormat::utc($mail['created'] . '+00:00', DateTimeFormat::ATOM); @@ -830,6 +997,8 @@ class Transmitter $type = 'Follow'; } elseif ($item['verb'] == Activity::TAG) { $type = 'Add'; + } elseif ($item['verb'] == Activity::ANNOUNCE) { + $type = 'Announce'; } else { $type = ''; } @@ -857,7 +1026,7 @@ class Transmitter } } - $data = ActivityPub\Transmitter::createActivityFromItem($item_id); + $data = self::createActivityFromItem($item_id); DI::cache()->set($cachekey, $data, Duration::QUARTER_HOUR); return $data; @@ -869,43 +1038,63 @@ class Transmitter * @param integer $item_id * @param boolean $object_mode Is the activity item is used inside another object? * - * @return array of activity + * @return false|array * @throws \Exception */ - public static function createActivityFromItem($item_id, $object_mode = false) + public static function createActivityFromItem(int $item_id, bool $object_mode = false) { + Logger::info('Fetching activity', ['item' => $item_id]); $item = Item::selectFirst([], ['id' => $item_id, 'parent-network' => Protocol::NATIVE_SUPPORT]); - if (!DBA::isResult($item)) { return false; } - if ($item['wall'] && ($item['uri'] == $item['parent-uri'])) { - $owner = User::getOwnerDataById($item['uid']); - if (($owner['account-type'] == User::ACCOUNT_TYPE_COMMUNITY) && ($item['author-link'] != $owner['url'])) { - $type = 'Announce'; - - // Disguise forum posts as reshares. Will later be converted to a real announce - $item['body'] = share_header($item['author-name'], $item['author-link'], $item['author-avatar'], - $item['guid'], $item['created'], $item['plink']) . $item['body'] . '[/share]'; - } - } - - if (empty($type)) { - $condition = ['item-uri' => $item['uri'], 'protocol' => Conversation::PARCEL_ACTIVITYPUB]; - $conversation = DBA::selectFirst('conversation', ['source'], $condition); - if (DBA::isResult($conversation)) { - $data = json_decode($conversation['source'], true); - if (!empty($data)) { - return $data; + // In case of a forum post ensure to return the original post if author and forum are on the same machine + if (!empty($item['forum_mode'])) { + $author = Contact::getById($item['author-id'], ['nurl']); + if (!empty($author['nurl'])) { + $self = Contact::selectFirst(['uid'], ['nurl' => $author['nurl'], 'self' => true]); + if (!empty($self['uid'])) { + $forum_item = Item::selectFirst([], ['uri-id' => $item['uri-id'], 'uid' => $self['uid']]); + if (DBA::isResult($item)) { + $item = $forum_item; + } } } - - $type = self::getTypeOfItem($item); } + if (empty($item['uri-id'])) { + Logger::warning('Item without uri-id', ['item' => $item]); + return false; + } + + $condition = ['item-uri' => $item['uri'], 'protocol' => Conversation::PARCEL_ACTIVITYPUB]; + $conversation = DBA::selectFirst('conversation', ['source'], $condition); + if (!$item['origin'] && DBA::isResult($conversation)) { + $data = json_decode($conversation['source'], true); + if (!empty($data['type'])) { + if (in_array($data['type'], ['Create', 'Update'])) { + if ($object_mode) { + unset($data['@context']); + unset($data['signature']); + } + Logger::info('Return stored conversation', ['item' => $item_id]); + return $data; + } elseif (in_array('as:' . $data['type'], Receiver::CONTENT_TYPES)) { + if (!empty($data['@context'])) { + $context = $data['@context']; + unset($data['@context']); + } + unset($data['actor']); + $object = $data; + } + } + } + + $type = self::getTypeOfItem($item); + if (!$object_mode) { - $data = ['@context' => ActivityPub::CONTEXT]; + $data = ['@context' => $context ?? ActivityPub::CONTEXT]; if ($item['deleted'] && ($item['gravity'] == GRAVITY_ACTIVITY)) { $type = 'Undo'; @@ -916,10 +1105,15 @@ class Transmitter $data = []; } - $data['id'] = $item['uri'] . '#' . $type; + if (($item['gravity'] == GRAVITY_ACTIVITY) && ($type != 'Undo')) { + $data['id'] = $item['uri']; + } else { + $data['id'] = $item['uri'] . '/' . $type; + } + $data['type'] = $type; - if (Item::isForumPost($item) && ($type != 'Announce')) { + if (($type != 'Announce') || ($item['gravity'] != GRAVITY_PARENT)) { $data['actor'] = $item['author-link']; } else { $data['actor'] = $item['owner-link']; @@ -932,11 +1126,15 @@ class Transmitter $data = array_merge($data, self::createPermissionBlockForItem($item, false)); if (in_array($data['type'], ['Create', 'Update', 'Delete'])) { - $data['object'] = self::createNote($item); + $data['object'] = $object ?? self::createNote($item); } elseif ($data['type'] == 'Add') { $data = self::createAddTag($item, $data); } elseif ($data['type'] == 'Announce') { - $data = self::createAnnounce($item, $data); + if ($item['verb'] == ACTIVITY::ANNOUNCE) { + $data['object'] = $item['thr-parent']; + } else { + $data = self::createAnnounce($item, $data); + } } elseif ($data['type'] == 'Follow') { $data['object'] = $item['parent-uri']; } elseif ($data['type'] == 'Undo') { @@ -957,7 +1155,10 @@ class Transmitter $owner = User::getOwnerDataById($uid); - if (!$object_mode && !empty($owner)) { + Logger::info('Fetched activity', ['item' => $item_id, 'uid' => $uid]); + + // We don't sign if we aren't the actor. This is important for relaying content especially for forums + if (!$object_mode && !empty($owner) && ($data['actor'] == $owner['url'])) { return LDSignature::sign($data, $owner); } else { return $data; @@ -1018,7 +1219,10 @@ class Transmitter $url = DI::baseUrl() . '/search?tag=' . urlencode($term['name']); $tags[] = ['type' => 'Hashtag', 'href' => $url, 'name' => '#' . $term['name']]; } else { - $contact = Contact::getDetailsByURL($term['url']); + $contact = Contact::getByURL($term['url'], false, ['addr']); + if (empty($contact)) { + continue; + } if (!empty($contact['addr'])) { $mention = '@' . $contact['addr']; } else { @@ -1082,57 +1286,22 @@ class Transmitter $attachments[] = $attachment; } */ - $arr = explode('[/attach],', $item['attach']); - if (count($arr)) { - foreach ($arr as $r) { - $matches = false; - $cnt = preg_match('|\[attach\]href=\"(.*?)\" length=\"(.*?)\" type=\"(.*?)\" title=\"(.*?)\"|', $r, $matches); - if ($cnt) { - $attributes = ['type' => 'Document', - 'mediaType' => $matches[3], - 'url' => $matches[1], - 'name' => null]; - - if (trim($matches[4]) != '') { - $attributes['name'] = trim($matches[4]); - } - - $attachments[] = $attributes; - } - } + foreach (Post\Media::getByURIId($item['uri-id'], [Post\Media::DOCUMENT, Post\Media::TORRENT, Post\Media::UNKNOWN]) as $attachment) { + $attachments[] = ['type' => 'Document', + 'mediaType' => $attachment['mimetype'], + 'url' => $attachment['url'], + 'name' => $attachment['description']]; } if ($type != 'Note') { return $attachments; } - // Simplify image codes - $body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $item['body']); - - // Grab all pictures without alternative descriptions and create attachments out of them - if (preg_match_all("/\[img\]([^\[\]]*)\[\/img\]/Usi", $body, $pictures)) { - foreach ($pictures[1] as $picture) { - $imgdata = Images::getInfoFromURLCached($picture); - if ($imgdata) { - $attachments[] = ['type' => 'Document', - 'mediaType' => $imgdata['mime'], - 'url' => $picture, - 'name' => null]; - } - } - } - - // Grab all pictures with alternative description and create attachments out of them - if (preg_match_all("/\[img=([^\[\]]*)\]([^\[\]]*)\[\/img\]/Usi", $body, $pictures, PREG_SET_ORDER)) { - foreach ($pictures as $picture) { - $imgdata = Images::getInfoFromURLCached($picture[1]); - if ($imgdata) { - $attachments[] = ['type' => 'Document', - 'mediaType' => $imgdata['mime'], - 'url' => $picture[1], - 'name' => $picture[2]]; - } - } + foreach (Post\Media::getByURIId($item['uri-id'], [Post\Media::AUDIO, Post\Media::IMAGE, Post\Media::VIDEO]) as $attachment) { + $attachments[] = ['type' => 'Document', + 'mediaType' => $attachment['mimetype'], + 'url' => $attachment['url'], + 'name' => $attachment['description']]; } return $attachments; @@ -1151,12 +1320,12 @@ class Transmitter return ''; } - $data = Contact::getDetailsByURL($match[1]); + $data = Contact::getByURL($match[1], false, ['url', 'alias', 'nick']); if (empty($data['nick'])) { return $match[0]; } - return '@[url=' . $data['url'] . ']' . $data['nick'] . '[/url]'; + return '[url=' . ($data['alias'] ?: $data['url']) . ']@' . $data['nick'] . '[/url]'; } /** @@ -1238,7 +1407,7 @@ class Transmitter { $event = []; $event['name'] = $item['event-summary']; - $event['content'] = BBCode::convert($item['event-desc'], false, 9); + $event['content'] = BBCode::convert($item['event-desc'], false, BBCode::ACTIVITYPUB); $event['startTime'] = DateTimeFormat::utc($item['event-start'] . '+00:00', DateTimeFormat::ATOM); if (!$item['event-nofinish']) { @@ -1316,23 +1485,23 @@ class Transmitter $body = $item['body']; - if (empty($item['uid']) || !Feature::isEnabled($item['uid'], 'explicit_mentions')) { - $body = self::prependMentions($body, $permission_block); - } - if ($type == 'Note') { - $body = self::removePictures($body); + $body = $item['raw-body'] ?? self::removePictures($body); } elseif (($type == 'Article') && empty($data['summary'])) { $data['summary'] = BBCode::toPlaintext(Plaintext::shorten(self::removePictures($body), 1000)); } + if (empty($item['uid']) || !Feature::isEnabled($item['uid'], 'explicit_mentions')) { + $body = self::prependMentions($body, $item['uri-id'], $item['author-link']); + } + if ($type == 'Event') { $data = array_merge($data, self::createEvent($item)); } else { $regexp = "/[@!]\[url\=([^\[\]]*)\].*?\[\/url\]/ism"; $body = preg_replace_callback($regexp, ['self', 'mentionCallback'], $body); - $data['content'] = BBCode::convert($body, false, 9); + $data['content'] = BBCode::convert($body, false, BBCode::ACTIVITYPUB); } // The regular "content" field does contain a minimized HTML. This is done since systems like @@ -1501,6 +1670,10 @@ class Transmitter */ public static function isAnnounce($item) { + if (!empty($item['verb']) && ($item['verb'] == Activity::ANNOUNCE)) { + return true; + } + $announce = self::getAnnounceArray($item); if (empty($announce)) { return false; @@ -1833,18 +2006,19 @@ class Transmitter * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException * @throws \Exception + * @return bool success */ public static function sendContactUndo($target, $cid, $uid) { $profile = APContact::getByURL($target); if (empty($profile['inbox'])) { Logger::warning('No inbox found for target', ['target' => $target, 'profile' => $profile]); - return; + return false; } $object_id = self::activityIDFromContact($cid); if (empty($object_id)) { - return; + return false; } $id = DI::baseUrl() . '/activity/' . System::createGUID(); @@ -1863,25 +2037,22 @@ class Transmitter Logger::log('Sending undo to ' . $target . ' for user ' . $uid . ' with id ' . $id, Logger::DEBUG); $signed = LDSignature::sign($data, $owner); - HTTPSignature::transmit($signed, $profile['inbox'], $uid); + return HTTPSignature::transmit($signed, $profile['inbox'], $uid); } - private static function prependMentions($body, array $permission_block) + private static function prependMentions($body, int $uriid, string $authorLink) { - if (DI::config()->get('system', 'disable_implicit_mentions')) { - return $body; - } - $mentions = []; - foreach ($permission_block['to'] as $profile_url) { - $profile = Contact::getDetailsByURL($profile_url); + foreach (Tag::getByURIId($uriid, [Tag::IMPLICIT_MENTION]) as $tag) { + $profile = Contact::getByURL($tag['url'], false, ['addr', 'contact-type', 'nick']); if (!empty($profile['addr']) && $profile['contact-type'] != Contact::TYPE_COMMUNITY && !strstr($body, $profile['addr']) - && !strstr($body, $profile_url) + && !strstr($body, $tag['url']) + && $tag['url'] !== $authorLink ) { - $mentions[] = '@[url=' . $profile_url . ']' . $profile['nick'] . '[/url]'; + $mentions[] = '@[url=' . $tag['url'] . ']' . $profile['nick'] . '[/url]'; } } diff --git a/src/Protocol/DFRN.php b/src/Protocol/DFRN.php index fbcaf8d4b5..a322327b0e 100644 --- a/src/Protocol/DFRN.php +++ b/src/Protocol/DFRN.php @@ -33,16 +33,19 @@ use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\Conversation; use Friendica\Model\Event; -use Friendica\Model\GContact; +use Friendica\Model\FContact; use Friendica\Model\Item; use Friendica\Model\ItemURI; use Friendica\Model\Mail; +use Friendica\Model\Notify; use Friendica\Model\Notify\Type; use Friendica\Model\PermissionSet; +use Friendica\Model\Post; use Friendica\Model\Post\Category; use Friendica\Model\Profile; use Friendica\Model\Tag; use Friendica\Model\User; +use Friendica\Model\Verb; use Friendica\Network\Probe; use Friendica\Util\Crypto; use Friendica\Util\DateTimeFormat; @@ -256,10 +259,11 @@ class DFRN FROM `item` USE INDEX (`uid_wall_changed`) $sql_post_table STRAIGHT_JOIN `contact` ON `contact`.`id` = `item`.`contact-id` WHERE `item`.`uid` = %d AND `item`.`wall` AND `item`.`changed` > '%s' - AND `item`.`visible` $sql_extra + AND `vid` != %d AND `item`.`visible` $sql_extra ORDER BY `item`.`parent` ".$sort.", `item`.`received` ASC LIMIT 0, 300", intval($owner_id), DBA::escape($check_date), + Verb::getID(Activity::ANNOUNCE), DBA::escape($sort) ); @@ -411,36 +415,36 @@ class DFRN /** * Create XML text for DFRN mails * - * @param array $item message elements + * @param array $mail Mail record * @param array $owner Owner record * * @return string DFRN mail * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @todo Find proper type-hints */ - public static function mail($item, $owner) + public static function mail(array $mail, array $owner) { $doc = new DOMDocument('1.0', 'utf-8'); $doc->formatOutput = true; $root = self::addHeader($doc, $owner, "dfrn:owner", "", false); - $mail = $doc->createElement("dfrn:mail"); - $sender = $doc->createElement("dfrn:sender"); + $mailElement = $doc->createElement("dfrn:mail"); + $senderElement = $doc->createElement("dfrn:sender"); - XML::addElement($doc, $sender, "dfrn:name", $owner['name']); - XML::addElement($doc, $sender, "dfrn:uri", $owner['url']); - XML::addElement($doc, $sender, "dfrn:avatar", $owner['thumb']); + XML::addElement($doc, $senderElement, "dfrn:name", $owner['name']); + XML::addElement($doc, $senderElement, "dfrn:uri", $owner['url']); + XML::addElement($doc, $senderElement, "dfrn:avatar", $owner['thumb']); - $mail->appendChild($sender); + $mailElement->appendChild($senderElement); - XML::addElement($doc, $mail, "dfrn:id", $item['uri']); - XML::addElement($doc, $mail, "dfrn:in-reply-to", $item['parent-uri']); - XML::addElement($doc, $mail, "dfrn:sentdate", DateTimeFormat::utc($item['created'] . '+00:00', DateTimeFormat::ATOM)); - XML::addElement($doc, $mail, "dfrn:subject", $item['title']); - XML::addElement($doc, $mail, "dfrn:content", $item['body']); + XML::addElement($doc, $mailElement, "dfrn:id", $mail['uri']); + XML::addElement($doc, $mailElement, "dfrn:in-reply-to", $mail['parent-uri']); + XML::addElement($doc, $mailElement, "dfrn:sentdate", DateTimeFormat::utc($mail['created'] . '+00:00', DateTimeFormat::ATOM)); + XML::addElement($doc, $mailElement, "dfrn:subject", $mail['title']); + XML::addElement($doc, $mailElement, "dfrn:content", $mail['body']); - $root->appendChild($mail); + $root->appendChild($mailElement); return trim($doc->saveXML()); } @@ -755,7 +759,7 @@ class DFRN { $author = $doc->createElement($element); - $contact = Contact::getDetailsByURL($contact_url, $item["uid"]); + $contact = Contact::getByURLForUser($contact_url, $item["uid"], false, ['url', 'name', 'addr', 'photo']); if (!empty($contact)) { XML::addElement($doc, $author, "name", $contact["name"]); XML::addElement($doc, $author, "uri", $contact["url"]); @@ -863,27 +867,19 @@ class DFRN */ private static function getAttachment($doc, $root, $item) { - $arr = explode('[/attach],', $item['attach']); - if (count($arr)) { - foreach ($arr as $r) { - $matches = false; - $cnt = preg_match('|\[attach\]href=\"(.*?)\" length=\"(.*?)\" type=\"(.*?)\" title=\"(.*?)\"|', $r, $matches); - if ($cnt) { - $attributes = ["rel" => "enclosure", - "href" => $matches[1], - "type" => $matches[3]]; + foreach (Post\Media::getByURIId($item['uri-id'], [Post\Media::DOCUMENT, Post\Media::TORRENT, Post\Media::UNKNOWN]) as $attachment) { + $attributes = ['rel' => 'enclosure', + 'href' => $attachment['url'], + 'type' => $attachment['mimetype']]; - if (intval($matches[2])) { - $attributes["length"] = intval($matches[2]); - } - - if (trim($matches[4]) != "") { - $attributes["title"] = trim($matches[4]); - } - - XML::addElement($doc, $root, "link", "", $attributes); - } + if (!empty($attachment['size'])) { + $attributes['length'] = intval($attachment['size']); } + if (!empty($attachment['description'])) { + $attributes['title'] = $attachment['description']; + } + + XML::addElement($doc, $root, 'link', '', $attributes); } } @@ -951,7 +947,7 @@ class DFRN $htmlbody = "[b]" . $item['title'] . "[/b]\n\n" . $htmlbody; } - $htmlbody = BBCode::convert($htmlbody, false, 7); + $htmlbody = BBCode::convert($htmlbody, false, BBCode::OSTATUS); } $author = self::addEntryAuthor($doc, "author", $item["author-link"], $item); @@ -960,13 +956,14 @@ class DFRN $dfrnowner = self::addEntryAuthor($doc, "dfrn:owner", $item["owner-link"], $item); $entry->appendChild($dfrnowner); - if (($item['parent'] != $item['id']) || ($item['parent-uri'] !== $item['uri']) || (($item['thr-parent'] !== '') && ($item['thr-parent'] !== $item['uri']))) { - $parent_item = (($item['thr-parent']) ? $item['thr-parent'] : $item['parent-uri']); - $parent = Item::selectFirst(['guid', 'plink'], ['uri' => $parent_item, 'uid' => $item['uid']]); - $attributes = ["ref" => $parent_item, "type" => "text/html", - "href" => $parent['plink'], - "dfrn:diaspora_guid" => $parent['guid']]; - XML::addElement($doc, $entry, "thr:in-reply-to", "", $attributes); + if ($item['gravity'] != GRAVITY_PARENT) { + $parent = Item::selectFirst(['guid', 'plink'], ['uri' => $item['thr-parent'], 'uid' => $item['uid']]); + if (DBA::isResult($parent)) { + $attributes = ["ref" => $item['thr-parent'], "type" => "text/html", + "href" => $parent['plink'], + "dfrn:diaspora_guid" => $parent['guid']]; + XML::addElement($doc, $entry, "thr:in-reply-to", "", $attributes); + } } // Add conversation data. This is used for OStatus @@ -974,7 +971,7 @@ class DFRN $conversation_uri = $conversation_href; if (isset($parent_item)) { - $conversation = DBA::selectFirst('conversation', ['conversation-uri', 'conversation-href'], ['item-uri' => $item['parent-uri']]); + $conversation = DBA::selectFirst('conversation', ['conversation-uri', 'conversation-href'], ['item-uri' => $item['thr-parent']]); if (DBA::isResult($conversation)) { if ($conversation['conversation-uri'] != '') { $conversation_uri = $conversation['conversation-uri']; @@ -1059,7 +1056,7 @@ class DFRN if ($item['object-type'] != "") { XML::addElement($doc, $entry, "activity:object-type", $item['object-type']); - } elseif ($item['id'] == $item['parent']) { + } elseif ($item['gravity'] == GRAVITY_PARENT) { XML::addElement($doc, $entry, "activity:object-type", Activity\ObjectType::NOTE); } else { XML::addElement($doc, $entry, "activity:object-type", Activity\ObjectType::COMMENT); @@ -1194,7 +1191,7 @@ class DFRN Logger::log('dfrn_deliver: ' . $url); - $curlResult = Network::curl($url); + $curlResult = DI::httpRequest()->get($url); if ($curlResult->isTimeout()) { return -2; // timed out @@ -1341,9 +1338,9 @@ class DFRN } - Logger::log('dfrn_deliver: ' . "SENDING: " . print_r($postvars, true), Logger::DATA); + Logger::debug('dfrn_deliver', ['post' => $postvars]); - $postResult = Network::post($contact['notify'], $postvars); + $postResult = DI::httpRequest()->post($contact['notify'], $postvars); $xml = $postResult->getBody(); @@ -1410,7 +1407,7 @@ class DFRN } } - $fcontact = Diaspora::personByHandle($contact['addr']); + $fcontact = FContact::getByURL($contact['addr']); if (empty($fcontact)) { Logger::log('Unable to find contact details for ' . $contact['id'] . ' - ' . $contact['addr']); return -22; @@ -1440,7 +1437,7 @@ class DFRN $content_type = ($public_batch ? "application/magic-envelope+xml" : "application/json"); - $postResult = Network::post($dest_url, $envelope, ["Content-Type: ".$content_type]); + $postResult = DI::httpRequest()->post($dest_url, $envelope, ["Content-Type: " . $content_type]); $xml = $postResult->getBody(); $curl_stat = $postResult->getReturnCode(); @@ -1495,21 +1492,25 @@ class DFRN $fields = ['id', 'uid', 'url', 'network', 'avatar-date', 'avatar', 'name-date', 'uri-date', 'addr', 'name', 'nick', 'about', 'location', 'keywords', 'xmpp', 'bdyear', 'bd', 'hidden', 'contact-type']; - $condition = ["`uid` = ? AND `nurl` = ? AND `network` != ?", + $condition = ["`uid` = ? AND `nurl` = ? AND `network` != ? AND NOT `pending` AND NOT `blocked`", $importer["importer_uid"], Strings::normaliseLink($author["link"]), Protocol::STATUSNET]; + + if ($importer['account-type'] != User::ACCOUNT_TYPE_COMMUNITY) { + $condition = DBA::mergeConditions($condition, ['rel' => [Contact::SHARING, Contact::FRIEND]]); + } + $contact_old = DBA::selectFirst('contact', $fields, $condition); if (DBA::isResult($contact_old)) { $author["contact-id"] = $contact_old["id"]; $author["network"] = $contact_old["network"]; } else { - if (!$onlyfetch) { - Logger::debug("Contact ".$author["link"]." wasn't found for user ".$importer["importer_uid"]." XML: ".$xml); - } + Logger::info('Contact not found', ['condition' => $condition]); $author["contact-unknown"] = true; - $author["contact-id"] = $importer["id"]; - $author["network"] = $importer["network"]; + $contact = Contact::getByURL($author["link"], null, ["id", "network"]); + $author["contact-id"] = $contact["id"] ?? $importer["id"]; + $author["network"] = $contact["network"] ?? $importer["network"]; $onlyfetch = true; } @@ -1560,7 +1561,7 @@ class DFRN if (DBA::isResult($contact_old) && !$onlyfetch) { Logger::log("Check if contact details for contact " . $contact_old["id"] . " (" . $contact_old["nick"] . ") have to be updated.", Logger::DEBUG); - $poco = ["url" => $contact_old["url"]]; + $poco = ["url" => $contact_old["url"], "network" => $contact_old["network"]]; // When was the last change to name or uri? $name_element = $xpath->query($element . "/atom:name", $context)->item(0); @@ -1680,27 +1681,12 @@ class DFRN $condition = ['uid' => 0, 'nurl' => Strings::normaliseLink($contact_old['url'])]; DBA::update('contact', $fields, $condition, true); - Contact::updateAvatar($author['avatar'], $importer['importer_uid'], $contact['id']); + Contact::updateAvatar($contact['id'], $author['avatar']); $pcid = Contact::getIdForURL($contact_old['url']); if (!empty($pcid)) { - Contact::updateAvatar($author['avatar'], 0, $pcid); + Contact::updateAvatar($pcid, $author['avatar']); } - - /* - * The generation is a sign for the reliability of the provided data. - * It is used in the socgraph.php to prevent that old contact data - * that was relayed over several servers can overwrite contact - * data that we received directly. - */ - - $poco["generation"] = 2; - $poco["photo"] = $author["avatar"]; - $poco["hide"] = $hide; - $poco["contact-type"] = $contact["contact-type"]; - $gcid = GContact::update($poco); - - GContact::link($gcid, $importer["importer_uid"], $contact["id"]); } return $author; @@ -1777,15 +1763,15 @@ class DFRN $msg = []; $msg["uid"] = $importer["importer_uid"]; - $msg["from-name"] = $xpath->query("dfrn:sender/dfrn:name/text()", $mail)->item(0)->nodeValue; - $msg["from-url"] = $xpath->query("dfrn:sender/dfrn:uri/text()", $mail)->item(0)->nodeValue; - $msg["from-photo"] = $xpath->query("dfrn:sender/dfrn:avatar/text()", $mail)->item(0)->nodeValue; + $msg["from-name"] = XML::getFirstValue($xpath, "dfrn:sender/dfrn:name/text()", $mail); + $msg["from-url"] = XML::getFirstValue($xpath, "dfrn:sender/dfrn:uri/text()", $mail); + $msg["from-photo"] = XML::getFirstValue($xpath, "dfrn:sender/dfrn:avatar/text()", $mail); $msg["contact-id"] = $importer["id"]; - $msg["uri"] = $xpath->query("dfrn:id/text()", $mail)->item(0)->nodeValue; - $msg["parent-uri"] = $xpath->query("dfrn:in-reply-to/text()", $mail)->item(0)->nodeValue; - $msg["created"] = DateTimeFormat::utc($xpath->query("dfrn:sentdate/text()", $mail)->item(0)->nodeValue); - $msg["title"] = $xpath->query("dfrn:subject/text()", $mail)->item(0)->nodeValue; - $msg["body"] = $xpath->query("dfrn:content/text()", $mail)->item(0)->nodeValue; + $msg["uri"] = XML::getFirstValue($xpath, "dfrn:id/text()", $mail); + $msg["parent-uri"] = XML::getFirstValue($xpath, "dfrn:in-reply-to/text()", $mail); + $msg["created"] = DateTimeFormat::utc(XML::getFirstValue($xpath, "dfrn:sentdate/text()", $mail)); + $msg["title"] = XML::getFirstValue($xpath, "dfrn:subject/text()", $mail); + $msg["body"] = XML::getFirstValue($xpath, "dfrn:content/text()", $mail); Mail::insert($msg); } @@ -1802,91 +1788,13 @@ class DFRN */ private static function processSuggestion($xpath, $suggestion, $importer) { - Logger::log('Processing suggestions'); + Logger::notice('Processing suggestions'); - /// @TODO Rewrite this to one statement - $suggest = []; - $suggest['uid'] = $importer['importer_uid']; - $suggest['cid'] = $importer['id']; - $suggest['url'] = $xpath->query('dfrn:url/text()', $suggestion)->item(0)->nodeValue; - $suggest['name'] = $xpath->query('dfrn:name/text()', $suggestion)->item(0)->nodeValue; - $suggest['photo'] = $xpath->query('dfrn:photo/text()', $suggestion)->item(0)->nodeValue; - $suggest['request'] = $xpath->query('dfrn:request/text()', $suggestion)->item(0)->nodeValue; - $suggest['body'] = $xpath->query('dfrn:note/text()', $suggestion)->item(0)->nodeValue; + $url = $xpath->evaluate('string(dfrn:url[1]/text())', $suggestion); + $cid = Contact::getIdForURL($url); + $note = $xpath->evaluate('string(dfrn:note[1]/text())', $suggestion); - // Does our member already have a friend matching this description? - - /* - * The valid result means the friend we're about to send a friend - * suggestion already has them in their contact, which means no further - * action is required. - * - * @see https://github.com/friendica/friendica/pull/3254#discussion_r107315246 - */ - $condition = ['nurl' => Strings::normaliseLink($suggest['url']), 'uid' => $suggest['uid']]; - if (DBA::exists('contact', $condition)) { - return false; - } - // Do we already have an fcontact record for this person? - - $fid = 0; - $fcontact = DBA::selectFirst('fcontact', ['id'], ['url' => $suggest['url']]); - if (DBA::isResult($fcontact)) { - $fid = $fcontact['id']; - - // OK, we do. Do we already have an introduction for this person? - if (DBA::exists('intro', ['uid' => $suggest['uid'], 'fid' => $fid])) { - /* - * The valid result means the friend we're about to send a friend - * suggestion already has them in their contact, which means no further - * action is required. - * - * @see https://github.com/friendica/friendica/pull/3254#discussion_r107315246 - */ - return false; - } - } - - if (!$fid) { - $fields = ['name' => $suggest['name'], 'url' => $suggest['url'], - 'photo' => $suggest['photo'], 'request' => $suggest['request']]; - DBA::insert('fcontact', $fields); - $fid = DBA::lastInsertId(); - } - - /* - * If no record in fcontact is found, below INSERT statement will not - * link an introduction to it. - */ - if (empty($fid)) { - // Database record did not get created. Quietly give up. - exit(); - } - - $hash = Strings::getRandomHex(); - - $fields = ['uid' => $suggest['uid'], 'fid' => $fid, 'contact-id' => $suggest['cid'], - 'note' => $suggest['body'], 'hash' => $hash, 'datetime' => DateTimeFormat::utcNow(), 'blocked' => false]; - DBA::insert('intro', $fields); - - notification( - [ - 'type' => Type::SUGGEST, - 'notify_flags' => $importer['notify-flags'], - 'language' => $importer['language'], - 'to_name' => $importer['username'], - 'to_email' => $importer['email'], - 'uid' => $importer['importer_uid'], - 'item' => $suggest, - 'link' => DI::baseUrl().'/notifications/intros', - 'source_name' => $importer['name'], - 'source_link' => $importer['url'], - 'source_photo' => $importer['photo'], - 'verb' => Activity::REQ_FRIEND, - 'otype' => 'intro'] - ); - - return true; + return FContact::addSuggestion($importer['importer_uid'], $cid, $importer['id'], $note); } /** @@ -1943,15 +1851,6 @@ class DFRN $old = $r[0]; - // Update the gcontact entry - $relocate["server_url"] = preg_replace("=(https?://)(.*)/profile/(.*)=ism", "$1$2", $relocate["url"]); - - $fields = ['name' => $relocate["name"], 'photo' => $relocate["avatar"], - 'url' => $relocate["url"], 'nurl' => Strings::normaliseLink($relocate["url"]), - 'addr' => $relocate["addr"], 'connect' => $relocate["addr"], - 'notify' => $relocate["notify"], 'server_url' => $relocate["server_url"]]; - DBA::update('gcontact', $fields, ['nurl' => Strings::normaliseLink($old["url"])]); - // Update the contact table. We try to find every entry. $fields = ['name' => $relocate["name"], 'avatar' => $relocate["avatar"], 'url' => $relocate["url"], 'nurl' => Strings::normaliseLink($relocate["url"]), @@ -1962,7 +1861,7 @@ class DFRN DBA::update('contact', $fields, $condition); - Contact::updateAvatar($relocate["avatar"], $importer["importer_uid"], $importer["id"], true); + Contact::updateAvatar($importer["id"], $relocate["avatar"], true); Logger::log('Contacts are updated.'); @@ -2019,7 +1918,7 @@ class DFRN */ private static function getEntryType($importer, $item) { - if ($item["parent-uri"] != $item["uri"]) { + if ($item["thr-parent"] != $item["uri"]) { $community = false; if ($importer["page-flags"] == User::PAGE_FLAGS_COMMUNITY || $importer["page-flags"] == User::PAGE_FLAGS_PRVGROUP) { @@ -2035,18 +1934,18 @@ class DFRN $is_a_remote_action = false; - $parent = Item::selectFirst(['parent-uri'], ['uri' => $item["parent-uri"]]); + $parent = Item::selectFirst(['thr-parent'], ['uri' => $item["thr-parent"]]); if (DBA::isResult($parent)) { $r = q( "SELECT `item`.`forum_mode`, `item`.`wall` FROM `item` INNER JOIN `contact` ON `contact`.`id` = `item`.`contact-id` - WHERE `item`.`uri` = '%s' AND (`item`.`parent-uri` = '%s' OR `item`.`thr-parent` = '%s') + WHERE `item`.`uri` = '%s' AND (`item`.`thr-parent` = '%s' OR `item`.`thr-parent` = '%s') AND `item`.`uid` = %d $sql_extra LIMIT 1", - DBA::escape($parent["parent-uri"]), - DBA::escape($parent["parent-uri"]), - DBA::escape($parent["parent-uri"]), + DBA::escape($parent["thr-parent"]), + DBA::escape($parent["thr-parent"]), + DBA::escape($parent["thr-parent"]), intval($importer["importer_uid"]) ); if (DBA::isResult($r)) { @@ -2107,29 +2006,23 @@ class DFRN } if ($Blink && Strings::compareLink($Blink, DI::baseUrl() . "/profile/" . $importer["nickname"])) { - $author = DBA::selectFirst('contact', ['name', 'thumb', 'url'], ['id' => $item['author-id']]); + $author = DBA::selectFirst('contact', ['id', 'name', 'thumb', 'url'], ['id' => $item['author-id']]); - $parent = Item::selectFirst(['id'], ['uri' => $item['parent-uri'], 'uid' => $importer["importer_uid"]]); - $item["parent"] = $parent['id']; + $parent = Item::selectFirst(['id'], ['uri' => $item['thr-parent'], 'uid' => $importer["importer_uid"]]); + $item['parent'] = $parent['id']; // send a notification notification( [ - "type" => Type::POKE, - "notify_flags" => $importer["notify-flags"], - "language" => $importer["language"], - "to_name" => $importer["username"], - "to_email" => $importer["email"], - "uid" => $importer["importer_uid"], - "item" => $item, - "link" => DI::baseUrl()."/display/".urlencode($item['guid']), - "source_name" => $author["name"], - "source_link" => $author["url"], - "source_photo" => $author["thumb"], - "verb" => $item["verb"], - "otype" => "person", - "activity" => $verb, - "parent" => $item["parent"]] + "type" => Type::POKE, + "otype" => Notify\ObjectType::PERSON, + "activity" => $verb, + "verb" => $item["verb"], + "uid" => $importer["importer_uid"], + "cid" => $author["id"], + "item" => $item, + "link" => DI::baseUrl() . "/display/" . urlencode($item['guid']), + ] ); } } @@ -2186,19 +2079,20 @@ class DFRN || ($item["verb"] == Activity::ATTEND) || ($item["verb"] == Activity::ATTENDNO) || ($item["verb"] == Activity::ATTENDMAYBE) + || ($item["verb"] == Activity::ANNOUNCE) ) { $is_like = true; $item["gravity"] = GRAVITY_ACTIVITY; // only one like or dislike per person - // splitted into two queries for performance issues + // split into two queries for performance issues $condition = ['uid' => $item["uid"], 'author-id' => $item["author-id"], 'gravity' => GRAVITY_ACTIVITY, - 'verb' => $item["verb"], 'parent-uri' => $item["parent-uri"]]; + 'verb' => $item['verb'], 'parent-uri' => $item['thr-parent']]; if (Item::exists($condition)) { return false; } $condition = ['uid' => $item["uid"], 'author-id' => $item["author-id"], 'gravity' => GRAVITY_ACTIVITY, - 'verb' => $item["verb"], 'thr-parent' => $item["parent-uri"]]; + 'verb' => $item['verb'], 'thr-parent' => $item['thr-parent']]; if (Item::exists($condition)) { return false; } @@ -2246,9 +2140,9 @@ class DFRN { $rel = ""; $href = ""; - $type = ""; - $length = "0"; - $title = ""; + $type = null; + $length = null; + $title = null; foreach ($links as $link) { foreach ($link->attributes as $attributes) { switch ($attributes->name) { @@ -2265,19 +2159,33 @@ class DFRN $item["plink"] = $href; break; case "enclosure": - if (!empty($item["attach"])) { - $item["attach"] .= ","; - } else { - $item["attach"] = ""; - } - - $item["attach"] .= '[attach]href="' . $href . '" length="' . $length . '" type="' . $type . '" title="' . $title . '"[/attach]'; + Post\Media::insert(['uri-id' => $item['uri-id'], 'type' => Post\Media::DOCUMENT, + 'url' => $href, 'mimetype' => $type, 'size' => $length, 'description' => $title]); break; } } } } + /** + * Checks if an incoming message is wanted + * + * @param array $item + * @return boolean Is the message wanted? + */ + private static function isSolicitedMessage(array $item) + { + if (DBA::exists('contact', ["`nurl` = ? AND `uid` != ? AND `rel` IN (?, ?)", + Strings::normaliseLink($item["author-link"]), 0, Contact::FRIEND, Contact::SHARING])) { + Logger::info('Author has got followers - accepted', ['uri' => $item['uri'], 'author' => $item["author-link"]]); + return true; + } + + $taglist = Tag::getByURIId($item['uri-id'], [Tag::HASHTAG]); + $tags = array_column($taglist, 'name'); + return Relay::isSolicitedPost($tags, $item['body'], $item['author-id'], $item['uri'], Protocol::DFRN); + } + /** * Processes the entry elements which contain the items and comments * @@ -2291,13 +2199,13 @@ class DFRN * @throws \ImagickException * @todo Add type-hints */ - private static function processEntry($header, $xpath, $entry, $importer, $xml) + private static function processEntry($header, $xpath, $entry, $importer, $xml, $protocol) { Logger::log("Processing entries"); $item = $header; - $item["protocol"] = Conversation::PARCEL_DFRN; + $item["protocol"] = $protocol; $item["source"] = $xml; @@ -2384,7 +2292,11 @@ class DFRN // We store the data from "dfrn:diaspora_signature" in a different table, this is done in "Item::insert" $dsprsig = XML::unescape(XML::getFirstNodeValue($xpath, "dfrn:diaspora_signature/text()", $entry)); if ($dsprsig != "") { - $item["dsprsig"] = $dsprsig; + $signature = json_decode(base64_decode($dsprsig)); + // We don't store the old style signatures anymore that also contained the "signature" and "signer" + if (!empty($signature->signed_text) && empty($signature->signature) && empty($signature->signer)) { + $item["diaspora_signed_text"] = $signature->signed_text; + } } $item["verb"] = XML::getFirstNodeValue($xpath, "activity:verb/text()", $entry); @@ -2424,7 +2336,8 @@ class DFRN if (($term != "") && ($scheme != "")) { $parts = explode(":", $scheme); if ((count($parts) >= 4) && (array_shift($parts) == "X-DFRN")) { - $termurl = implode(":", $parts); + $termurl = array_pop($parts); + $termurl = array_pop($parts) . ':' . $termurl; Tag::store($item['uri-id'], Tag::IMPLICIT_MENTION, $term, $termurl); } } @@ -2451,17 +2364,25 @@ class DFRN } // Is it a reply or a top level posting? - $item["parent-uri"] = $item["uri"]; + $item['thr-parent'] = $item['uri']; $inreplyto = $xpath->query("thr:in-reply-to", $entry); if (is_object($inreplyto->item(0))) { foreach ($inreplyto->item(0)->attributes as $attributes) { if ($attributes->name == "ref") { - $item["parent-uri"] = $attributes->textContent; + $item['thr-parent'] = $attributes->textContent; } } } + // Check if the message is wanted + if (($importer['importer_uid'] == 0) && ($item['uri'] == $item['thr-parent'])) { + if (!self::isSolicitedMessage($item)) { + DBA::delete('item-uri', ['uri' => $item['uri']]); + return 403; + } + } + // Get the type of the item (Top level post, reply or remote reply) $entrytype = self::getEntryType($importer, $item); @@ -2504,13 +2425,17 @@ class DFRN $ev = Event::fromBBCode($item["body"]); if ((!empty($ev['desc']) || !empty($ev['summary'])) && !empty($ev['start'])) { Logger::log("Event in item ".$item["uri"]." was found.", Logger::DEBUG); - $ev["cid"] = $importer["id"]; - $ev["uid"] = $importer["importer_uid"]; - $ev["uri"] = $item["uri"]; - $ev["edited"] = $item["edited"]; - $ev["private"] = $item["private"]; - $ev["guid"] = $item["guid"]; - $ev["plink"] = $item["plink"]; + $ev["cid"] = $importer["id"]; + $ev["uid"] = $importer["importer_uid"]; + $ev["uri"] = $item["uri"]; + $ev["edited"] = $item["edited"]; + $ev["private"] = $item["private"]; + $ev["guid"] = $item["guid"]; + $ev["plink"] = $item["plink"]; + $ev["network"] = $item["network"]; + $ev["protocol"] = $item["protocol"]; + $ev["direction"] = $item["direction"]; + $ev["source"] = $item["source"]; $condition = ['uri' => $item["uri"], 'uid' => $importer["importer_uid"]]; $event = DBA::selectFirst('event', ['id'], $condition); @@ -2548,6 +2473,11 @@ class DFRN } if (in_array($entrytype, [DFRN::REPLY, DFRN::REPLY_RC])) { + // Will be overwritten for sharing accounts in Item::insert + if (empty($item['post-type']) && ($entrytype == DFRN::REPLY)) { + $item['post-type'] = Item::PT_COMMENT; + } + $posted_id = Item::insert($item); if ($posted_id) { Logger::log("Reply from contact ".$item["contact-id"]." was stored with id ".$posted_id, Logger::DEBUG); @@ -2584,7 +2514,7 @@ class DFRN // Turn this into a wall post. $notify = Item::isRemoteSelf($importer, $item); - $posted_id = Item::insert($item, false, $notify); + $posted_id = Item::insert($item, $notify); if ($notify) { $posted_id = $notify; @@ -2629,7 +2559,7 @@ class DFRN } $condition = ['uri' => $uri, 'uid' => $importer["importer_uid"]]; - $item = Item::selectFirst(['id', 'parent', 'contact-id', 'file', 'deleted'], $condition); + $item = Item::selectFirst(['id', 'parent', 'contact-id', 'file', 'deleted', 'gravity'], $condition); if (!DBA::isResult($item)) { Logger::log("Item with uri " . $uri . " for user " . $importer["importer_uid"] . " wasn't found.", Logger::DEBUG); return; @@ -2641,13 +2571,13 @@ class DFRN } // When it is a starting post it has to belong to the person that wants to delete it - if (($item['id'] == $item['parent']) && ($item['contact-id'] != $importer["id"])) { + if (($item['gravity'] == GRAVITY_PARENT) && ($item['contact-id'] != $importer["id"])) { Logger::log("Item with uri " . $uri . " don't belong to contact " . $importer["id"] . " - ignoring deletion.", Logger::DEBUG); return; } // Comments can be deleted by the thread owner or comment owner - if (($item['id'] != $item['parent']) && ($item['contact-id'] != $importer["id"])) { + if (($item['gravity'] != GRAVITY_PARENT) && ($item['contact-id'] != $importer["id"])) { $condition = ['id' => $item['parent'], 'contact-id' => $importer["id"]]; if (!Item::exists($condition)) { Logger::log("Item with uri " . $uri . " wasn't found or mustn't be deleted by contact " . $importer["id"] . " - ignoring deletion.", Logger::DEBUG); @@ -2667,15 +2597,16 @@ class DFRN /** * Imports a DFRN message * - * @param string $xml The DFRN message - * @param array $importer Record of the importer user mixed with contact of the content - * @param bool $sort_by_date Is used when feeds are polled + * @param string $xml The DFRN message + * @param array $importer Record of the importer user mixed with contact of the content + * @param int $protocol Transport protocol + * @param int $direction Is the message pushed or pulled? * @return integer Import status * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException * @todo set proper type-hints */ - public static function import($xml, $importer, $sort_by_date = false) + public static function import($xml, $importer, $protocol, $direction) { if ($xml == "") { return 400; @@ -2702,6 +2633,11 @@ class DFRN $header["wall"] = 0; $header["origin"] = 0; $header["contact-id"] = $importer["id"]; + $header["direction"] = $direction; + + if ($direction === Conversation::RELAY) { + $header['post-type'] = Item::PT_RELAY; + } // Update the contact table if the data has changed @@ -2779,30 +2715,21 @@ class DFRN } $deletions = $xpath->query("/atom:feed/at:deleted-entry"); - foreach ($deletions as $deletion) { - self::processDeletion($xpath, $deletion, $importer); - } - - if (!$sort_by_date) { - $entries = $xpath->query("/atom:feed/atom:entry"); - foreach ($entries as $entry) { - self::processEntry($header, $xpath, $entry, $importer, $xml); + if (!empty($deletions)) { + foreach ($deletions as $deletion) { + self::processDeletion($xpath, $deletion, $importer); } - } else { - $newentries = []; - $entries = $xpath->query("/atom:feed/atom:entry"); - foreach ($entries as $entry) { - $created = XML::getFirstNodeValue($xpath, "atom:published/text()", $entry); - $newentries[strtotime($created)] = $entry; - } - - // Now sort after the publishing date - ksort($newentries); - - foreach ($newentries as $entry) { - self::processEntry($header, $xpath, $entry, $importer, $xml); + if (count($deletions) > 0) { + Logger::notice('Deletions had been processed'); + return 200; } } + + $entries = $xpath->query("/atom:feed/atom:entry"); + foreach ($entries as $entry) { + self::processEntry($header, $xpath, $entry, $importer, $xml, $protocol); + } + Logger::log("Import done for user " . $importer["importer_uid"] . " from contact " . $importer["id"], Logger::DEBUG); return 200; } @@ -2828,7 +2755,7 @@ class DFRN // check that the message originated elsewhere and is a top-level post - if ($item['wall'] || $item['origin'] || ($item['uri'] != $item['parent-uri'])) { + if ($item['wall'] || $item['origin'] || ($item['uri'] != $item['thr-parent'])) { return false; } @@ -2897,14 +2824,13 @@ class DFRN * Checks if the given contact url does support DFRN * * @param string $url profile url - * @param boolean $update Update the profile * @return boolean * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function isSupportedByContactUrl($url, $update = false) + public static function isSupportedByContactUrl($url) { - $probe = Probe::uri($url, Protocol::DFRN, 0, !$update); + $probe = Probe::uri($url, Protocol::DFRN); return $probe['network'] == Protocol::DFRN; } } diff --git a/src/Protocol/Diaspora.php b/src/Protocol/Diaspora.php index 708f5335a5..269d623868 100644 --- a/src/Protocol/Diaspora.php +++ b/src/Protocol/Diaspora.php @@ -22,6 +22,7 @@ namespace Friendica\Protocol; use Friendica\Content\Feature; +use Friendica\Content\PageInfo; use Friendica\Content\Text\BBCode; use Friendica\Content\Text\Markdown; use Friendica\Core\Cache\Duration; @@ -33,7 +34,7 @@ use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\Conversation; -use Friendica\Model\GContact; +use Friendica\Model\FContact; use Friendica\Model\Item; use Friendica\Model\ItemURI; use Friendica\Model\Mail; @@ -55,196 +56,6 @@ use SimpleXMLElement; */ class Diaspora { - /** - * Mark the relay contact of the given contact for archival - * This is called whenever there is a communication issue with the server. - * It avoids sending stuff to servers who don't exist anymore. - * The relay contact is a technical contact entry that exists once per server. - * - * @param array $contact of the relay contact - */ - public static function markRelayForArchival(array $contact) - { - if (!empty($contact['contact-type']) && ($contact['contact-type'] == Contact::TYPE_RELAY)) { - // This is already the relay contact, we don't need to fetch it - $relay_contact = $contact; - } elseif (empty($contact['baseurl'])) { - if (!empty($contact['batch'])) { - $condition = ['uid' => 0, 'network' => Protocol::FEDERATED, 'batch' => $contact['batch'], 'contact-type' => Contact::TYPE_RELAY]; - $relay_contact = DBA::selectFirst('contact', [], $condition); - } else { - return; - } - } else { - $relay_contact = self::getRelayContact($contact['baseurl'], []); - } - - if (!empty($relay_contact)) { - Logger::info('Relay contact will be marked for archival', ['id' => $relay_contact['id'], 'url' => $relay_contact['url']]); - Contact::markForArchival($relay_contact); - } - } - - /** - * Return a list of relay servers - * - * The list contains not only the official relays but also servers that we serve directly - * - * @param integer $item_id The id of the item that is sent - * @param array $contacts The previously fetched contacts - * - * @return array of relay servers - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function relayList($item_id, array $contacts = []) - { - $serverlist = []; - - // Fetching relay servers - $serverdata = DI::config()->get("system", "relay_server"); - - if (!empty($serverdata)) { - $servers = explode(",", $serverdata); - foreach ($servers as $server) { - $serverlist[$server] = trim($server); - } - } - - if (DI::config()->get("system", "relay_directly", false)) { - // We distribute our stuff based on the parent to ensure that the thread will be complete - $parent = Item::selectFirst(['uri-id'], ['id' => $item_id]); - if (!DBA::isResult($parent)) { - return; - } - - // Servers that want to get all content - $servers = DBA::select('gserver', ['url'], ['relay-subscribe' => true, 'relay-scope' => 'all']); - while ($server = DBA::fetch($servers)) { - $serverlist[$server['url']] = $server['url']; - } - DBA::close($servers); - - // All tags of the current post - $tags = DBA::select('tag-view', ['name'], ['uri-id' => $parent['uri-id'], 'type' => Tag::HASHTAG]); - $taglist = []; - while ($tag = DBA::fetch($tags)) { - $taglist[] = $tag['name']; - } - DBA::close($tags); - - // All servers who wants content with this tag - $tagserverlist = []; - if (!empty($taglist)) { - $tagserver = DBA::select('gserver-tag', ['gserver-id'], ['tag' => $taglist]); - while ($server = DBA::fetch($tagserver)) { - $tagserverlist[] = $server['gserver-id']; - } - DBA::close($tagserver); - } - - // All adresses with the given id - if (!empty($tagserverlist)) { - $servers = DBA::select('gserver', ['url'], ['relay-subscribe' => true, 'relay-scope' => 'tags', 'id' => $tagserverlist]); - while ($server = DBA::fetch($servers)) { - $serverlist[$server['url']] = $server['url']; - } - DBA::close($servers); - } - } - - // Now we are collecting all relay contacts - foreach ($serverlist as $server_url) { - // We don't send messages to ourselves - if (Strings::compareLink($server_url, DI::baseUrl())) { - continue; - } - $contact = self::getRelayContact($server_url); - if (is_bool($contact)) { - continue; - } - - $exists = false; - foreach ($contacts as $entry) { - if ($entry['batch'] == $contact['batch']) { - $exists = true; - } - } - - if (!$exists) { - $contacts[] = $contact; - } - } - - return $contacts; - } - - /** - * Return a contact for a given server address or creates a dummy entry - * - * @param string $server_url The url of the server - * @param array $fields Fieldlist - * @return array with the contact - * @throws \Exception - */ - private static function getRelayContact(string $server_url, array $fields = ['batch', 'id', 'url', 'name', 'network', 'protocol', 'archive', 'blocked']) - { - // Fetch the relay contact - $condition = ['uid' => 0, 'nurl' => Strings::normaliseLink($server_url), - 'contact-type' => Contact::TYPE_RELAY]; - $contact = DBA::selectFirst('contact', $fields, $condition); - - if (DBA::isResult($contact)) { - if ($contact['archive'] || $contact['blocked']) { - return false; - } - return $contact; - } else { - self::setRelayContact($server_url); - - $contact = DBA::selectFirst('contact', $fields, $condition); - if (DBA::isResult($contact)) { - return $contact; - } - } - - // It should never happen that we arrive here - return []; - } - - /** - * Update or insert a relay contact - * - * @param string $server_url The url of the server - * @param array $network_fields Optional network specific fields - * @throws \Exception - */ - public static function setRelayContact($server_url, array $network_fields = []) - { - $fields = ['created' => DateTimeFormat::utcNow(), - 'name' => 'relay', 'nick' => 'relay', 'url' => $server_url, - 'nurl' => Strings::normaliseLink($server_url), - 'network' => Protocol::DIASPORA, 'uid' => 0, - 'batch' => $server_url . '/receive/public', - 'rel' => Contact::FOLLOWER, 'blocked' => false, - 'pending' => false, 'writable' => true, - 'baseurl' => $server_url, 'contact-type' => Contact::TYPE_RELAY]; - - $fields = array_merge($fields, $network_fields); - - $condition = ['uid' => 0, 'nurl' => Strings::normaliseLink($server_url)]; - $old = DBA::selectFirst('contact', [], $condition); - if (DBA::isResult($old)) { - unset($fields['created']); - $condition = ['id' => $old['id']]; - - Logger::info('Update relay contact', ['fields' => $fields, 'condition' => $condition]); - DBA::update('contact', $fields, $condition, $old); - } else { - Logger::info('Create relay contact', ['fields' => $fields]); - Contact::insert($fields); - } - } - /** * Return a list of participating contacts for a thread * @@ -252,24 +63,27 @@ class Diaspora * One of the parameters is a contact array. * This is done to avoid duplicates. * - * @param array $parent The parent post + * @param array $item Item that is about to be delivered * @param array $contacts The previously fetched contacts * * @return array of relay servers * @throws \Exception */ - public static function participantsForThread(array $parent, array $contacts) + public static function participantsForThread(array $item, array $contacts) { - if (!in_array($parent['private'], [Item::PUBLIC, Item::UNLISTED])) { + if (!in_array($item['private'], [Item::PUBLIC, Item::UNLISTED]) || in_array($item["verb"], [Activity::FOLLOW, Activity::TAG])) { + Logger::info('Item is private or a participation request. It will not be relayed', ['guid' => $item['guid'], 'private' => $item['private'], 'verb' => $item['verb']]); return $contacts; } - $items = Item::select(['author-id'], ['parent' => $parent['id']], ['group_by' => ['author-id']]); + $items = Item::select(['author-id', 'author-link', 'parent-author-link', 'parent-guid', 'guid'], + ['parent' => $item['parent'], 'gravity' => [GRAVITY_COMMENT, GRAVITY_ACTIVITY]]); while ($item = DBA::fetch($items)) { $contact = DBA::selectFirst('contact', ['id', 'url', 'name', 'protocol', 'batch', 'network'], ['id' => $item['author-id']]); - if (!DBA::isResult($contact)) { - // Shouldn't happen + if (!DBA::isResult($contact) || empty($contact['batch']) || + ($contact['network'] != Protocol::DIASPORA) || + Strings::compareLink($item['parent-author-link'], $item['author-link'])) { continue; } @@ -281,7 +95,7 @@ class Diaspora } if (!$exists) { - Logger::info('Add participant to receiver list', ['item' => $parent['guid'], 'participant' => $contact['url']]); + Logger::info('Add participant to receiver list', ['parent' => $item['parent-guid'], 'item' => $item['guid'], 'participant' => $contact['url']]); $contacts[] = $contact; } } @@ -290,37 +104,6 @@ class Diaspora return $contacts; } - /** - * repairs a signature that was double encoded - * - * The function is unused at the moment. It was copied from the old implementation. - * - * @param string $signature The signature - * @param string $handle The handle of the signature owner - * @param integer $level This value is only set inside this function to avoid endless loops - * - * @return string the repaired signature - * @throws \Exception - */ - private static function repairSignature($signature, $handle = "", $level = 1) - { - if ($signature == "") { - return ($signature); - } - - if (base64_encode(base64_decode(base64_decode($signature))) == base64_decode($signature)) { - $signature = base64_decode($signature); - Logger::log("Repaired double encoded signature from Diaspora/Hubzilla handle ".$handle." - level ".$level, Logger::DEBUG); - - // Do a recursive call to be able to fix even multiple levels - if ($level < 10) { - $signature = self::repairSignature($signature, $handle, ++$level); - } - } - - return($signature); - } - /** * verify the envelope and return the verified data * @@ -539,7 +322,7 @@ class Diaspora $basedom = XML::parseString($xml); if (!is_object($basedom)) { - Logger::log("XML is not parseable."); + Logger::notice('XML is not parseable.'); return false; } $children = $basedom->children('https://joindiaspora.com/protocol'); @@ -553,7 +336,7 @@ class Diaspora } else { // This happens with posts from a relais if (empty($privKey)) { - Logger::log("This is no private post in the old format", Logger::DEBUG); + Logger::info('This is no private post in the old format'); return false; } @@ -572,7 +355,7 @@ class Diaspora $decrypted = self::aesDecrypt($outer_key, $outer_iv, $ciphertext); - Logger::log('decrypted: '.$decrypted, Logger::DEBUG); + Logger::info('decrypted', ['data' => $decrypted]); $idom = XML::parseString($decrypted); $inner_iv = base64_decode($idom->iv); @@ -666,13 +449,14 @@ class Diaspora /** * Dispatches public messages and find the fitting receivers * - * @param array $msg The post that will be dispatched + * @param array $msg The post that will be dispatched + * @param bool $fetched The message had been fetched (default "false") * * @return int The message id of the generated message, "true" or "false" if there was an error * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function dispatchPublic($msg) + public static function dispatchPublic($msg, bool $fetched = false) { $enabled = intval(DI::config()->get("system", "diaspora_enabled")); if (!$enabled) { @@ -686,7 +470,7 @@ class Diaspora } $importer = ["uid" => 0, "page-flags" => User::PAGE_FLAGS_FREELOVE]; - $success = self::dispatch($importer, $msg, $fields); + $success = self::dispatch($importer, $msg, $fields, $fetched); return $success; } @@ -697,12 +481,13 @@ class Diaspora * @param array $importer Array of the importer user * @param array $msg The post that will be dispatched * @param SimpleXMLElement $fields SimpleXML object that contains the message + * @param bool $fetched The message had been fetched (default "false") * * @return int The message id of the generated message, "true" or "false" if there was an error * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function dispatch(array $importer, $msg, SimpleXMLElement $fields = null) + public static function dispatch(array $importer, $msg, SimpleXMLElement $fields = null, bool $fetched = false) { // The sender is the handle of the contact that sent the message. // This will often be different with relayed messages (for example "like" and "comment") @@ -721,7 +506,7 @@ class Diaspora $type = $fields->getName(); - Logger::log("Received message type ".$type." from ".$sender." for user ".$importer["uid"], Logger::DEBUG); + Logger::info('Received message', ['type' => $type, 'sender' => $sender, 'user' => $importer["uid"]]); switch ($type) { case "account_migration": @@ -735,7 +520,7 @@ class Diaspora return self::receiveAccountDeletion($fields); case "comment": - return self::receiveComment($importer, $sender, $fields, $msg["message"]); + return self::receiveComment($importer, $sender, $fields, $msg["message"], $fetched); case "contact": if (!$private) { @@ -752,7 +537,7 @@ class Diaspora return self::receiveConversation($importer, $msg, $fields); case "like": - return self::receiveLike($importer, $sender, $fields); + return self::receiveLike($importer, $sender, $fields, $fetched); case "message": if (!$private) { @@ -766,7 +551,7 @@ class Diaspora Logger::log('Message with type ' . $type . ' is not private, quitting.'); return false; } - return self::receiveParticipation($importer, $fields); + return self::receiveParticipation($importer, $fields, $fetched); case "photo": // Not implemented return self::receivePhoto($importer, $fields); @@ -782,13 +567,13 @@ class Diaspora return self::receiveProfile($importer, $fields); case "reshare": - return self::receiveReshare($importer, $fields, $msg["message"]); + return self::receiveReshare($importer, $fields, $msg["message"], $fetched); case "retraction": return self::receiveRetraction($importer, $sender, $fields); case "status_message": - return self::receiveStatusMessage($importer, $fields, $msg["message"]); + return self::receiveStatusMessage($importer, $fields, $msg["message"], $fetched); default: Logger::log("Unknown message type ".$type); @@ -813,7 +598,7 @@ class Diaspora $data = XML::parseString($msg["message"]); if (!is_object($data)) { - Logger::log("No valid XML ".$msg["message"], Logger::DEBUG); + Logger::info('No valid XML', ['message' => $msg['message']]); return false; } @@ -925,7 +710,7 @@ class Diaspora if (isset($parent_author_signature)) { $key = self::key($msg["author"]); if (empty($key)) { - Logger::log("No key found for parent author ".$msg["author"], Logger::DEBUG); + Logger::info('No key found for parent', ['author' => $msg["author"]]); return false; } @@ -937,7 +722,7 @@ class Diaspora $key = self::key($fields->author); if (empty($key)) { - Logger::log("No key found for author ".$fields->author, Logger::DEBUG); + Logger::info('No key found', ['author' => $fields->author]); return false; } @@ -964,7 +749,7 @@ class Diaspora Logger::log("Fetching diaspora key for: ".$handle); - $r = self::personByHandle($handle); + $r = FContact::getByURL($handle); if ($r) { return $r["pubkey"]; } @@ -972,81 +757,6 @@ class Diaspora return ""; } - /** - * Fetches data for a given handle - * - * @param string $handle The handle - * @param boolean $update true = always update, false = never update, null = update when not found or outdated - * - * @return array the queried data - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - public static function personByHandle($handle, $update = null) - { - $person = DBA::selectFirst('fcontact', [], ['network' => Protocol::DIASPORA, 'addr' => $handle]); - if (!DBA::isResult($person)) { - $urls = [$handle, str_replace('http://', 'https://', $handle), Strings::normaliseLink($handle)]; - $person = DBA::selectFirst('fcontact', [], ['network' => Protocol::DIASPORA, 'url' => $urls]); - } - - if (DBA::isResult($person)) { - Logger::debug("In cache " . print_r($person, true)); - - if (is_null($update)) { - // update record occasionally so it doesn't get stale - $d = strtotime($person["updated"]." +00:00"); - if ($d < strtotime("now - 14 days")) { - $update = true; - } - - if ($person["guid"] == "") { - $update = true; - } - } - } elseif (is_null($update)) { - $update = !DBA::isResult($person); - } else { - $person = []; - } - - if ($update) { - Logger::log("create or refresh", Logger::DEBUG); - $r = Probe::uri($handle, Protocol::DIASPORA); - - // Note that Friendica contacts will return a "Diaspora person" - // if Diaspora connectivity is enabled on their server - if ($r && ($r["network"] === Protocol::DIASPORA)) { - self::updateFContact($r); - - $person = self::personByHandle($handle, false); - } - } - - return $person; - } - - /** - * Updates the fcontact table - * - * @param array $arr The fcontact data - * @throws \Exception - */ - private static function updateFContact($arr) - { - $fields = ['name' => $arr["name"], 'photo' => $arr["photo"], - 'request' => $arr["request"], 'nick' => $arr["nick"], - 'addr' => strtolower($arr["addr"]), 'guid' => $arr["guid"], - 'batch' => $arr["batch"], 'notify' => $arr["notify"], - 'poll' => $arr["poll"], 'confirm' => $arr["confirm"], - 'alias' => $arr["alias"], 'pubkey' => $arr["pubkey"], - 'updated' => DateTimeFormat::utcNow()]; - - $condition = ['url' => $arr["url"], 'network' => $arr["network"]]; - - DBA::update('fcontact', $fields, $condition, true); - } - /** * get a handle (user@domain.tld) from a given contact id * @@ -1094,32 +804,6 @@ class Diaspora return strtolower($handle); } - /** - * get a url (scheme://domain.tld/u/user) from a given Diaspora* - * fcontact guid - * - * @param mixed $fcontact_guid Hexadecimal string guid - * - * @return string the contact url or null - * @throws \Exception - */ - public static function urlFromContactGuid($fcontact_guid) - { - Logger::log("fcontact guid is ".$fcontact_guid, Logger::DEBUG); - - $r = q( - "SELECT `url` FROM `fcontact` WHERE `url` != '' AND `network` = '%s' AND `guid` = '%s'", - DBA::escape(Protocol::DIASPORA), - DBA::escape($fcontact_guid) - ); - - if (DBA::isResult($r)) { - return $r[0]['url']; - } - - return null; - } - /** * Get a contact id for a given handle * @@ -1134,20 +818,7 @@ class Diaspora */ private static function contactByHandle($uid, $handle) { - $cid = Contact::getIdForURL($handle, $uid); - if (!$cid) { - Logger::log("Haven't found a contact for user " . $uid . " and handle " . $handle, Logger::DEBUG); - return false; - } - - $contact = DBA::selectFirst('contact', [], ['id' => $cid]); - if (!DBA::isResult($contact)) { - // This here shouldn't happen at all - Logger::log("Haven't found a contact for user " . $uid . " and handle " . $handle, Logger::DEBUG); - return false; - } - - return $contact; + return Contact::getByURL($handle, null, [], $uid); } /** @@ -1161,7 +832,7 @@ class Diaspora */ public static function isSupportedByContactUrl($url, $update = null) { - return !empty(self::personByHandle($url, $update)); + return !empty(FContact::getByURL($url, $update)); } /** @@ -1313,7 +984,7 @@ class Diaspora // 0 => '[url=/people/0123456789abcdef]Foo Bar[/url]' // 1 => '0123456789abcdef' // 2 => 'Foo Bar' - $handle = self::urlFromContactGuid($match[1]); + $handle = FContact::getUrlByGuid($match[1]); if ($handle) { $return = '@[url='.$handle.']'.$match[2].'[/url]'; @@ -1379,7 +1050,7 @@ class Diaspora Logger::log("Successfully fetched item ".$guid." from ".$server, Logger::DEBUG); // Now call the dispatcher - return self::dispatchPublic($msg); + return self::dispatchPublic($msg, true); } /** @@ -1406,7 +1077,7 @@ class Diaspora Logger::log("Fetch post from ".$source_url, Logger::DEBUG); - $envelope = Network::fetchUrl($source_url); + $envelope = DI::httpRequest()->fetch($source_url); if ($envelope) { Logger::log("Envelope was fetched.", Logger::DEBUG); $x = self::verifyMagicEnvelope($envelope); @@ -1514,13 +1185,13 @@ class Diaspora private static function parentItem($uid, $guid, $author, array $contact) { $fields = ['id', 'parent', 'body', 'wall', 'uri', 'guid', 'private', 'origin', - 'author-name', 'author-link', 'author-avatar', + 'author-name', 'author-link', 'author-avatar', 'gravity', 'owner-name', 'owner-link', 'owner-avatar']; $condition = ['uid' => $uid, 'guid' => $guid]; $item = Item::selectFirst($fields, $condition); if (!DBA::isResult($item)) { - $person = self::personByHandle($author); + $person = FContact::getByURL($author); $result = self::storeByGuid($guid, $person["url"], $uid); // We don't have an url for items that arrived at the public dispatcher @@ -1594,9 +1265,9 @@ class Diaspora * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function plink($addr, $guid, $parent_guid = '') + private static function plink(string $addr, string $guid, string $parent_guid = '') { - $contact = Contact::getDetailsByAddr($addr); + $contact = Contact::getByURL($addr); if (empty($contact)) { Logger::info('No contact data for address', ['addr' => $addr]); return ''; @@ -1683,7 +1354,7 @@ class Diaspora // Update the profile self::receiveProfile($importer, $data->profile); - // change the technical stuff in contact and gcontact + // change the technical stuff in contact $data = Probe::uri($new_handle); if ($data['network'] == Protocol::PHANTOM) { Logger::log('Account for '.$new_handle." couldn't be probed."); @@ -1698,14 +1369,6 @@ class Diaspora DBA::update('contact', $fields, ['addr' => $old_handle]); - $fields = ['url' => $data['url'], 'nurl' => Strings::normaliseLink($data['url']), - 'name' => $data['name'], 'nick' => $data['nick'], - 'addr' => $data['addr'], 'connect' => $data['addr'], - 'notify' => $data['notify'], 'photo' => $data['photo'], - 'server_url' => $data['baseurl'], 'network' => $data['network']]; - - DBA::update('gcontact', $fields, ['addr' => $old_handle]); - Logger::log('Contacts are updated.'); return true; @@ -1729,8 +1392,6 @@ class Diaspora } DBA::close($contacts); - DBA::delete('gcontact', ['addr' => $author]); - Logger::log('Removed contacts for ' . $author); return true; @@ -1753,7 +1414,7 @@ class Diaspora if (DBA::isResult($item)) { return $item["uri"]; } elseif (!$onlyfound) { - $person = self::personByHandle($author); + $person = FContact::getByURL($author); $parts = parse_url($person['url']); unset($parts['path']); @@ -1784,27 +1445,6 @@ class Diaspora } } - /** - * Find the best importer for a comment, like, ... - * - * @param string $guid The guid of the item - * - * @return array|boolean the origin owner of that post - or false - * @throws \Exception - */ - private static function importerForGuid($guid) - { - $item = Item::selectFirst(['uid'], ['origin' => true, 'guid' => $guid]); - if (DBA::isResult($item)) { - Logger::log("Found user ".$item['uid']." as owner of item ".$guid, Logger::DEBUG); - $contact = DBA::selectFirst('contact', [], ['self' => true, 'uid' => $item['uid']]); - if (DBA::isResult($contact)) { - return $contact; - } - } - return false; - } - /** * Store the mentions in the tag table * @@ -1830,7 +1470,7 @@ class Diaspora continue; } - $person = self::personByHandle($match[3]); + $person = FContact::getByURL($match[3]); if (empty($person)) { continue; } @@ -1846,12 +1486,13 @@ class Diaspora * @param string $sender The sender of the message * @param object $data The message object * @param string $xml The original XML of the message + * @param bool $fetched The message had been fetched and not pushed * * @return int The message id of the generated comment or "false" if there was an error * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function receiveComment(array $importer, $sender, $data, $xml) + private static function receiveComment(array $importer, $sender, $data, $xml, bool $fetched) { $author = Strings::escapeTags(XML::unescape($data->author)); $guid = Strings::escapeTags(XML::unescape($data->guid)); @@ -1866,9 +1507,9 @@ class Diaspora if (isset($data->thread_parent_guid)) { $thread_parent_guid = Strings::escapeTags(XML::unescape($data->thread_parent_guid)); - $thr_uri = self::getUriFromGuid("", $thread_parent_guid, true); + $thr_parent = self::getUriFromGuid("", $thread_parent_guid, true); } else { - $thr_uri = ""; + $thr_parent = ""; } $contact = self::allowedContactByHandle($importer, $sender, true); @@ -1881,12 +1522,12 @@ class Diaspora return true; } - $parent_item = self::parentItem($importer["uid"], $parent_guid, $author, $contact); - if (!$parent_item) { + $toplevel_parent_item = self::parentItem($importer["uid"], $parent_guid, $author, $contact); + if (!$toplevel_parent_item) { return false; } - $person = self::personByHandle($author); + $person = FContact::getByURL($author); if (!is_array($person)) { Logger::log("unable to find author details"); return false; @@ -1907,6 +1548,15 @@ class Diaspora $datarray["owner-link"] = $contact["url"]; $datarray["owner-id"] = Contact::getIdForURL($contact["url"], 0); + // Will be overwritten for sharing accounts in Item::insert + if ($fetched) { + $datarray["post-type"] = Item::PT_FETCHED; + } elseif ($datarray["uid"] == 0) { + $datarray["post-type"] = Item::PT_GLOBAL; + } else { + $datarray["post-type"] = Item::PT_COMMENT; + } + $datarray["guid"] = $guid; $datarray["uri"] = self::getUriFromGuid($author, $guid); $datarray['uri-id'] = ItemURI::insert(['uri' => $datarray['uri'], 'guid' => $datarray['guid']]); @@ -1914,20 +1564,17 @@ class Diaspora $datarray["verb"] = Activity::POST; $datarray["gravity"] = GRAVITY_COMMENT; - if ($thr_uri != "") { - $datarray["parent-uri"] = $thr_uri; - } else { - $datarray["parent-uri"] = $parent_item["uri"]; - } + $datarray['thr-parent'] = $thr_parent ?: $toplevel_parent_item['uri']; $datarray["object-type"] = Activity\ObjectType::COMMENT; $datarray["protocol"] = Conversation::PARCEL_DIASPORA; $datarray["source"] = $xml; + $datarray["direction"] = $fetched ? Conversation::PULL : Conversation::PUSH; $datarray["changed"] = $datarray["created"] = $datarray["edited"] = $created_at; - $datarray["plink"] = self::plink($author, $guid, $parent_item['guid']); + $datarray["plink"] = self::plink($author, $guid, $toplevel_parent_item['guid']); $body = Markdown::toBBCode($text); $datarray["body"] = self::replacePeopleGuid($body, $person["url"]); @@ -1939,10 +1586,15 @@ class Diaspora // If we are the origin of the parent we store the original data. // We notify our followers during the item storage. - if ($parent_item["origin"]) { + if ($toplevel_parent_item["origin"]) { $datarray['diaspora_signed_text'] = json_encode($data); } + if (Item::isTooOld($datarray)) { + Logger::info('Comment is too old', ['created' => $datarray['created'], 'uid' => $datarray['uid'], 'guid' => $datarray['guid']]); + return false; + } + $message_id = Item::insert($datarray); if ($message_id <= 0) { @@ -2001,7 +1653,7 @@ class Diaspora $body = Markdown::toBBCode($msg_text); $message_uri = $msg_author.":".$msg_guid; - $person = self::personByHandle($msg_author); + $person = FContact::getByURL($msg_author); return Mail::insert([ 'uid' => $importer['uid'], @@ -2089,7 +1741,7 @@ class Diaspora * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function receiveLike(array $importer, $sender, $data) + private static function receiveLike(array $importer, $sender, $data, bool $fetched) { $author = Strings::escapeTags(XML::unescape($data->author)); $guid = Strings::escapeTags(XML::unescape($data->guid)); @@ -2113,12 +1765,12 @@ class Diaspora return true; } - $parent_item = self::parentItem($importer["uid"], $parent_guid, $author, $contact); - if (!$parent_item) { + $toplevel_parent_item = self::parentItem($importer["uid"], $parent_guid, $author, $contact); + if (!$toplevel_parent_item) { return false; } - $person = self::personByHandle($author); + $person = FContact::getByURL($author); if (!is_array($person)) { Logger::log("unable to find author details"); return false; @@ -2138,6 +1790,7 @@ class Diaspora $datarray = []; $datarray["protocol"] = Conversation::PARCEL_DIASPORA; + $datarray["direction"] = $fetched ? Conversation::PULL : Conversation::PUSH; $datarray["uid"] = $importer["uid"]; $datarray["contact-id"] = $author_contact["cid"]; @@ -2151,7 +1804,7 @@ class Diaspora $datarray["verb"] = $verb; $datarray["gravity"] = GRAVITY_ACTIVITY; - $datarray["parent-uri"] = $parent_item["uri"]; + $datarray['thr-parent'] = $toplevel_parent_item['uri']; $datarray["object-type"] = Activity\ObjectType::NOTE; @@ -2161,11 +1814,11 @@ class Diaspora $datarray["changed"] = $datarray["created"] = $datarray["edited"] = DateTimeFormat::utcNow(); // like on comments have the comment as parent. So we need to fetch the toplevel parent - if ($parent_item["id"] != $parent_item["parent"]) { - $toplevel = Item::selectFirst(['origin'], ['id' => $parent_item["parent"]]); + if ($toplevel_parent_item['gravity'] != GRAVITY_PARENT) { + $toplevel = Item::selectFirst(['origin'], ['id' => $toplevel_parent_item['parent']]); $origin = $toplevel["origin"]; } else { - $origin = $parent_item["origin"]; + $origin = $toplevel_parent_item["origin"]; } // If we are the origin of the parent we store the original data. @@ -2174,6 +1827,11 @@ class Diaspora $datarray['diaspora_signed_text'] = json_encode($data); } + if (Item::isTooOld($datarray)) { + Logger::info('Like is too old', ['created' => $datarray['created'], 'uid' => $datarray['uid'], 'guid' => $datarray['guid']]); + return false; + } + $message_id = Item::insert($datarray); if ($message_id <= 0) { @@ -2224,7 +1882,7 @@ class Diaspora $message_uri = $author.":".$guid; - $person = self::personByHandle($author); + $person = FContact::getByURL($author); if (!$person) { Logger::log("unable to find author details"); return false; @@ -2261,7 +1919,7 @@ class Diaspora * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function receiveParticipation(array $importer, $data) + private static function receiveParticipation(array $importer, $data, bool $fetched) { $author = strtolower(Strings::escapeTags(XML::unescape($data->author))); $guid = Strings::escapeTags(XML::unescape($data->guid)); @@ -2276,17 +1934,21 @@ class Diaspora return true; } - $parent_item = self::parentItem($importer["uid"], $parent_guid, $author, $contact); - if (!$parent_item) { + $toplevel_parent_item = self::parentItem($importer["uid"], $parent_guid, $author, $contact); + if (!$toplevel_parent_item) { return false; } - if (!in_array($parent_item['private'], [Item::PUBLIC, Item::UNLISTED])) { + if (!$toplevel_parent_item['origin']) { + Logger::info('Not our origin. Participation is ignored', ['parent_guid' => $parent_guid, 'guid' => $guid, 'author' => $author]); + } + + if (!in_array($toplevel_parent_item['private'], [Item::PUBLIC, Item::UNLISTED])) { Logger::info('Item is not public, participation is ignored', ['parent_guid' => $parent_guid, 'guid' => $guid, 'author' => $author]); return false; } - $person = self::personByHandle($author); + $person = FContact::getByURL($author); if (!is_array($person)) { Logger::log("Person not found: ".$author); return false; @@ -2298,6 +1960,7 @@ class Diaspora $datarray = []; $datarray["protocol"] = Conversation::PARCEL_DIASPORA; + $datarray["direction"] = $fetched ? Conversation::PULL : Conversation::PUSH; $datarray["uid"] = $importer["uid"]; $datarray["contact-id"] = $author_contact["cid"]; @@ -2311,7 +1974,7 @@ class Diaspora $datarray["verb"] = Activity::FOLLOW; $datarray["gravity"] = GRAVITY_ACTIVITY; - $datarray["parent-uri"] = $parent_item["uri"]; + $datarray['thr-parent'] = $toplevel_parent_item['uri']; $datarray["object-type"] = Activity\ObjectType::NOTE; @@ -2320,14 +1983,31 @@ class Diaspora // Diaspora doesn't provide a date for a participation $datarray["changed"] = $datarray["created"] = $datarray["edited"] = DateTimeFormat::utcNow(); + if (Item::isTooOld($datarray)) { + Logger::info('Participation is too old', ['created' => $datarray['created'], 'uid' => $datarray['uid'], 'guid' => $datarray['guid']]); + return false; + } + $message_id = Item::insert($datarray); Logger::info('Participation stored', ['id' => $message_id, 'guid' => $guid, 'parent_guid' => $parent_guid, 'author' => $author]); // Send all existing comments and likes to the requesting server - $comments = Item::select(['id', 'uri-id', 'parent'], ['parent' => $parent_item['id']]); + $comments = Item::select(['id', 'uri-id', 'parent-author-network', 'author-network', 'verb'], + ['parent' => $toplevel_parent_item['id'], 'gravity' => [GRAVITY_COMMENT, GRAVITY_ACTIVITY]]); while ($comment = Item::fetch($comments)) { - if ($comment['id'] == $comment['parent']) { + if (in_array($comment['verb'], [Activity::FOLLOW, Activity::TAG])) { + Logger::info('participation messages are not relayed', ['item' => $comment['id']]); + continue; + } + + if ($comment['author-network'] == Protocol::ACTIVITYPUB) { + Logger::info('Comments from ActivityPub authors are not relayed', ['item' => $comment['id']]); + continue; + } + + if ($comment['parent-author-network'] == Protocol::ACTIVITYPUB) { + Logger::info('Comments to comments from ActivityPub authors are not relayed', ['item' => $comment['id']]); continue; } @@ -2421,7 +2101,7 @@ class Diaspora $image_url = "http://".$handle_parts[1].$image_url; } - Contact::updateAvatar($image_url, $importer["uid"], $contact["id"]); + Contact::updateAvatar($contact["id"], $image_url); // Generic birthday. We don't know the timezone. The year is irrelevant. @@ -2449,18 +2129,6 @@ class Diaspora DBA::update('contact', $fields, ['id' => $contact['id']]); - // @todo Update the public contact, then update the gcontact from that - - $gcontact = ["url" => $contact["url"], "network" => Protocol::DIASPORA, "generation" => 2, - "photo" => $image_url, "name" => $name, "location" => $location, - "about" => $about, "birthday" => $birthday, - "addr" => $author, "nick" => $nick, "keywords" => $keywords, - "hide" => !$searchable, "nsfw" => $nsfw]; - - $gcid = GContact::update($gcontact); - - GContact::link($gcid, $importer["uid"], $contact["id"]); - Logger::log("Profile of contact ".$contact["id"]." stored for user ".$importer["uid"], Logger::DEBUG); return true; @@ -2560,7 +2228,7 @@ class Diaspora Logger::log("Author ".$author." wants to listen to us.", Logger::DEBUG); } - $ret = self::personByHandle($author); + $ret = FContact::getByURL($author); if (!$ret || ($ret["network"] != Protocol::DIASPORA)) { Logger::log("Cannot resolve diaspora handle ".$author." for ".$recipient); @@ -2614,8 +2282,8 @@ class Diaspora } // Do we already have this item? - $fields = ['body', 'title', 'attach', 'app', 'created', 'object-type', 'uri', 'guid', - 'author-name', 'author-link', 'author-avatar']; + $fields = ['body', 'title', 'app', 'created', 'object-type', 'uri', 'guid', + 'author-name', 'author-link', 'author-avatar', 'plink', 'uri-id']; $condition = ['guid' => $guid, 'visible' => true, 'deleted' => false, 'private' => [Item::PUBLIC, Item::UNLISTED]]; $item = Item::selectFirst($fields, $condition); @@ -2633,7 +2301,7 @@ class Diaspora $item["body"] = self::replacePeopleGuid($item["body"], $item["author-link"]); // Add OEmbed and other information to the body - $item["body"] = add_page_info_to_body($item["body"], false, true); + $item["body"] = PageInfo::searchAndAppendToBody($item["body"], false, true); return $item; } else { @@ -2658,8 +2326,8 @@ class Diaspora } if ($stored) { - $fields = ['body', 'title', 'attach', 'app', 'created', 'object-type', 'uri', 'guid', - 'author-name', 'author-link', 'author-avatar']; + $fields = ['body', 'title', 'app', 'created', 'object-type', 'uri', 'guid', + 'author-name', 'author-link', 'author-avatar', 'plink', 'uri-id']; $condition = ['guid' => $guid, 'visible' => true, 'deleted' => false, 'private' => [Item::PUBLIC, Item::UNLISTED]]; $item = Item::selectFirst($fields, $condition); @@ -2703,18 +2371,25 @@ class Diaspora $datarray['guid'] = $parent['guid'] . '-' . $guid; $datarray['uri'] = self::getUriFromGuid($author, $datarray['guid']); - $datarray['parent-uri'] = $parent['uri']; + $datarray['thr-parent'] = $parent['uri']; $datarray['verb'] = $datarray['body'] = Activity::ANNOUNCE; $datarray['gravity'] = GRAVITY_ACTIVITY; $datarray['object-type'] = Activity\ObjectType::NOTE; $datarray['protocol'] = $item['protocol']; + $datarray['source'] = $item['source']; + $datarray['direction'] = $item['direction']; $datarray['plink'] = self::plink($author, $datarray['guid']); $datarray['private'] = $item['private']; $datarray['changed'] = $datarray['created'] = $datarray['edited'] = $item['created']; + if (Item::isTooOld($datarray)) { + Logger::info('Reshare activity is too old', ['created' => $datarray['created'], 'uid' => $datarray['uid'], 'guid' => $datarray['guid']]); + return false; + } + $message_id = Item::insert($datarray); if ($message_id) { @@ -2736,7 +2411,7 @@ class Diaspora * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function receiveReshare(array $importer, $data, $xml) + private static function receiveReshare(array $importer, $data, $xml, bool $fetched) { $author = Strings::escapeTags(XML::unescape($data->author)); $guid = Strings::escapeTags(XML::unescape($data->guid)); @@ -2761,7 +2436,9 @@ class Diaspora return false; } - $orig_url = DI::baseUrl()."/display/".$original_item["guid"]; + if (empty($original_item['plink'])) { + $original_item['plink'] = self::plink($root_author, $root_guid); + } $datarray = []; @@ -2776,7 +2453,7 @@ class Diaspora $datarray["owner-id"] = $datarray["author-id"]; $datarray["guid"] = $guid; - $datarray["uri"] = $datarray["parent-uri"] = self::getUriFromGuid($author, $guid); + $datarray["uri"] = $datarray["thr-parent"] = self::getUriFromGuid($author, $guid); $datarray['uri-id'] = ItemURI::insert(['uri' => $datarray['uri'], 'guid' => $datarray['guid']]); $datarray["verb"] = Activity::POST; @@ -2784,16 +2461,17 @@ class Diaspora $datarray["protocol"] = Conversation::PARCEL_DIASPORA; $datarray["source"] = $xml; + $datarray["direction"] = $fetched ? Conversation::PULL : Conversation::PUSH; /// @todo Copy tag data from original post - $prefix = share_header( + $prefix = BBCode::getShareOpeningTag( $original_item["author-name"], $original_item["author-link"], $original_item["author-avatar"], - $original_item["guid"], + $original_item["plink"], $original_item["created"], - $orig_url + $original_item["guid"] ); if (!empty($original_item['title'])) { @@ -2804,7 +2482,7 @@ class Diaspora Tag::storeFromBody($datarray['uri-id'], $datarray["body"]); - $datarray["attach"] = $original_item["attach"]; + Post\Media::copy($original_item['uri-id'], $datarray['uri-id']); $datarray["app"] = $original_item["app"]; $datarray["plink"] = self::plink($author, $guid); @@ -2814,6 +2492,12 @@ class Diaspora $datarray["object-type"] = $original_item["object-type"]; self::fetchGuid($datarray); + + if (Item::isTooOld($datarray)) { + Logger::info('Reshare is too old', ['created' => $datarray['created'], 'uid' => $datarray['uid'], 'guid' => $datarray['guid']]); + return false; + } + $message_id = Item::insert($datarray); self::sendParticipation($contact, $datarray); @@ -2850,7 +2534,7 @@ class Diaspora $target_guid = Strings::escapeTags(XML::unescape($data->target_guid)); $target_type = Strings::escapeTags(XML::unescape($data->target_type)); - $person = self::personByHandle($author); + $person = FContact::getByURL($author); if (!is_array($person)) { Logger::log("unable to find author detail for ".$author); return false; @@ -2861,7 +2545,7 @@ class Diaspora } // Fetch items that are about to be deleted - $fields = ['uid', 'id', 'parent', 'parent-uri', 'author-link', 'file']; + $fields = ['uid', 'id', 'parent', 'author-link', 'file']; // When we receive a public retraction, we delete every item that we find. if ($importer['uid'] == 0) { @@ -2883,7 +2567,7 @@ class Diaspora } // Fetch the parent item - $parent = Item::selectFirst(['author-link'], ['id' => $item["parent"]]); + $parent = Item::selectFirst(['author-link'], ['id' => $item['parent']]); // Only delete it if the parent author really fits if (!Strings::compareLink($parent["author-link"], $contact["url"]) && !Strings::compareLink($item["author-link"], $contact["url"])) { @@ -2893,7 +2577,7 @@ class Diaspora Item::markForDeletion(['id' => $item['id']]); - Logger::log("Deleted target ".$target_guid." (".$item["id"].") from user ".$item["uid"]." parent: ".$item["parent"], Logger::DEBUG); + Logger::log("Deleted target ".$target_guid." (".$item["id"].") from user ".$item["uid"]." parent: ".$item['parent'], Logger::DEBUG); } return true; @@ -2945,18 +2629,61 @@ class Diaspora return true; } + /** + * Checks if an incoming message is wanted + * + * @param string $url + * @param integer $uriid + * @param string $author + * @param string $body + * @return boolean Is the message wanted? + */ + private static function isSolicitedMessage(string $url, int $uriid, string $author, string $body) + { + $contact = Contact::getByURL($author); + if (DBA::exists('contact', ["`nurl` = ? AND `uid` != ? AND `rel` IN (?, ?)", + $contact['nurl'], 0, Contact::FRIEND, Contact::SHARING])) { + Logger::info('Author has got followers - accepted', ['url' => $url, 'author' => $author]); + return true; + } + + $taglist = Tag::getByURIId($uriid, [Tag::HASHTAG]); + $tags = array_column($taglist, 'name'); + return Relay::isSolicitedPost($tags, $body, $contact['id'], $url, Protocol::DIASPORA); + } + + /** + * Store an attached photo in the post-media table + * + * @param int $uriid + * @param object $photo + * @return void + */ + private static function storePhotoAsMedia(int $uriid, $photo) + { + $data = []; + $data['uri-id'] = $uriid; + $data['type'] = Post\Media::IMAGE; + $data['url'] = XML::unescape($photo->remote_photo_path) . XML::unescape($photo->remote_photo_name); + $data['height'] = (int)XML::unescape($photo->height ?? 0); + $data['width'] = (int)XML::unescape($photo->width ?? 0); + $data['description'] = XML::unescape($photo->text ?? ''); + + Post\Media::insert($data); + } + /** * Receives status messages * * @param array $importer Array of the importer user * @param SimpleXMLElement $data The message object * @param string $xml The original XML of the message - * + * @param bool $fetched The message had been fetched and not pushed * @return int The message id of the newly created item * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function receiveStatusMessage(array $importer, SimpleXMLElement $data, $xml) + private static function receiveStatusMessage(array $importer, SimpleXMLElement $data, $xml, bool $fetched) { $author = Strings::escapeTags(XML::unescape($data->author)); $guid = Strings::escapeTags(XML::unescape($data->guid)); @@ -2982,13 +2709,18 @@ class Diaspora } } - $body = Markdown::toBBCode($text); + $raw_body = $body = Markdown::toBBCode($text); $datarray = []; + $datarray["guid"] = $guid; + $datarray["uri"] = $datarray["thr-parent"] = self::getUriFromGuid($author, $guid); + $datarray['uri-id'] = ItemURI::insert(['uri' => $datarray['uri'], 'guid' => $datarray['guid']]); + // Attach embedded pictures to the body if ($data->photo) { foreach ($data->photo as $photo) { + self::storePhotoAsMedia($datarray['uri-id'], $photo); $body = "[img]".XML::unescape($photo->remote_photo_path). XML::unescape($photo->remote_photo_name)."[/img]\n".$body; } @@ -2999,7 +2731,7 @@ class Diaspora // Add OEmbed and other information to the body if (!self::isHubzilla($contact["url"])) { - $body = add_page_info_to_body($body, false, true); + $body = PageInfo::searchAndAppendToBody($body, false, true); } } @@ -3022,21 +2754,30 @@ class Diaspora $datarray["owner-link"] = $datarray["author-link"]; $datarray["owner-id"] = $datarray["author-id"]; - $datarray["guid"] = $guid; - $datarray["uri"] = $datarray["parent-uri"] = self::getUriFromGuid($author, $guid); - $datarray['uri-id'] = ItemURI::insert(['uri' => $datarray['uri'], 'guid' => $datarray['guid']]); - $datarray["verb"] = Activity::POST; $datarray["gravity"] = GRAVITY_PARENT; $datarray["protocol"] = Conversation::PARCEL_DIASPORA; $datarray["source"] = $xml; + $datarray["direction"] = $fetched ? Conversation::PULL : Conversation::PUSH; + + if ($fetched) { + $datarray["post-type"] = Item::PT_FETCHED; + } elseif ($datarray["uid"] == 0) { + $datarray["post-type"] = Item::PT_GLOBAL; + } $datarray["body"] = self::replacePeopleGuid($body, $contact["url"]); + $datarray["raw-body"] = self::replacePeopleGuid($raw_body, $contact["url"]); self::storeMentions($datarray['uri-id'], $text); Tag::storeRawTagsFromBody($datarray['uri-id'], $datarray["body"]); + if (!$fetched && !self::isSolicitedMessage($datarray["uri"], $datarray['uri-id'], $author, $body)) { + DBA::delete('item-uri', ['uri' => $datarray['uri']]); + return false; + } + if ($provider_display_name != "") { $datarray["app"] = $provider_display_name; } @@ -3054,6 +2795,12 @@ class Diaspora } self::fetchGuid($datarray); + + if (Item::isTooOld($datarray)) { + Logger::info('Status is too old', ['created' => $datarray['created'], 'uid' => $datarray['uid'], 'guid' => $datarray['guid']]); + return false; + } + $message_id = Item::insert($datarray); self::sendParticipation($contact, $datarray); @@ -3131,7 +2878,9 @@ class Diaspora $json = json_encode(["iv" => $b_iv, "key" => $b_aes_key]); $encrypted_key_bundle = ""; - openssl_public_encrypt($json, $encrypted_key_bundle, $pubkey); + if (!@openssl_public_encrypt($json, $encrypted_key_bundle, $pubkey)) { + return false; + } $json_object = json_encode( ["aes_key" => base64_encode($encrypted_key_bundle), @@ -3251,7 +3000,7 @@ class Diaspora // We always try to use the data from the fcontact table. // This is important for transmitting data to Friendica servers. if (!empty($contact['addr'])) { - $fcontact = self::personByHandle($contact['addr']); + $fcontact = FContact::getByURL($contact['addr']); if (!empty($fcontact)) { $dest_url = ($public_batch ? $fcontact["batch"] : $fcontact["notify"]); } @@ -3271,7 +3020,7 @@ class Diaspora if (!intval(DI::config()->get("system", "diaspora_test"))) { $content_type = (($public_batch) ? "application/magic-envelope+xml" : "application/json"); - $postResult = Network::post($dest_url."/", $envelope, ["Content-Type: ".$content_type]); + $postResult = DI::httpRequest()->post($dest_url . "/", $envelope, ["Content-Type: " . $content_type]); $return_code = $postResult->getReturnCode(); } else { Logger::log("test_mode"); @@ -3325,7 +3074,18 @@ class Diaspora $owner['uprvkey'] = $owner['prvkey']; } - $envelope = self::buildMessage($msg, $owner, $contact, $owner['uprvkey'], $contact['pubkey'], $public_batch); + // When sending content to Friendica contacts using the Diaspora protocol + // we have to fetch the public key from the fcontact. + // This is due to the fact that legacy DFRN had unique keys for every contact. + $pubkey = $contact['pubkey']; + if (!empty($contact['addr'])) { + $fcontact = FContact::getByURL($contact['addr']); + if (!empty($fcontact)) { + $pubkey = $fcontact['pubkey']; + } + } + + $envelope = self::buildMessage($msg, $owner, $contact, $owner['uprvkey'], $pubkey, $public_batch); $return_code = self::transmit($owner, $contact, $envelope, $public_batch, $guid); @@ -3406,7 +3166,7 @@ class Diaspora "profile" => $profile, "signature" => $signature]; - Logger::log("Send account migration ".print_r($message, true), Logger::DEBUG); + Logger::info('Send account migration', ['msg' => $message]); return self::buildAndTransmit($owner, $contact, "account_migration", $message); } @@ -3450,7 +3210,7 @@ class Diaspora "following" => "true", "sharing" => "true"]; - Logger::log("Send share ".print_r($message, true), Logger::DEBUG); + Logger::info('Send share', ['msg' => $message]); return self::buildAndTransmit($owner, $contact, "contact", $message); } @@ -3471,7 +3231,7 @@ class Diaspora "following" => "false", "sharing" => "false"]; - Logger::log("Send unshare ".print_r($message, true), Logger::DEBUG); + Logger::info('Send unshare', ['msg' => $message]); return self::buildAndTransmit($owner, $contact, "contact", $message); } @@ -3665,9 +3425,8 @@ class Diaspora } if ($item['author-link'] != $item['owner-link']) { - require_once 'mod/share.php'; - $body = share_header($item['author-name'], $item['author-link'], $item['author-avatar'], - "", $item['created'], $item['plink']) . $body . '[/share]'; + $body = BBCode::getShareOpeningTag($item['author-name'], $item['author-link'], $item['author-avatar'], + $item['plink'], $item['created']) . $body . '[/share]'; } // convert to markdown @@ -3678,13 +3437,11 @@ class Diaspora $body = "### ".html_entity_decode($title)."\n\n".$body; } - if ($item["attach"]) { - $cnt = preg_match_all('/href=\"(.*?)\"(.*?)title=\"(.*?)\"/ism', $item["attach"], $matches, PREG_SET_ORDER); - if ($cnt) { - $body .= "\n".DI::l10n()->t("Attachments:")."\n"; - foreach ($matches as $mtch) { - $body .= "[".$mtch[3]."](".$mtch[1].")\n"; - } + $attachments = Post\Media::getByURIId($item['uri-id'], [Post\Media::DOCUMENT, Post\Media::TORRENT, Post\Media::UNKNOWN]); + if (!empty($attachments)) { + $body .= "\n".DI::l10n()->t("Attachments:")."\n"; + foreach ($attachments as $attachment) { + $body .= "[" . $attachment['description'] . "](" . $attachment['url'] . ")\n"; } } @@ -3741,7 +3498,7 @@ class Diaspora private static function prependParentAuthorMention($body, $profile_url) { - $profile = Contact::getDetailsByURL($profile_url); + $profile = Contact::getByURL($profile_url, false, ['addr', 'name', 'contact-type']); if (!empty($profile['addr']) && $profile['contact-type'] != Contact::TYPE_COMMUNITY && !strstr($body, $profile['addr']) @@ -3783,12 +3540,12 @@ class Diaspora */ private static function constructLike(array $item, array $owner) { - $parent = Item::selectFirst(['guid', 'uri', 'parent-uri'], ['uri' => $item["thr-parent"]]); + $parent = Item::selectFirst(['guid', 'uri', 'thr-parent'], ['uri' => $item["thr-parent"]]); if (!DBA::isResult($parent)) { return false; } - $target_type = ($parent["uri"] === $parent["parent-uri"] ? "Post" : "Comment"); + $target_type = ($parent["uri"] === $parent["thr-parent"] ? "Post" : "Comment"); $positive = null; if ($item['verb'] === Activity::LIKE) { $positive = "true"; @@ -3815,7 +3572,7 @@ class Diaspora */ private static function constructAttend(array $item, array $owner) { - $parent = Item::selectFirst(['guid', 'uri', 'parent-uri'], ['uri' => $item["thr-parent"]]); + $parent = Item::selectFirst(['guid'], ['uri' => $item['thr-parent']]); if (!DBA::isResult($parent)) { return false; } @@ -3860,9 +3617,9 @@ class Diaspora return $result; } - $toplevel_item = Item::selectFirst(['guid', 'author-id', 'author-link'], ['id' => $item["parent"], 'parent' => $item["parent"]]); + $toplevel_item = Item::selectFirst(['guid', 'author-id', 'author-link'], ['id' => $item['parent'], 'parent' => $item['parent']]); if (!DBA::isResult($toplevel_item)) { - Logger::error('Missing parent conversation item', ['parent' => $item["parent"]]); + Logger::error('Missing parent conversation item', ['parent' => $item['parent']]); return false; } @@ -4033,7 +3790,7 @@ class Diaspora $message["parent_author_signature"] = self::signature($owner, $message); - Logger::log("Relayed data ".print_r($message, true), Logger::DEBUG); + Logger::info('Relayed data', ['msg' => $message]); return self::buildAndTransmit($owner, $contact, $type, $message, $public_batch, $item["guid"]); } @@ -4056,7 +3813,7 @@ class Diaspora $msg_type = "retraction"; - if ($item['id'] == $item['parent']) { + if ($item['gravity'] == GRAVITY_PARENT) { $target_type = "Post"; } elseif (in_array($item["verb"], [Activity::LIKE, Activity::DISLIKE])) { $target_type = "Like"; @@ -4068,7 +3825,7 @@ class Diaspora "target_guid" => $item['guid'], "target_type" => $target_type]; - Logger::log("Got message ".print_r($message, true), Logger::DEBUG); + Logger::info('Got message', ['msg' => $message]); return self::buildAndTransmit($owner, $contact, $msg_type, $message, $public_batch, $item["guid"]); } @@ -4309,7 +4066,7 @@ class Diaspora { $owner = User::getOwnerDataById($uid); if (empty($owner)) { - Logger::log("No owner post, so not storing signature", Logger::DEBUG); + Logger::info('No owner post, so not storing signature'); return false; } @@ -4340,14 +4097,11 @@ class Diaspora { $owner = User::getOwnerDataById($uid); if (empty($owner)) { - Logger::log("No owner post, so not storing signature", Logger::DEBUG); + Logger::info('No owner post, so not storing signature'); return false; } - // This is a workaround for the behaviour of the "insert" function, see mod/item.php - $item['thr-parent'] = $item['parent-uri']; - - $parent = Item::selectFirst(['parent-uri'], ['uri' => $item['parent-uri']]); + $parent = Item::selectFirst(['parent-uri'], ['uri' => $item['thr-parent']]); if (!DBA::isResult($parent)) { return; } diff --git a/src/Protocol/Feed.php b/src/Protocol/Feed.php index baf439dc02..41b63def58 100644 --- a/src/Protocol/Feed.php +++ b/src/Protocol/Feed.php @@ -23,21 +23,32 @@ namespace Friendica\Protocol; use DOMDocument; use DOMXPath; +use Friendica\Content\PageInfo; +use Friendica\Content\Text\BBCode; use Friendica\Content\Text\HTML; +use Friendica\Core\Cache\Duration; use Friendica\Core\Logger; use Friendica\Core\Protocol; +use Friendica\Core\Worker; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Model\Contact; +use Friendica\Model\Conversation; use Friendica\Model\Item; +use Friendica\Model\Post; use Friendica\Model\Tag; +use Friendica\Model\User; +use Friendica\Util\DateTimeFormat; use Friendica\Util\Network; use Friendica\Util\ParseUrl; +use Friendica\Util\Strings; use Friendica\Util\XML; /** * This class contain functions to import feeds (RSS/RDF/Atom) */ -class Feed { +class Feed +{ /** * Read a RSS/RDF/Atom feed and create an item entry for it * @@ -233,6 +244,7 @@ class Feed { } $items = []; + $creation_dates = []; // Limit the number of items that are about to be fetched $total_items = ($entries->length - 1); @@ -241,6 +253,8 @@ class Feed { $total_items = $max_items; } + $postings = []; + // Importing older entries first for ($i = $total_items; $i >= 0; --$i) { $entry = $entries->item($i); @@ -277,21 +291,13 @@ class Feed { $item["uri"] = $item["plink"]; } + // Add the base path if missing + $item["uri"] = Network::addBasePath($item["uri"], $basepath); + $item["plink"] = Network::addBasePath($item["plink"], $basepath); + $orig_plink = $item["plink"]; - $item["plink"] = Network::finalUrl($item["plink"]); - - $item["parent-uri"] = $item["uri"]; - - if (!$dryRun) { - $condition = ["`uid` = ? AND `uri` = ? AND `network` IN (?, ?)", - $importer["uid"], $item["uri"], Protocol::FEED, Protocol::DFRN]; - $previous = Item::selectFirst(['id'], $condition); - if (DBA::isResult($previous)) { - Logger::info("Item with uri " . $item["uri"] . " for user " . $importer["uid"] . " already existed under id " . $previous["id"]); - continue; - } - } + $item["plink"] = DI::httpRequest()->finalUrl($item["plink"]); $item["title"] = XML::getFirstNodeValue($xpath, 'atom:title/text()', $entry); @@ -332,6 +338,19 @@ class Feed { $item["edited"] = $updated; } + if (!$dryRun) { + $condition = ["`uid` = ? AND `uri` = ? AND `network` IN (?, ?)", + $importer["uid"], $item["uri"], Protocol::FEED, Protocol::DFRN]; + $previous = Item::selectFirst(['id', 'created'], $condition); + if (DBA::isResult($previous)) { + // Use the creation date when the post had been stored. It can happen this date changes in the feed. + $creation_dates[] = $previous['created']; + Logger::info("Item with uri " . $item["uri"] . " for user " . $importer["uid"] . " already existed under id " . $previous["id"]); + continue; + } + $creation_dates[] = DateTimeFormat::utc($item['created']); + } + $creator = XML::getFirstNodeValue($xpath, 'author/text()', $entry); if (empty($creator)) { @@ -361,28 +380,22 @@ class Feed { $enclosures = $xpath->query("enclosure|atom:link[@rel='enclosure']", $entry); foreach ($enclosures AS $enclosure) { $href = ""; - $length = ""; - $type = ""; + $length = null; + $type = null; foreach ($enclosure->attributes AS $attribute) { if (in_array($attribute->name, ["url", "href"])) { $href = $attribute->textContent; } elseif ($attribute->name == "length") { - $length = $attribute->textContent; + $length = (int)$attribute->textContent; } elseif ($attribute->name == "type") { $type = $attribute->textContent; } } - if (!empty($item["attach"])) { - $item["attach"] .= ','; - } else { - $item["attach"] = ''; + if (!empty($href)) { + $attachments[] = ['type' => Post\Media::DOCUMENT, 'url' => $href, 'mimetype' => $type, 'size' => $length]; } - - $attachments[] = ["link" => $href, "type" => $type, "length" => $length]; - - $item["attach"] .= '[attach]href="' . $href . '" length="' . $length . '" type="' . $type . '"[/attach]'; } $taglist = []; @@ -419,17 +432,31 @@ class Feed { } $item["body"] = HTML::toBBCode($body, $basepath); + // Remove tracking pixels + $item["body"] = preg_replace("/\[img=1x1\]([^\[\]]*)\[\/img\]/Usi", '', $item["body"]); + if (($item["body"] == '') && ($item["title"] != '')) { $item["body"] = $item["title"]; $item["title"] = ''; } + if ($dryRun) { + $items[] = $item; + break; + } elseif (!Item::isValid($item)) { + Logger::info('Feed item is invalid', ['created' => $item['created'], 'uid' => $item['uid'], 'uri' => $item['uri']]); + continue; + } elseif (Item::isTooOld($item)) { + Logger::info('Feed is too old', ['created' => $item['created'], 'uid' => $item['uid'], 'uri' => $item['uri']]); + continue; + } + $preview = ''; if (!empty($contact["fetch_further_information"]) && ($contact["fetch_further_information"] < 3)) { // Handle enclosures and treat them as preview picture foreach ($attachments AS $attachment) { - if ($attachment["type"] == "image/jpeg") { - $preview = $attachment["link"]; + if ($attachment["mimetype"] == "image/jpeg") { + $preview = $attachment["url"]; } } @@ -450,6 +477,9 @@ class Feed { $replace = true; } + $saved_body = $item["body"]; + $saved_title = $item["title"]; + if ($replace) { $item["body"] = trim($item["title"]); } @@ -466,12 +496,29 @@ class Feed { } } - // We always strip the title since it will be added in the page information - $item["title"] = ""; - $item["body"] = $item["body"] . add_page_info($item["plink"], false, $preview, ($contact["fetch_further_information"] == 2), $contact["ffi_keyword_blacklist"]); - $taglist = get_page_keywords($item["plink"], $preview, ($contact["fetch_further_information"] == 2), $contact["ffi_keyword_blacklist"]); - $item["object-type"] = Activity\ObjectType::BOOKMARK; - unset($item["attach"]); + $data = PageInfo::queryUrl($item["plink"], false, $preview, ($contact["fetch_further_information"] == 2), $contact["ffi_keyword_denylist"] ?? ''); + + if (!empty($data)) { + // Take the data that was provided by the feed if the query is empty + if (($data['type'] == 'link') && empty($data['title']) && empty($data['text'])) { + $data['title'] = $saved_title; + $item["body"] = $saved_body; + } + + $data_text = strip_tags(trim($data['text'] ?? '')); + $item_body = strip_tags(trim($item['body'] ?? '')); + + if (!empty($data_text) && (($data_text == $item_body) || strstr($item_body, $data_text))) { + $data['text'] = ''; + } + + // We always strip the title since it will be added in the page information + $item["title"] = ""; + $item["body"] = $item["body"] . "\n" . PageInfo::getFooterFromData($data, false); + $taglist = $contact["fetch_further_information"] == 2 ? PageInfo::getTagsFromUrl($item["plink"], $preview, $contact["ffi_keyword_denylist"] ?? '') : []; + $item["object-type"] = Activity\ObjectType::BOOKMARK; + $attachments = []; + } } else { if (!empty($summary)) { $item["body"] = '[abstract]' . HTML::toBBCode($summary, $basepath) . "[/abstract]\n" . $item["body"]; @@ -479,7 +526,7 @@ class Feed { if (!empty($contact["fetch_further_information"]) && ($contact["fetch_further_information"] == 3)) { if (empty($taglist)) { - $taglist = get_page_keywords($item["plink"], $preview, true, $contact["ffi_keyword_blacklist"]); + $taglist = PageInfo::getTagsFromUrl($item["plink"], $preview, $contact["ffi_keyword_denylist"] ?? ''); } $item["body"] .= "\n" . self::tagToString($taglist); } else { @@ -492,41 +539,241 @@ class Feed { } } - if ($dryRun) { - $items[] = $item; - break; + Logger::info('Stored feed', ['item' => $item]); + + $notify = Item::isRemoteSelf($contact, $item); + + // Distributed items should have a well formatted URI. + // Additionally we have to avoid conflicts with identical URI between imported feeds and these items. + if ($notify) { + $item['guid'] = Item::guidFromUri($orig_plink, DI::baseUrl()->getHostname()); + $item['uri'] = Item::newURI($item['uid'], $item['guid']); + unset($item['thr-parent']); + unset($item['parent-uri']); + + // Set the delivery priority for "remote self" to "medium" + $notify = PRIORITY_MEDIUM; + } + + $condition = ['uid' => $item['uid'], 'uri' => $item['uri']]; + if (!Item::exists($condition) && !Post\Delayed::exists($item["uri"], $item['uid'])) { + if (!$notify) { + Post\Delayed::publish($item, $notify, $taglist, $attachments); + } else { + $postings[] = ['item' => $item, 'notify' => $notify, + 'taglist' => $taglist, 'attachments' => $attachments]; + } } else { - Logger::info("Stored feed: " . print_r($item, true)); - - $notify = Item::isRemoteSelf($contact, $item); - - // Distributed items should have a well formatted URI. - // Additionally we have to avoid conflicts with identical URI between imported feeds and these items. - if ($notify) { - $item['guid'] = Item::guidFromUri($orig_plink, DI::baseUrl()->getHostname()); - unset($item['uri']); - unset($item['parent-uri']); - - // Set the delivery priority for "remote self" to "medium" - $notify = PRIORITY_MEDIUM; - } - - $id = Item::insert($item, false, $notify); - - Logger::info("Feed for contact " . $contact["url"] . " stored under id " . $id); - - if (!empty($id) && !empty($taglist)) { - $feeditem = Item::selectFirst(['uri-id'], ['id' => $id]); - foreach ($taglist as $tag) { - Tag::store($feeditem['uri-id'], Tag::HASHTAG, $tag); - } - } + Logger::info('Post already created or exists in the delayed posts queue', ['uid' => $item['uid'], 'uri' => $item["uri"]]); } } + if (!empty($postings)) { + $min_posting = DI::config()->get('system', 'minimum_posting_interval', 0); + $total = count($postings); + if ($total > 1) { + // Posts shouldn't be delayed more than a day + $interval = min(1440, self::getPollInterval($contact)); + $delay = max(round(($interval * 60) / $total), 60 * $min_posting); + Logger::info('Got posting delay', ['delay' => $delay, 'interval' => $interval, 'items' => $total, 'cid' => $contact['id'], 'url' => $contact['url']]); + } else { + $delay = 0; + } + + $post_delay = 0; + + foreach ($postings as $posting) { + if ($delay > 0) { + $publish_time = time() + $post_delay; + $post_delay += $delay; + } else { + $publish_time = time(); + } + + $last_publish = DI::pConfig()->get($posting['item']['uid'], 'system', 'last_publish', 0, true); + $next_publish = max($last_publish + (60 * $min_posting), time()); + if ($publish_time < $next_publish) { + $publish_time = $next_publish; + } + $publish_at = date(DateTimeFormat::MYSQL, $publish_time); + + Post\Delayed::add($posting['item']['uri'], $posting['item'], $posting['notify'], false, $publish_at, $posting['taglist'], $posting['attachments']); + } + } + + if (!$dryRun && DI::config()->get('system', 'adjust_poll_frequency')) { + self::adjustPollFrequency($contact, $creation_dates); + } + return ["header" => $author, "items" => $items]; } + /** + * Automatically adjust the poll frequency according to the post frequency + * + * @param array $contact + * @param array $creation_dates + * @return void + */ + private static function adjustPollFrequency(array $contact, array $creation_dates) + { + if ($contact['network'] != Protocol::FEED) { + Logger::info('Contact is no feed, skip.', ['id' => $contact['id'], 'uid' => $contact['uid'], 'url' => $contact['url'], 'network' => $contact['network']]); + return; + } + + if (!empty($creation_dates)) { + // Count the post frequency and the earliest and latest post date + $frequency = []; + $oldest = time(); + $newest = 0; + $oldest_date = $newest_date = ''; + + foreach ($creation_dates as $date) { + $timestamp = strtotime($date); + $day = intdiv($timestamp, 86400); + $hour = $timestamp % 86400; + + // Only have a look at values from the last seven days + if (((time() / 86400) - $day) < 7) { + if (empty($frequency[$day])) { + $frequency[$day] = ['count' => 1, 'low' => $hour, 'high' => $hour]; + } else { + ++$frequency[$day]['count']; + if ($frequency[$day]['low'] > $hour) { + $frequency[$day]['low'] = $hour; + } + if ($frequency[$day]['high'] < $hour) { + $frequency[$day]['high'] = $hour; + } + } + } + if ($oldest > $day) { + $oldest = $day; + $oldest_date = $date; + } + + if ($newest < $day) { + $newest = $day; + $newest_date = $date; + } + } + + if (count($creation_dates) == 1) { + Logger::info('Feed had posted a single time, switching to daily polling', ['newest' => $newest_date, 'id' => $contact['id'], 'uid' => $contact['uid'], 'url' => $contact['url']]); + $priority = 8; // Poll once a day + } + + if (empty($priority) && (((time() / 86400) - $newest) > 730)) { + Logger::info('Feed had not posted for two years, switching to monthly polling', ['newest' => $newest_date, 'id' => $contact['id'], 'uid' => $contact['uid'], 'url' => $contact['url']]); + $priority = 10; // Poll every month + } + + if (empty($priority) && (((time() / 86400) - $newest) > 365)) { + Logger::info('Feed had not posted for a year, switching to weekly polling', ['newest' => $newest_date, 'id' => $contact['id'], 'uid' => $contact['uid'], 'url' => $contact['url']]); + $priority = 9; // Poll every week + } + + if (empty($priority) && empty($frequency)) { + Logger::info('Feed had not posted for at least a week, switching to daily polling', ['newest' => $newest_date, 'id' => $contact['id'], 'uid' => $contact['uid'], 'url' => $contact['url']]); + $priority = 8; // Poll once a day + } + + if (empty($priority)) { + // Calculate the highest "posts per day" value + $max = 0; + foreach ($frequency as $entry) { + if (($entry['count'] == 1) || ($entry['high'] == $entry['low'])) { + continue; + } + + // We take the earliest and latest post day and interpolate the number of post per day + // that would had been created with this post frequency + + // Assume at least four hours between oldest and newest post per day - should be okay for news outlets + $duration = max($entry['high'] - $entry['low'], 14400); + $ppd = (86400 / $duration) * $entry['count']; + if ($ppd > $max) { + $max = $ppd; + } + } + if ($max > 48) { + $priority = 1; // Poll every quarter hour + } elseif ($max > 24) { + $priority = 2; // Poll half an hour + } elseif ($max > 12) { + $priority = 3; // Poll hourly + } elseif ($max > 8) { + $priority = 4; // Poll every two hours + } elseif ($max > 4) { + $priority = 5; // Poll every three hours + } elseif ($max > 2) { + $priority = 6; // Poll every six hours + } else { + $priority = 7; // Poll twice a day + } + Logger::info('Calculated priority by the posts per day', ['priority' => $priority, 'max' => round($max, 2), 'id' => $contact['id'], 'uid' => $contact['uid'], 'url' => $contact['url']]); + } + } else { + Logger::info('No posts, switching to daily polling', ['id' => $contact['id'], 'uid' => $contact['uid'], 'url' => $contact['url']]); + $priority = 8; // Poll once a day + } + + if ($contact['rating'] != $priority) { + Logger::notice('Adjusting priority', ['old' => $contact['rating'], 'new' => $priority, 'id' => $contact['id'], 'uid' => $contact['uid'], 'url' => $contact['url']]); + DBA::update('contact', ['rating' => $priority], ['id' => $contact['id']]); + } + } + + /** + * Get the poll interval for the given contact array + * + * @param array $contact + * @return int Poll interval in minutes + */ + public static function getPollInterval(array $contact) + { + if (in_array($contact['network'], [Protocol::MAIL, Protocol::FEED])) { + $ratings = [0, 3, 7, 8, 9, 10]; + if (DI::config()->get('system', 'adjust_poll_frequency') && ($contact['network'] == Protocol::FEED)) { + $rating = $contact['rating']; + } elseif (array_key_exists($contact['priority'], $ratings)) { + $rating = $ratings[$contact['priority']]; + } else { + $rating = -1; + } + } else { + // Check once a week per default for all other networks + $rating = 9; + } + + // Friendica and OStatus are checked once a day + if (in_array($contact['network'], [Protocol::DFRN, Protocol::OSTATUS])) { + $rating = 8; + } + + // Check archived contacts or contacts with unsupported protocols once a month + if ($contact['archive'] || in_array($contact['network'], [Protocol::ZOT, Protocol::PHANTOM])) { + $rating = 10; + } + + if ($rating < 0) { + return 0; + } + /* + * Based on $contact['priority'], should we poll this site now? Or later? + */ + + $min_poll_interval = max(1, DI::config()->get('system', 'min_poll_interval')); + + $poll_intervals = [$min_poll_interval, 15, 30, 60, 120, 180, 360, 720 ,1440, 10080, 43200]; + + //$poll_intervals = [$min_poll_interval . ' minute', '15 minute', '30 minute', + // '1 hour', '2 hour', '3 hour', '6 hour', '12 hour' ,'1 day', '1 week', '1 month']; + + return $poll_intervals[$rating]; + } + /** * Convert a tag array to a tag string * @@ -541,7 +788,7 @@ class Feed { if ($tagstr != "") { $tagstr .= ", "; } - + $tagstr .= "#[url=" . DI::baseUrl() . "/search?tag=" . urlencode($tag) . "]" . $tag . "[/url]"; } @@ -573,4 +820,389 @@ class Feed { } return ($title == $body); } + + /** + * Creates the Atom feed for a given nickname + * + * Supported filters: + * - activity (default): all the public posts + * - posts: all the public top-level posts + * - comments: all the public replies + * + * Updates the provided last_update parameter if the result comes from the + * cache or it is empty + * + * @param string $owner_nick Nickname of the feed owner + * @param string $last_update Date of the last update + * @param integer $max_items Number of maximum items to fetch + * @param string $filter Feed items filter (activity, posts or comments) + * @param boolean $nocache Wether to bypass caching + * + * @return string Atom feed + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public static function atom($owner_nick, $last_update, $max_items = 300, $filter = 'activity', $nocache = false) + { + $stamp = microtime(true); + + $owner = User::getOwnerDataByNick($owner_nick); + if (!$owner) { + return; + } + + $cachekey = "feed:feed:" . $owner_nick . ":" . $filter . ":" . $last_update; + + $previous_created = $last_update; + + // Don't cache when the last item was posted less then 15 minutes ago (Cache duration) + if ((time() - strtotime($owner['last-item'])) < 15*60) { + $result = DI::cache()->get($cachekey); + if (!$nocache && !is_null($result)) { + Logger::info('Cached feed duration', ['seconds' => number_format(microtime(true) - $stamp, 3), 'nick' => $owner_nick, 'filter' => $filter, 'created' => $previous_created]); + return $result['feed']; + } + } + + $check_date = empty($last_update) ? '' : DateTimeFormat::utc($last_update); + $authorid = Contact::getIdForURL($owner["url"]); + + $condition = ["`uid` = ? AND `received` > ? AND NOT `deleted` AND `gravity` IN (?, ?) + AND `private` != ? AND `visible` AND `wall` AND `parent-network` IN (?, ?, ?, ?)", + $owner["uid"], $check_date, GRAVITY_PARENT, GRAVITY_COMMENT, + Item::PRIVATE, Protocol::ACTIVITYPUB, + Protocol::OSTATUS, Protocol::DFRN, Protocol::DIASPORA]; + + if ($filter === 'comments') { + $condition[0] .= " AND `object-type` = ? "; + $condition[] = Activity\ObjectType::COMMENT; + } + + if ($owner['account-type'] != User::ACCOUNT_TYPE_COMMUNITY) { + $condition[0] .= " AND `contact-id` = ? AND `author-id` = ?"; + $condition[] = $owner["id"]; + $condition[] = $authorid; + } + + $params = ['order' => ['received' => true], 'limit' => $max_items]; + + if ($filter === 'posts') { + $ret = Item::selectThread([], $condition, $params); + } else { + $ret = Item::select([], $condition, $params); + } + + $items = Item::inArray($ret); + + $doc = new DOMDocument('1.0', 'utf-8'); + $doc->formatOutput = true; + + $root = self::addHeader($doc, $owner, $filter); + + foreach ($items as $item) { + $entry = self::entry($doc, $item, $owner); + $root->appendChild($entry); + + if ($last_update < $item['created']) { + $last_update = $item['created']; + } + } + + $feeddata = trim($doc->saveXML()); + + $msg = ['feed' => $feeddata, 'last_update' => $last_update]; + DI::cache()->set($cachekey, $msg, Duration::QUARTER_HOUR); + + Logger::info('Feed duration', ['seconds' => number_format(microtime(true) - $stamp, 3), 'nick' => $owner_nick, 'filter' => $filter, 'created' => $previous_created]); + + return $feeddata; + } + + /** + * Adds the header elements to the XML document + * + * @param DOMDocument $doc XML document + * @param array $owner Contact data of the poster + * @param string $filter The related feed filter (activity, posts or comments) + * + * @return object header root element + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + private static function addHeader(DOMDocument $doc, array $owner, $filter) + { + $root = $doc->createElementNS(ActivityNamespace::ATOM1, 'feed'); + $doc->appendChild($root); + + $title = ''; + $selfUri = '/feed/' . $owner["nick"] . '/'; + switch ($filter) { + case 'activity': + $title = DI::l10n()->t('%s\'s timeline', $owner['name']); + $selfUri .= $filter; + break; + case 'posts': + $title = DI::l10n()->t('%s\'s posts', $owner['name']); + break; + case 'comments': + $title = DI::l10n()->t('%s\'s comments', $owner['name']); + $selfUri .= $filter; + break; + } + + $attributes = ["uri" => "https://friendi.ca", "version" => FRIENDICA_VERSION . "-" . DB_UPDATE_VERSION]; + XML::addElement($doc, $root, "generator", FRIENDICA_PLATFORM, $attributes); + XML::addElement($doc, $root, "id", DI::baseUrl() . "/profile/" . $owner["nick"]); + XML::addElement($doc, $root, "title", $title); + XML::addElement($doc, $root, "subtitle", sprintf("Updates from %s on %s", $owner["name"], DI::config()->get('config', 'sitename'))); + XML::addElement($doc, $root, "logo", $owner["photo"]); + XML::addElement($doc, $root, "updated", DateTimeFormat::utcNow(DateTimeFormat::ATOM)); + + $author = self::addAuthor($doc, $owner); + $root->appendChild($author); + + $attributes = ["href" => $owner["url"], "rel" => "alternate", "type" => "text/html"]; + XML::addElement($doc, $root, "link", "", $attributes); + + OStatus::hublinks($doc, $root, $owner["nick"]); + + $attributes = ["href" => DI::baseUrl() . $selfUri, "rel" => "self", "type" => "application/atom+xml"]; + XML::addElement($doc, $root, "link", "", $attributes); + + return $root; + } + + /** + * Adds the author element to the XML document + * + * @param DOMDocument $doc XML document + * @param array $owner Contact data of the poster + * + * @return \DOMElement author element + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + private static function addAuthor(DOMDocument $doc, array $owner) + { + $author = $doc->createElement("author"); + XML::addElement($doc, $author, "uri", $owner["url"]); + XML::addElement($doc, $author, "name", $owner["nick"]); + XML::addElement($doc, $author, "email", $owner["addr"]); + + return $author; + } + + /** + * Adds an entry element to the XML document + * + * @param DOMDocument $doc XML document + * @param array $item Data of the item that is to be posted + * @param array $owner Contact data of the poster + * @param bool $toplevel optional default false + * + * @return \DOMElement Entry element + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + private static function entry(DOMDocument $doc, array $item, array $owner) + { + $xml = null; + + $repeated_guid = OStatus::getResharedGuid($item); + if ($repeated_guid != "") { + $xml = self::reshareEntry($doc, $item, $owner, $repeated_guid); + } + + if ($xml) { + return $xml; + } + + return self::noteEntry($doc, $item, $owner); + } + + /** + * Adds an entry element with reshared content + * + * @param DOMDocument $doc XML document + * @param array $item Data of the item that is to be posted + * @param array $owner Contact data of the poster + * @param string $repeated_guid guid + * @param bool $toplevel Is it for en entry element (false) or a feed entry (true)? + * + * @return bool Entry element + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + private static function reshareEntry(DOMDocument $doc, array $item, array $owner, $repeated_guid) + { + if (($item['gravity'] != GRAVITY_PARENT) && (Strings::normaliseLink($item["author-link"]) != Strings::normaliseLink($owner["url"]))) { + Logger::info('Feed entry author does not match feed owner', ['owner' => $owner["url"], 'author' => $item["author-link"]]); + } + + $entry = OStatus::entryHeader($doc, $owner, $item, false); + + $condition = ['uid' => $owner["uid"], 'guid' => $repeated_guid, 'private' => [Item::PUBLIC, Item::UNLISTED], + 'network' => Protocol::FEDERATED]; + $repeated_item = Item::selectFirst([], $condition); + if (!DBA::isResult($repeated_item)) { + return false; + } + + self::entryContent($doc, $entry, $item, self::getTitle($repeated_item), Activity::SHARE, false); + + self::entryFooter($doc, $entry, $item, $owner); + + return $entry; + } + + /** + * Adds a regular entry element + * + * @param DOMDocument $doc XML document + * @param array $item Data of the item that is to be posted + * @param array $owner Contact data of the poster + * @param bool $toplevel Is it for en entry element (false) or a feed entry (true)? + * + * @return \DOMElement Entry element + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + private static function noteEntry(DOMDocument $doc, array $item, array $owner) + { + if (($item['gravity'] != GRAVITY_PARENT) && (Strings::normaliseLink($item["author-link"]) != Strings::normaliseLink($owner["url"]))) { + Logger::info('Feed entry author does not match feed owner', ['owner' => $owner["url"], 'author' => $item["author-link"]]); + } + + $entry = OStatus::entryHeader($doc, $owner, $item, false); + + self::entryContent($doc, $entry, $item, self::getTitle($item), '', true); + + self::entryFooter($doc, $entry, $item, $owner); + + return $entry; + } + + /** + * Adds elements to the XML document + * + * @param DOMDocument $doc XML document + * @param \DOMElement $entry Entry element where the content is added + * @param array $item Data of the item that is to be posted + * @param array $owner Contact data of the poster + * @param string $title Title for the post + * @param string $verb The activity verb + * @param bool $complete Add the "status_net" element? + * @param bool $feed_mode Behave like a regular feed for users if true + * @return void + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + private static function entryContent(DOMDocument $doc, \DOMElement $entry, array $item, $title, $verb = "", $complete = true) + { + if ($verb == "") { + $verb = OStatus::constructVerb($item); + } + + XML::addElement($doc, $entry, "id", $item["uri"]); + XML::addElement($doc, $entry, "title", html_entity_decode($title, ENT_QUOTES, 'UTF-8')); + + $body = OStatus::formatPicturePost($item['body']); + + $body = BBCode::convert($body, false, BBCode::OSTATUS); + + XML::addElement($doc, $entry, "content", $body, ["type" => "html"]); + + XML::addElement($doc, $entry, "link", "", ["rel" => "alternate", "type" => "text/html", + "href" => DI::baseUrl()."/display/".$item["guid"]] + ); + + XML::addElement($doc, $entry, "published", DateTimeFormat::utc($item["created"]."+00:00", DateTimeFormat::ATOM)); + XML::addElement($doc, $entry, "updated", DateTimeFormat::utc($item["edited"]."+00:00", DateTimeFormat::ATOM)); + } + + /** + * Adds the elements at the foot of an entry to the XML document + * + * @param DOMDocument $doc XML document + * @param object $entry The entry element where the elements are added + * @param array $item Data of the item that is to be posted + * @param array $owner Contact data of the poster + * @param bool $complete default true + * @return void + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + private static function entryFooter(DOMDocument $doc, $entry, array $item, array $owner) + { + $mentioned = []; + + if ($item['gravity'] != GRAVITY_PARENT) { + $parent = Item::selectFirst(['guid', 'author-link', 'owner-link'], ['id' => $item['parent']]); + + $thrparent = Item::selectFirst(['guid', 'author-link', 'owner-link', 'plink'], ['uid' => $owner["uid"], 'uri' => $item['thr-parent']]); + + if (DBA::isResult($thrparent)) { + $mentioned[$thrparent["author-link"]] = $thrparent["author-link"]; + $mentioned[$thrparent["owner-link"]] = $thrparent["owner-link"]; + $parent_plink = $thrparent["plink"]; + } else { + $mentioned[$parent["author-link"]] = $parent["author-link"]; + $mentioned[$parent["owner-link"]] = $parent["owner-link"]; + $parent_plink = DI::baseUrl()."/display/".$parent["guid"]; + } + + $attributes = [ + "ref" => $item['thr-parent'], + "href" => $parent_plink]; + XML::addElement($doc, $entry, "thr:in-reply-to", "", $attributes); + + $attributes = [ + "rel" => "related", + "href" => $parent_plink]; + XML::addElement($doc, $entry, "link", "", $attributes); + } + + // uri-id isn't present for follow entry pseudo-items + $tags = Tag::getByURIId($item['uri-id'] ?? 0); + foreach ($tags as $tag) { + $mentioned[$tag['url']] = $tag['url']; + } + + foreach ($tags as $tag) { + if ($tag['type'] == Tag::HASHTAG) { + XML::addElement($doc, $entry, "category", "", ["term" => $tag['name']]); + } + } + + OStatus::getAttachment($doc, $entry, $item); + } + + /** + * Fetch or create title for feed entry + * + * @param array $item + * @return string title + */ + private static function getTitle(array $item) + { + if ($item['title'] != '') { + return BBCode::convert($item['title'], false, BBCode::OSTATUS); + } + + // Fetch information about the post + $siteinfo = BBCode::getAttachedData($item["body"]); + if (isset($siteinfo["title"])) { + return $siteinfo["title"]; + } + + // If no bookmark is found then take the first line + // Remove the share element before fetching the first line + $title = trim(preg_replace("/\[share.*?\](.*?)\[\/share\]/ism","\n$1\n",$item['body'])); + + $title = HTML::toPlaintext(BBCode::convert($title, false), 0, true)."\n"; + $pos = strpos($title, "\n"); + $trailer = ""; + if (($pos == 0) || ($pos > 100)) { + $pos = 100; + $trailer = "..."; + } + + return substr($title, 0, $pos) . $trailer; + } } diff --git a/src/Protocol/OStatus.php b/src/Protocol/OStatus.php index 07465c522a..f55fd4dadb 100644 --- a/src/Protocol/OStatus.php +++ b/src/Protocol/OStatus.php @@ -23,6 +23,7 @@ namespace Friendica\Protocol; use DOMDocument; use DOMXPath; +use Friendica\Content\PageInfo; use Friendica\Content\Text\BBCode; use Friendica\Content\Text\HTML; use Friendica\Core\Cache\Duration; @@ -33,22 +34,18 @@ use Friendica\DI; use Friendica\Model\APContact; use Friendica\Model\Contact; use Friendica\Model\Conversation; -use Friendica\Model\GContact; use Friendica\Model\Item; use Friendica\Model\ItemURI; +use Friendica\Model\Post; use Friendica\Model\Tag; use Friendica\Model\User; use Friendica\Network\Probe; use Friendica\Util\DateTimeFormat; use Friendica\Util\Images; -use Friendica\Util\Network; use Friendica\Util\Proxy as ProxyUtils; use Friendica\Util\Strings; use Friendica\Util\XML; -require_once 'mod/share.php'; -require_once 'include/api.php'; - /** * This class contain functions for the OStatus protocol */ @@ -216,11 +213,11 @@ class OStatus if (!empty($author["author-avatar"]) && ($author["author-avatar"] != $current['avatar'])) { Logger::log("Update profile picture for contact ".$contact["id"], Logger::DEBUG); - Contact::updateAvatar($author["author-avatar"], $importer["uid"], $contact["id"]); + Contact::updateAvatar($contact["id"], $author["author-avatar"]); } // Ensure that we are having this contact (with uid=0) - $cid = Contact::getIdForURL($aliaslink, 0, true); + $cid = Contact::getIdForURL($aliaslink); if ($cid) { $fields = ['url', 'nurl', 'name', 'nick', 'alias', 'about', 'location']; @@ -237,18 +234,9 @@ class OStatus // Update the avatar if (!empty($author["author-avatar"])) { - Contact::updateAvatar($author["author-avatar"], 0, $cid); + Contact::updateAvatar($cid, $author["author-avatar"]); } } - - $contact["generation"] = 2; - $contact["hide"] = false; // OStatus contacts are never hidden - if (!empty($author["author-avatar"])) { - $contact["photo"] = $author["author-avatar"]; - } - $gcid = GContact::update($contact); - - GContact::link($gcid, $contact["uid"], $contact["id"]); } elseif (empty($contact["network"]) || ($contact["network"] != Protocol::DFRN)) { $contact = []; } @@ -324,7 +312,7 @@ class OStatus */ public static function import($xml, array $importer, array &$contact, &$hub) { - self::process($xml, $importer, $contact, $hub); + self::process($xml, $importer, $contact, $hub, false, true, Conversation::PUSH); } /** @@ -341,7 +329,7 @@ class OStatus * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function process($xml, array $importer, array &$contact = null, &$hub, $stored = false, $initialize = true) + private static function process($xml, array $importer, array &$contact = null, &$hub, $stored = false, $initialize = true, $direction = Conversation::UNKNOWN) { if ($initialize) { self::$itemlist = []; @@ -409,6 +397,7 @@ class OStatus $header["protocol"] = Conversation::PARCEL_SALMON; $header["source"] = $xml2; + $header["direction"] = $direction; } elseif (!$initialize) { return false; } @@ -491,7 +480,7 @@ class OStatus Logger::log("Favorite ".$orig_uri." ".print_r($item, true)); $item["verb"] = Activity::LIKE; - $item["parent-uri"] = $orig_uri; + $item["thr-parent"] = $orig_uri; $item["gravity"] = GRAVITY_ACTIVITY; $item["object-type"] = Activity\ObjectType::NOTE; } @@ -504,7 +493,7 @@ class OStatus self::processPost($xpath, $entry, $item, $importer); if ($initialize && (count(self::$itemlist) > 0)) { - if (self::$itemlist[0]['uri'] == self::$itemlist[0]['parent-uri']) { + if (self::$itemlist[0]['uri'] == self::$itemlist[0]['thr-parent']) { // We will import it everytime, when it is started by our contacts $valid = Contact::isSharingByURL(self::$itemlist[0]['author-link'], self::$itemlist[0]['uid']); @@ -531,11 +520,7 @@ class OStatus } } } else { - // But we will only import complete threads - $valid = Item::exists(['uid' => $importer["uid"], 'uri' => self::$itemlist[0]['parent-uri']]); - if ($valid) { - Logger::log("Item with uri ".self::$itemlist[0]["uri"]." belongs to parent ".self::$itemlist[0]['parent-uri']." of user ".$importer["uid"].". It will be imported.", Logger::DEBUG); - } + $valid = true; } if ($valid) { @@ -554,15 +539,8 @@ class OStatus } elseif ($item['contact-id'] < 0) { Logger::log("Item with uri ".$item["uri"]." is from a blocked contact.", Logger::DEBUG); } else { - // We are having duplicated entries. Hopefully this solves it. - if (DI::lock()->acquire('ostatus_process_item_insert')) { - $ret = Item::insert($item); - DI::lock()->release('ostatus_process_item_insert'); - Logger::log("Item with uri ".$item["uri"]." for user ".$importer["uid"].' stored. Return value: '.$ret); - } else { - $ret = Item::insert($item); - Logger::log("We couldn't lock - but tried to store the item anyway. Return value is ".$ret); - } + $ret = Item::insert($item); + Logger::log("Item with uri ".$item["uri"]." for user ".$importer["uid"].' stored. Return value: '.$ret); } } } @@ -637,7 +615,7 @@ class OStatus if (is_object($inreplyto->item(0))) { foreach ($inreplyto->item(0)->attributes as $attributes) { if ($attributes->name == "ref") { - $item["parent-uri"] = $attributes->textContent; + $item["thr-parent"] = $attributes->textContent; } if ($attributes->name == "href") { $related = $attributes->textContent; @@ -697,7 +675,7 @@ class OStatus // Only add additional data when there is no picture in the post if (!strstr($item["body"], '[/img]')) { - $item["body"] = add_page_info_to_body($item["body"]); + $item["body"] = PageInfo::searchAndAppendToBody($item["body"]); } Tag::storeFromBody($item['uri-id'], $item['body']); @@ -718,16 +696,16 @@ class OStatus self::fetchConversation($item['conversation-href'], $item['conversation-uri']); } - if (isset($item["parent-uri"])) { - if (!Item::exists(['uid' => $importer["uid"], 'uri' => $item['parent-uri']])) { + if (isset($item["thr-parent"])) { + if (!Item::exists(['uid' => $importer["uid"], 'uri' => $item['thr-parent']])) { if ($related != '') { - self::fetchRelated($related, $item["parent-uri"], $importer); + self::fetchRelated($related, $item["thr-parent"], $importer); } } else { Logger::log('Reply with URI '.$item["uri"].' already existed for user '.$importer["uid"].'.', Logger::DEBUG); } } else { - $item["parent-uri"] = $item["uri"]; + $item["thr-parent"] = $item["uri"]; $item["gravity"] = GRAVITY_PARENT; } @@ -755,7 +733,7 @@ class OStatus self::$conv_list[$conversation] = true; - $curlResult = Network::curl($conversation, false, ['accept_content' => 'application/atom+xml, text/html']); + $curlResult = DI::httpRequest()->get($conversation, ['accept_content' => 'application/atom+xml, text/html']); if (!$curlResult->isSuccess()) { return; @@ -784,7 +762,7 @@ class OStatus } } if ($file != '') { - $conversation_atom = Network::curl($attribute['href']); + $conversation_atom = DI::httpRequest()->get($attribute['href']); if ($conversation_atom->isSuccess()) { $xml = $conversation_atom->getBody(); @@ -830,6 +808,7 @@ class OStatus $conv_data = []; $conv_data['protocol'] = Conversation::PARCEL_SPLIT_CONVERSATION; + $conv_data['direction'] = Conversation::PULL; $conv_data['network'] = Protocol::OSTATUS; $conv_data['uri'] = XML::getFirstNodeValue($xpath, 'atom:id/text()', $entry); @@ -870,12 +849,6 @@ class OStatus $conv_data['source'] = $doc2->saveXML(); - $condition = ['item-uri' => $conv_data['uri'],'protocol' => Conversation::PARCEL_FEED]; - if (DBA::exists('conversation', $condition)) { - Logger::log('Delete deprecated entry for URI '.$conv_data['uri'], Logger::DEBUG); - DBA::delete('conversation', ['item-uri' => $conv_data['uri']]); - } - Logger::log('Store conversation data for uri '.$conv_data['uri'], Logger::DEBUG); Conversation::insert($conv_data); } @@ -895,13 +868,15 @@ class OStatus */ private static function fetchSelf($self, array &$item) { - $condition = ['`item-uri` = ? AND `protocol` IN (?, ?)', $self, Conversation::PARCEL_DFRN, Conversation::PARCEL_SALMON]; + $condition = ['item-uri' => $self, 'protocol' => [Conversation::PARCEL_DFRN, + Conversation::PARCEL_DIASPORA_DFRN, Conversation::PARCEL_LEGACY_DFRN, + Conversation::PARCEL_LOCAL_DFRN, Conversation::PARCEL_DIRECT, Conversation::PARCEL_SALMON]]; if (DBA::exists('conversation', $condition)) { Logger::log('Conversation '.$item['uri'].' is already stored.', Logger::DEBUG); return; } - $curlResult = Network::curl($self); + $curlResult = DI::httpRequest()->get($self); if (!$curlResult->isSuccess()) { return; @@ -916,6 +891,7 @@ class OStatus $item["protocol"] = Conversation::PARCEL_SALMON; $item["source"] = $xml; + $item["direction"] = Conversation::PULL; Logger::log('Conversation '.$item['uri'].' is now fetched.', Logger::DEBUG); } @@ -932,12 +908,14 @@ class OStatus */ private static function fetchRelated($related, $related_uri, $importer) { - $condition = ['`item-uri` = ? AND `protocol` IN (?, ?)', $related_uri, Conversation::PARCEL_DFRN, Conversation::PARCEL_SALMON]; + $condition = ['item-uri' => $related_uri, 'protocol' => [Conversation::PARCEL_DFRN, + Conversation::PARCEL_DIASPORA_DFRN, Conversation::PARCEL_LEGACY_DFRN, + Conversation::PARCEL_LOCAL_DFRN, Conversation::PARCEL_DIRECT, Conversation::PARCEL_SALMON]]; $conversation = DBA::selectFirst('conversation', ['source', 'protocol'], $condition); if (DBA::isResult($conversation)) { $stored = true; $xml = $conversation['source']; - if (self::process($xml, $importer, $contact, $hub, $stored, false)) { + if (self::process($xml, $importer, $contact, $hub, $stored, false, Conversation::PULL)) { Logger::log('Got valid cached XML for URI '.$related_uri, Logger::DEBUG); return; } @@ -948,7 +926,7 @@ class OStatus } $stored = false; - $curlResult = Network::curl($related, false, ['accept_content' => 'application/atom+xml, text/html']); + $curlResult = DI::httpRequest()->get($related, ['accept_content' => 'application/atom+xml, text/html']); if (!$curlResult->isSuccess()) { return; @@ -979,7 +957,7 @@ class OStatus } } if ($atom_file != '') { - $curlResult = Network::curl($atom_file); + $curlResult = DI::httpRequest()->get($atom_file); if ($curlResult->isSuccess()) { Logger::log('Fetched XML for URI ' . $related_uri, Logger::DEBUG); @@ -991,7 +969,7 @@ class OStatus // Workaround for older GNU Social servers if (($xml == '') && strstr($related, '/notice/')) { - $curlResult = Network::curl(str_replace('/notice/', '/api/statuses/show/', $related).'.atom'); + $curlResult = DI::httpRequest()->get(str_replace('/notice/', '/api/statuses/show/', $related) . '.atom'); if ($curlResult->isSuccess()) { Logger::log('GNU Social workaround to fetch XML for URI ' . $related_uri, Logger::DEBUG); @@ -1001,8 +979,8 @@ class OStatus // Even more worse workaround for GNU Social ;-) if ($xml == '') { - $related_guess = OStatus::convertHref($related_uri); - $curlResult = Network::curl(str_replace('/notice/', '/api/statuses/show/', $related_guess).'.atom'); + $related_guess = self::convertHref($related_uri); + $curlResult = DI::httpRequest()->get(str_replace('/notice/', '/api/statuses/show/', $related_guess) . '.atom'); if ($curlResult->isSuccess()) { Logger::log('GNU Social workaround 2 to fetch XML for URI ' . $related_uri, Logger::DEBUG); @@ -1022,7 +1000,7 @@ class OStatus } if ($xml != '') { - self::process($xml, $importer, $contact, $hub, $stored, false); + self::process($xml, $importer, $contact, $hub, $stored, false, Conversation::PULL); } else { Logger::log("XML couldn't be fetched for URI: ".$related_uri." - href: ".$related, Logger::DEBUG); } @@ -1090,7 +1068,7 @@ class OStatus if (is_object($inreplyto->item(0))) { foreach ($inreplyto->item(0)->attributes as $attributes) { if ($attributes->name == "ref") { - $item["parent-uri"] = $attributes->textContent; + $item["thr-parent"] = $attributes->textContent; } } } @@ -1120,7 +1098,7 @@ class OStatus if (($item["object-type"] == Activity\ObjectType::QUESTION) || ($item["object-type"] == Activity\ObjectType::EVENT) ) { - $item["body"] .= add_page_info($attribute['href']); + $item["body"] .= "\n" . PageInfo::getFooterFromUrl($attribute['href']); } break; case "ostatus:conversation": @@ -1135,25 +1113,19 @@ class OStatus if ($filetype == 'image') { $link_data['add_body'] .= "\n[img]".$attribute['href'].'[/img]'; } else { - if (!empty($item["attach"])) { - $item["attach"] .= ','; - } else { - $item["attach"] = ''; - } - if (!isset($attribute['length'])) { - $attribute['length'] = "0"; - } - $item["attach"] .= '[attach]href="'.$attribute['href'].'" length="'.$attribute['length'].'" type="'.$attribute['type'].'" title="'.($attribute['title'] ?? '') .'"[/attach]'; + Post\Media::insert(['uri-id' => $item['uri-id'], 'type' => Post\Media::DOCUMENT, + 'url' => $attribute['href'], 'mimetype' => $attribute['type'], + 'size' => $attribute['length'] ?? null, 'description' => $attribute['title'] ?? null]); } break; case "related": if ($item["object-type"] != Activity\ObjectType::BOOKMARK) { - if (!isset($item["parent-uri"])) { - $item["parent-uri"] = $attribute['href']; + if (!isset($item["thr-parent"])) { + $item["thr-parent"] = $attribute['href']; } $link_data['related'] = $attribute['href']; } else { - $item["body"] .= add_page_info($attribute['href']); + $item["body"] .= "\n" . PageInfo::getFooterFromUrl($attribute['href']); } break; case "self": @@ -1175,7 +1147,7 @@ class OStatus * * @return string URL in the format http(s)://.... */ - public static function convertHref($href) + private static function convertHref($href) { $elements = explode(":", $href); @@ -1207,7 +1179,7 @@ class OStatus * * @return string The guid if the post is a reshare */ - private static function getResharedGuid(array $item) + public static function getResharedGuid(array $item) { $reshared = Item::getShareArray($item); if (empty($reshared['guid']) || !empty($reshared['comment'])) { @@ -1225,7 +1197,7 @@ class OStatus * @return string The cleaned body * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - private static function formatPicturePost($body) + public static function formatPicturePost($body) { $siteinfo = BBCode::getAttachedData($body); @@ -1261,12 +1233,11 @@ class OStatus * @param DOMDocument $doc XML document * @param array $owner Contact data of the poster * @param string $filter The related feed filter (activity, posts or comments) - * @param bool $feed_mode Behave like a regular feed for users if true * * @return object header root element * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - private static function addHeader(DOMDocument $doc, array $owner, $filter, $feed_mode = false) + private static function addHeader(DOMDocument $doc, array $owner, $filter) { $root = $doc->createElementNS(ActivityNamespace::ATOM1, 'feed'); $doc->appendChild($root); @@ -1296,9 +1267,7 @@ class OStatus break; } - if (!$feed_mode) { - $selfUri = "/dfrn_poll/" . $owner["nick"]; - } + $selfUri = "/dfrn_poll/" . $owner["nick"]; $attributes = ["uri" => "https://friendi.ca", "version" => FRIENDICA_VERSION . "-" . DB_UPDATE_VERSION]; XML::addElement($doc, $root, "generator", FRIENDICA_PLATFORM, $attributes); @@ -1308,7 +1277,7 @@ class OStatus XML::addElement($doc, $root, "logo", $owner["photo"]); XML::addElement($doc, $root, "updated", DateTimeFormat::utcNow(DateTimeFormat::ATOM)); - $author = self::addAuthor($doc, $owner); + $author = self::addAuthor($doc, $owner, true); $root->appendChild($author); $attributes = ["href" => $owner["url"], "rel" => "alternate", "type" => "text/html"]; @@ -1334,7 +1303,7 @@ class OStatus $attributes = ["href" => DI::baseUrl() . $selfUri, "rel" => "self", "type" => "application/atom+xml"]; XML::addElement($doc, $root, "link", "", $attributes); - if ($owner['account-type'] == Contact::TYPE_COMMUNITY) { + if ($owner['contact-type'] == Contact::TYPE_COMMUNITY) { $condition = ['uid' => $owner['uid'], 'self' => false, 'pending' => false, 'archive' => false, 'hidden' => false, 'blocked' => false]; $members = DBA::count('contact', $condition); @@ -1368,7 +1337,7 @@ class OStatus * @return void * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - private static function getAttachment(DOMDocument $doc, $root, $item) + public static function getAttachment(DOMDocument $doc, $root, $item) { $siteinfo = BBCode::getAttachedData($item["body"]); @@ -1389,7 +1358,7 @@ class OStatus $attributes = ["rel" => "enclosure", "href" => $siteinfo["url"], "type" => "text/html; charset=UTF-8", - "length" => "", + "length" => "0", "title" => ($siteinfo["title"] ?? '') ?: $siteinfo["url"], ]; XML::addElement($doc, $root, "link", "", $attributes); @@ -1410,25 +1379,19 @@ class OStatus } } - $arr = explode('[/attach],', $item['attach']); - if (count($arr)) { - foreach ($arr as $r) { - $matches = false; - $cnt = preg_match('|\[attach\]href=\"(.*?)\" length=\"(.*?)\" type=\"(.*?)\" title=\"(.*?)\"|', $r, $matches); - if ($cnt) { - $attributes = ["rel" => "enclosure", - "href" => $matches[1], - "type" => $matches[3]]; + foreach (Post\Media::getByURIId($item['uri-id'], [Post\Media::DOCUMENT, Post\Media::TORRENT, Post\Media::UNKNOWN]) as $attachment) { + $attributes = ['rel' => 'enclosure', + 'href' => $attachment['url'], + 'type' => $attachment['mimetype']]; - if (intval($matches[2])) { - $attributes["length"] = intval($matches[2]); - } - if (trim($matches[4]) != "") { - $attributes["title"] = trim($matches[4]); - } - XML::addElement($doc, $root, "link", "", $attributes); - } + if (!empty($attachment['size'])) { + $attributes['length'] = intval($attachment['size']); } + if (!empty($attachment['description'])) { + $attributes['title'] = $attachment['description']; + } + + XML::addElement($doc, $root, 'link', '', $attributes); } } @@ -1447,16 +1410,17 @@ class OStatus $profile = DBA::selectFirst('profile', ['homepage', 'publish'], ['uid' => $owner['uid']]); $author = $doc->createElement("author"); XML::addElement($doc, $author, "id", $owner["url"]); - if ($owner['account-type'] == User::ACCOUNT_TYPE_COMMUNITY) { + if ($owner['contact-type'] == Contact::TYPE_COMMUNITY) { XML::addElement($doc, $author, "activity:object-type", Activity\ObjectType::GROUP); } else { XML::addElement($doc, $author, "activity:object-type", Activity\ObjectType::PERSON); } + XML::addElement($doc, $author, "uri", $owner["url"]); XML::addElement($doc, $author, "name", $owner["nick"]); XML::addElement($doc, $author, "email", $owner["addr"]); if ($show_profile) { - XML::addElement($doc, $author, "summary", BBCode::convert($owner["about"], false, 7)); + XML::addElement($doc, $author, "summary", BBCode::convert($owner["about"], false, BBCode::OSTATUS)); } $attributes = ["rel" => "alternate", "type" => "text/html", "href" => $owner["url"]]; @@ -1483,7 +1447,7 @@ class OStatus XML::addElement($doc, $author, "poco:preferredUsername", $owner["nick"]); XML::addElement($doc, $author, "poco:displayName", $owner["name"]); if ($show_profile) { - XML::addElement($doc, $author, "poco:note", BBCode::convert($owner["about"], false, 7)); + XML::addElement($doc, $author, "poco:note", BBCode::convert($owner["about"], false, BBCode::OSTATUS)); if (trim($owner["location"]) != "") { $element = $doc->createElement("poco:address"); @@ -1525,7 +1489,7 @@ class OStatus * * @return string activity */ - private static function constructVerb(array $item) + public static function constructVerb(array $item) { if (!empty($item['verb'])) { return $item['verb']; @@ -1557,13 +1521,12 @@ class OStatus * @param array $item Data of the item that is to be posted * @param array $owner Contact data of the poster * @param bool $toplevel optional default false - * @param bool $feed_mode Behave like a regular feed for users if true * * @return \DOMElement Entry element * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function entry(DOMDocument $doc, array $item, array $owner, $toplevel = false, $feed_mode = false) + private static function entry(DOMDocument $doc, array $item, array $owner, $toplevel = false) { $xml = null; @@ -1581,7 +1544,7 @@ class OStatus } elseif (in_array($item["verb"], [Activity::FOLLOW, Activity::O_UNFOLLOW])) { return self::followEntry($doc, $item, $owner, $toplevel); } else { - return self::noteEntry($doc, $item, $owner, $toplevel, $feed_mode); + return self::noteEntry($doc, $item, $owner, $toplevel); } } @@ -1607,59 +1570,6 @@ class OStatus return $source; } - /** - * Fetches contact data from the contact or the gcontact table - * - * @param string $url URL of the contact - * @param array $owner Contact data of the poster - * - * @return array Contact array - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - * @throws \ImagickException - */ - private static function contactEntry($url, array $owner) - { - $r = q( - "SELECT * FROM `contact` WHERE `nurl` = '%s' AND `uid` IN (0, %d) ORDER BY `uid` DESC LIMIT 1", - DBA::escape(Strings::normaliseLink($url)), - intval($owner["uid"]) - ); - if (DBA::isResult($r)) { - $contact = $r[0]; - $contact["uid"] = -1; - } - - if (!DBA::isResult($r)) { - $gcontact = DBA::selectFirst('gcontact', [], ['nurl' => Strings::normaliseLink($url)]); - if (DBA::isResult($r)) { - $contact = $gcontact; - $contact["uid"] = -1; - $contact["success_update"] = $contact["updated"]; - } - } - - if (!DBA::isResult($r)) { - $contact = $owner; - } - - if (!isset($contact["poll"])) { - $data = Probe::uri($url); - $contact["poll"] = $data["poll"]; - - if (!$contact["alias"]) { - $contact["alias"] = $data["alias"]; - } - } - - if (!isset($contact["alias"])) { - $contact["alias"] = $contact["url"]; - } - - $contact['account-type'] = $owner['account-type']; - - return $contact; - } - /** * Adds an entry element with reshared content * @@ -1675,7 +1585,7 @@ class OStatus */ private static function reshareEntry(DOMDocument $doc, array $item, array $owner, $repeated_guid, $toplevel) { - if (($item["id"] != $item["parent"]) && (Strings::normaliseLink($item["author-link"]) != Strings::normaliseLink($owner["url"]))) { + if (($item['gravity'] != GRAVITY_PARENT) && (Strings::normaliseLink($item["author-link"]) != Strings::normaliseLink($owner["url"]))) { Logger::log("OStatus entry is from author ".$owner["url"]." - not from ".$item["author-link"].". Quitting.", Logger::DEBUG); } @@ -1688,7 +1598,7 @@ class OStatus return false; } - $contact = self::contactEntry($repeated_item['author-link'], $owner); + $contact = Contact::getByURL($repeated_item['author-link']) ?: $owner; $title = $owner["nick"]." repeated a notice by ".$contact["nick"]; @@ -1721,7 +1631,7 @@ class OStatus $entry->appendChild($as_object); - self::entryFooter($doc, $entry, $item, $owner); + self::entryFooter($doc, $entry, $item, $owner, true); return $entry; } @@ -1740,7 +1650,7 @@ class OStatus */ private static function likeEntry(DOMDocument $doc, array $item, array $owner, $toplevel) { - if (($item["id"] != $item["parent"]) && (Strings::normaliseLink($item["author-link"]) != Strings::normaliseLink($owner["url"]))) { + if (($item['gravity'] != GRAVITY_PARENT) && (Strings::normaliseLink($item["author-link"]) != Strings::normaliseLink($owner["url"]))) { Logger::log("OStatus entry is from author ".$owner["url"]." - not from ".$item["author-link"].". Quitting.", Logger::DEBUG); } @@ -1824,16 +1734,17 @@ class OStatus */ private static function followEntry(DOMDocument $doc, array $item, array $owner, $toplevel) { - $item["id"] = $item["parent"] = 0; + $item["id"] = $item['parent'] = 0; $item["created"] = $item["edited"] = date("c"); $item["private"] = Item::PRIVATE; - $contact = Probe::uri($item['follow']); + $contact = Contact::getByURL($item['follow']); + $item['follow'] = $contact['url']; - if ($contact['alias'] == '') { - $contact['alias'] = $contact["url"]; - } else { + if ($contact['alias']) { $item['follow'] = $contact['alias']; + } else { + $contact['alias'] = $contact['url']; } $condition = ['uid' => $owner['uid'], 'nurl' => Strings::normaliseLink($contact["url"])]; @@ -1881,21 +1792,20 @@ class OStatus * @param array $item Data of the item that is to be posted * @param array $owner Contact data of the poster * @param bool $toplevel Is it for en entry element (false) or a feed entry (true)? - * @param bool $feed_mode Behave like a regular feed for users if true * * @return \DOMElement Entry element * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function noteEntry(DOMDocument $doc, array $item, array $owner, $toplevel, $feed_mode) + private static function noteEntry(DOMDocument $doc, array $item, array $owner, $toplevel) { - if (($item["id"] != $item["parent"]) && (Strings::normaliseLink($item["author-link"]) != Strings::normaliseLink($owner["url"]))) { + if (($item['gravity'] != GRAVITY_PARENT) && (Strings::normaliseLink($item["author-link"]) != Strings::normaliseLink($owner["url"]))) { Logger::log("OStatus entry is from author ".$owner["url"]." - not from ".$item["author-link"].". Quitting.", Logger::DEBUG); } if (!$toplevel) { if (!empty($item['title'])) { - $title = BBCode::convert($item['title'], false, 7); + $title = BBCode::convert($item['title'], false, BBCode::OSTATUS); } else { $title = sprintf("New note by %s", $owner["nick"]); } @@ -1907,9 +1817,9 @@ class OStatus XML::addElement($doc, $entry, "activity:object-type", Activity\ObjectType::NOTE); - self::entryContent($doc, $entry, $item, $owner, $title, '', true, $feed_mode); + self::entryContent($doc, $entry, $item, $owner, $title, '', true); - self::entryFooter($doc, $entry, $item, $owner, !$feed_mode, $feed_mode); + self::entryFooter($doc, $entry, $item, $owner, true); return $entry; } @@ -1926,13 +1836,13 @@ class OStatus * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function entryHeader(DOMDocument $doc, array $owner, array $item, $toplevel) + public static function entryHeader(DOMDocument $doc, array $owner, array $item, $toplevel) { if (!$toplevel) { $entry = $doc->createElement("entry"); - if ($owner['account-type'] == User::ACCOUNT_TYPE_COMMUNITY) { - $contact = self::contactEntry($item['author-link'], $owner); + if ($owner['contact-type'] == Contact::TYPE_COMMUNITY) { + $contact = Contact::getByURL($item['author-link']) ?: $owner; $author = self::addAuthor($doc, $contact, false); $entry->appendChild($author); } @@ -1965,11 +1875,10 @@ class OStatus * @param string $title Title for the post * @param string $verb The activity verb * @param bool $complete Add the "status_net" element? - * @param bool $feed_mode Behave like a regular feed for users if true * @return void * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - private static function entryContent(DOMDocument $doc, \DOMElement $entry, array $item, array $owner, $title, $verb = "", $complete = true, $feed_mode = false) + private static function entryContent(DOMDocument $doc, \DOMElement $entry, array $item, array $owner, $title, $verb = "", $complete = true) { if ($verb == "") { $verb = self::constructVerb($item); @@ -1980,11 +1889,11 @@ class OStatus $body = self::formatPicturePost($item['body']); - if (!empty($item['title']) && !$feed_mode) { + if (!empty($item['title'])) { $body = "[b]".$item['title']."[/b]\n\n".$body; } - $body = BBCode::convert($body, false, 7); + $body = BBCode::convert($body, false, BBCode::OSTATUS); XML::addElement($doc, $entry, "content", $body, ["type" => "html"]); @@ -1992,13 +1901,11 @@ class OStatus "href" => DI::baseUrl()."/display/".$item["guid"]] ); - if (!$feed_mode && $complete && ($item["id"] > 0)) { + if ($complete && ($item["id"] > 0)) { XML::addElement($doc, $entry, "status_net", "", ["notice_id" => $item["id"]]); } - if (!$feed_mode) { - XML::addElement($doc, $entry, "activity:verb", $verb); - } + XML::addElement($doc, $entry, "activity:verb", $verb); XML::addElement($doc, $entry, "published", DateTimeFormat::utc($item["created"]."+00:00", DateTimeFormat::ATOM)); XML::addElement($doc, $entry, "updated", DateTimeFormat::utc($item["edited"]."+00:00", DateTimeFormat::ATOM)); @@ -2012,19 +1919,17 @@ class OStatus * @param array $item Data of the item that is to be posted * @param array $owner Contact data of the poster * @param bool $complete default true - * @param bool $feed_mode Behave like a regular feed for users if true * @return void * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - private static function entryFooter(DOMDocument $doc, $entry, array $item, array $owner, $complete = true, $feed_mode = false) + private static function entryFooter(DOMDocument $doc, $entry, array $item, array $owner, $complete = true) { $mentioned = []; - if (($item['parent'] != $item['id']) || ($item['parent-uri'] !== $item['uri']) || (($item['thr-parent'] !== '') && ($item['thr-parent'] !== $item['uri']))) { - $parent = Item::selectFirst(['guid', 'author-link', 'owner-link'], ['id' => $item["parent"]]); - $parent_item = (($item['thr-parent']) ? $item['thr-parent'] : $item['parent-uri']); + if ($item['gravity'] != GRAVITY_PARENT) { + $parent = Item::selectFirst(['guid', 'author-link', 'owner-link'], ['id' => $item['parent']]); - $thrparent = Item::selectFirst(['guid', 'author-link', 'owner-link', 'plink'], ['uid' => $owner["uid"], 'uri' => $parent_item]); + $thrparent = Item::selectFirst(['guid', 'author-link', 'owner-link', 'plink'], ['uid' => $owner["uid"], 'uri' => $item['thr-parent']]); if (DBA::isResult($thrparent)) { $mentioned[$thrparent["author-link"]] = $thrparent["author-link"]; @@ -2037,7 +1942,7 @@ class OStatus } $attributes = [ - "ref" => $parent_item, + "ref" => $item['thr-parent'], "href" => $parent_plink]; XML::addElement($doc, $entry, "thr:in-reply-to", "", $attributes); @@ -2047,8 +1952,8 @@ class OStatus XML::addElement($doc, $entry, "link", "", $attributes); } - if (!$feed_mode && (intval($item["parent"]) > 0)) { - $conversation_href = $conversation_uri = str_replace('/objects/', '/context/', $item['parent-uri']); + if (intval($item['parent']) > 0) { + $conversation_href = $conversation_uri = str_replace('/objects/', '/context/', $item['thr-parent']); if (isset($parent_item)) { $conversation = DBA::selectFirst('conversation', ['conversation-uri', 'conversation-href'], ['item-uri' => $parent_item]); @@ -2066,7 +1971,7 @@ class OStatus $attributes = [ "href" => $conversation_href, - "local_id" => $item["parent"], + "local_id" => $item['parent'], "ref" => $conversation_uri]; XML::addElement($doc, $entry, "ostatus:conversation", $conversation_uri, $attributes); @@ -2087,10 +1992,8 @@ class OStatus $mentioned = $newmentions; foreach ($mentioned as $mention) { - $condition = ['uid' => $owner['uid'], 'nurl' => Strings::normaliseLink($mention)]; - $contact = DBA::selectFirst('contact', ['forum', 'prv', 'self', 'contact-type'], $condition); - if ($contact["forum"] || $contact["prv"] || ($owner['contact-type'] == Contact::TYPE_COMMUNITY) || - ($contact['self'] && ($owner['account-type'] == User::ACCOUNT_TYPE_COMMUNITY))) { + $contact = Contact::getByURL($mention, false, ['contact-type']); + if (!empty($contact) && ($contact['contact-type'] == Contact::TYPE_COMMUNITY)) { XML::addElement($doc, $entry, "link", "", [ "rel" => "mentioned", @@ -2107,7 +2010,7 @@ class OStatus } } - if ($owner['account-type'] == User::ACCOUNT_TYPE_COMMUNITY) { + if ($owner['contact-type'] == Contact::TYPE_COMMUNITY) { XML::addElement($doc, $entry, "link", "", [ "rel" => "mentioned", "ostatus:object-type" => "http://activitystrea.ms/schema/1.0/group", @@ -2115,7 +2018,7 @@ class OStatus ]); } - if (($item['private'] != Item::PRIVATE) && !$feed_mode) { + if ($item['private'] != Item::PRIVATE) { XML::addElement($doc, $entry, "link", "", ["rel" => "ostatus:attention", "href" => "http://activityschema.org/collection/public"]); XML::addElement($doc, $entry, "link", "", ["rel" => "mentioned", @@ -2168,13 +2071,12 @@ class OStatus * @param integer $max_items Number of maximum items to fetch * @param string $filter Feed items filter (activity, posts or comments) * @param boolean $nocache Wether to bypass caching - * @param boolean $feed_mode Behave like a regular feed for users if true * * @return string XML feed * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function feed($owner_nick, &$last_update, $max_items = 300, $filter = 'activity', $nocache = false, $feed_mode = false) + public static function feed($owner_nick, &$last_update, $max_items = 300, $filter = 'activity', $nocache = false) { $stamp = microtime(true); @@ -2202,7 +2104,7 @@ class OStatus } $check_date = DateTimeFormat::utc($last_update); - $authorid = Contact::getIdForURL($owner["url"], 0, true); + $authorid = Contact::getIdForURL($owner["url"]); $condition = ["`uid` = ? AND `received` > ? AND NOT `deleted` AND `private` != ? AND `visible` AND `wall` AND `parent-network` IN (?, ?)", @@ -2213,7 +2115,7 @@ class OStatus $condition[] = Activity\ObjectType::COMMENT; } - if ($owner['account-type'] != User::ACCOUNT_TYPE_COMMUNITY) { + if ($owner['contact-type'] != Contact::TYPE_COMMUNITY) { $condition[0] .= " AND `contact-id` = ? AND `author-id` = ?"; $condition[] = $owner["id"]; $condition[] = $authorid; @@ -2232,14 +2134,18 @@ class OStatus $doc = new DOMDocument('1.0', 'utf-8'); $doc->formatOutput = true; - $root = self::addHeader($doc, $owner, $filter, $feed_mode); + $root = self::addHeader($doc, $owner, $filter); foreach ($items as $item) { if (DI::config()->get('system', 'ostatus_debug')) { $item['body'] .= '🍼'; } - $entry = self::entry($doc, $item, $owner, false, $feed_mode); + if (in_array($item["verb"], [Activity::FOLLOW, Activity::O_UNFOLLOW, Activity::LIKE])) { + continue; + } + + $entry = self::entry($doc, $item, $owner, false); $root->appendChild($entry); if ($last_update < $item['created']) { @@ -2287,14 +2193,13 @@ class OStatus * Checks if the given contact url does support OStatus * * @param string $url profile url - * @param boolean $update Update the profile * @return boolean * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function isSupportedByContactUrl($url, $update = false) + public static function isSupportedByContactUrl($url) { - $probe = Probe::uri($url, Protocol::OSTATUS, 0, !$update); + $probe = Probe::uri($url, Protocol::OSTATUS); return $probe['network'] == Protocol::OSTATUS; } } diff --git a/src/Protocol/PortableContact.php b/src/Protocol/PortableContact.php deleted file mode 100644 index 70acf5064c..0000000000 --- a/src/Protocol/PortableContact.php +++ /dev/null @@ -1,494 +0,0 @@ -. - * - */ - -namespace Friendica\Protocol; - -use Exception; -use Friendica\Content\Text\HTML; -use Friendica\Core\Logger; -use Friendica\Core\Protocol; -use Friendica\Core\Worker; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Model\GContact; -use Friendica\Model\GServer; -use Friendica\Util\DateTimeFormat; -use Friendica\Util\Network; -use Friendica\Util\Strings; - -/** - * - * @todo Move GNU Social URL schemata (http://server.tld/user/number) to http://server.tld/username - * @todo Fetch profile data from profile page for Redmatrix users - * @todo Detect if it is a forum - */ -class PortableContact -{ - const DISABLED = 0; - const USERS = 1; - const USERS_GCONTACTS = 2; - const USERS_GCONTACTS_FALLBACK = 3; - - /** - * Fetch POCO data - * - * @param integer $cid Contact ID - * @param integer $uid User ID - * @param integer $zcid Global Contact ID - * @param integer $url POCO address that should be polled - * - * Given a contact-id (minimum), load the PortableContacts friend list for that contact, - * and add the entries to the gcontact (Global Contact) table, or update existing entries - * if anything (name or photo) has changed. - * We use normalised urls for comparison which ignore http vs https and www.domain vs domain - * - * Once the global contact is stored add (if necessary) the contact linkage which associates - * the given uid, cid to the global contact entry. There can be many uid/cid combinations - * pointing to the same global contact id. - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function loadWorker($cid, $uid = 0, $zcid = 0, $url = null) - { - // Call the function "load" via the worker - Worker::add(PRIORITY_LOW, 'FetchPoCo', (int)$cid, (int)$uid, (int)$zcid, $url); - } - - /** - * Fetch POCO data from the worker - * - * @param integer $cid Contact ID - * @param integer $uid User ID - * @param integer $zcid Global Contact ID - * @param integer $url POCO address that should be polled - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function load($cid, $uid, $zcid, $url) - { - if ($cid) { - if (!$url || !$uid) { - $contact = DBA::selectFirst('contact', ['poco', 'uid'], ['id' => $cid]); - if (DBA::isResult($contact)) { - $url = $contact['poco']; - $uid = $contact['uid']; - } - } - if (!$uid) { - return; - } - } - - if (!$url) { - return; - } - - $url = $url . (($uid) ? '/@me/@all?fields=displayName,urls,photos,updated,network,aboutMe,currentLocation,tags,contactType,generation' : '?fields=displayName,urls,photos,updated,network,aboutMe,currentLocation,tags,contactType,generation'); - - Logger::log('load: ' . $url, Logger::DEBUG); - - $fetchresult = Network::fetchUrlFull($url); - $s = $fetchresult->getBody(); - - Logger::log('load: returns ' . $s, Logger::DATA); - - Logger::log('load: return code: ' . $fetchresult->getReturnCode(), Logger::DEBUG); - - if (($fetchresult->getReturnCode() > 299) || (! $s)) { - return; - } - - $j = json_decode($s, true); - - Logger::log('load: json: ' . print_r($j, true), Logger::DATA); - - if (!isset($j['entry'])) { - return; - } - - $total = 0; - foreach ($j['entry'] as $entry) { - $total ++; - $profile_url = ''; - $profile_photo = ''; - $connect_url = ''; - $name = ''; - $network = ''; - $updated = DBA::NULL_DATETIME; - $location = ''; - $about = ''; - $keywords = ''; - $contact_type = -1; - $generation = 0; - - if (!empty($entry['displayName'])) { - $name = $entry['displayName']; - } - - if (isset($entry['urls'])) { - foreach ($entry['urls'] as $url) { - if ($url['type'] == 'profile') { - $profile_url = $url['value']; - continue; - } - if ($url['type'] == 'webfinger') { - $connect_url = str_replace('acct:', '', $url['value']); - continue; - } - } - } - if (isset($entry['photos'])) { - foreach ($entry['photos'] as $photo) { - if ($photo['type'] == 'profile') { - $profile_photo = $photo['value']; - continue; - } - } - } - - if (isset($entry['updated'])) { - $updated = date(DateTimeFormat::MYSQL, strtotime($entry['updated'])); - } - - if (isset($entry['network'])) { - $network = $entry['network']; - } - - if (isset($entry['currentLocation'])) { - $location = $entry['currentLocation']; - } - - if (isset($entry['aboutMe'])) { - $about = HTML::toBBCode($entry['aboutMe']); - } - - if (isset($entry['generation']) && ($entry['generation'] > 0)) { - $generation = ++$entry['generation']; - } - - if (isset($entry['tags'])) { - foreach ($entry['tags'] as $tag) { - $keywords = implode(", ", $tag); - } - } - - if (isset($entry['contactType']) && ($entry['contactType'] >= 0)) { - $contact_type = $entry['contactType']; - } - - $gcontact = ["url" => $profile_url, - "name" => $name, - "network" => $network, - "photo" => $profile_photo, - "about" => $about, - "location" => $location, - "keywords" => $keywords, - "connect" => $connect_url, - "updated" => $updated, - "contact-type" => $contact_type, - "generation" => $generation]; - - try { - $gcontact = GContact::sanitize($gcontact); - $gcid = GContact::update($gcontact); - - GContact::link($gcid, $uid, $cid, $zcid); - } catch (Exception $e) { - Logger::log($e->getMessage(), Logger::DEBUG); - } - } - Logger::log("load: loaded $total entries", Logger::DEBUG); - - $condition = ["`cid` = ? AND `uid` = ? AND `zcid` = ? AND `updated` < UTC_TIMESTAMP - INTERVAL 2 DAY", $cid, $uid, $zcid]; - DBA::delete('glink', $condition); - } - - /** - * Returns a list of all known servers - * @return array List of server urls - * @throws Exception - */ - public static function serverlist() - { - $r = q( - "SELECT `url`, `site_name` AS `displayName`, `network`, `platform`, `version` FROM `gserver` - WHERE `network` IN ('%s', '%s', '%s') AND `last_contact` > `last_failure` - ORDER BY `last_contact` - LIMIT 1000", - DBA::escape(Protocol::DFRN), - DBA::escape(Protocol::DIASPORA), - DBA::escape(Protocol::OSTATUS) - ); - - if (!DBA::isResult($r)) { - return false; - } - - return $r; - } - - /** - * Fetch server list from remote servers and adds them when they are new. - * - * @param string $poco URL to the POCO endpoint - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - private static function fetchServerlist($poco) - { - $curlResult = Network::curl($poco . "/@server"); - - if (!$curlResult->isSuccess()) { - return; - } - - $serverlist = json_decode($curlResult->getBody(), true); - - if (!is_array($serverlist)) { - return; - } - - foreach ($serverlist as $server) { - $server_url = str_replace("/index.php", "", $server['url']); - - $r = q("SELECT `nurl` FROM `gserver` WHERE `nurl` = '%s'", DBA::escape(Strings::normaliseLink($server_url))); - - if (!DBA::isResult($r)) { - Logger::log("Call server check for server ".$server_url, Logger::DEBUG); - Worker::add(PRIORITY_LOW, 'UpdateGServer', $server_url); - } - } - } - - public static function discoverSingleServer($id) - { - $server = DBA::selectFirst('gserver', ['poco', 'nurl', 'url', 'network'], ['id' => $id]); - - if (!DBA::isResult($server)) { - return false; - } - - // Discover new servers out there (Works from Friendica version 3.5.2) - self::fetchServerlist($server["poco"]); - - // Fetch all users from the other server - $url = $server["poco"] . "/?fields=displayName,urls,photos,updated,network,aboutMe,currentLocation,tags,contactType,generation"; - - Logger::info("Fetch all users from the server " . $server["url"]); - - $curlResult = Network::curl($url); - - if ($curlResult->isSuccess() && !empty($curlResult->getBody())) { - $data = json_decode($curlResult->getBody(), true); - - if (!empty($data)) { - self::discoverServer($data, 2); - } - - if (DI::config()->get('system', 'poco_discovery') >= self::USERS_GCONTACTS) { - $timeframe = DI::config()->get('system', 'poco_discovery_since'); - - if ($timeframe == 0) { - $timeframe = 30; - } - - $updatedSince = date(DateTimeFormat::MYSQL, time() - $timeframe * 86400); - - // Fetch all global contacts from the other server (Not working with Redmatrix and Friendica versions before 3.3) - $url = $server["poco"]."/@global?updatedSince=".$updatedSince."&fields=displayName,urls,photos,updated,network,aboutMe,currentLocation,tags,contactType,generation"; - - $success = false; - - $curlResult = Network::curl($url); - - if ($curlResult->isSuccess() && !empty($curlResult->getBody())) { - Logger::info("Fetch all global contacts from the server " . $server["nurl"]); - $data = json_decode($curlResult->getBody(), true); - - if (!empty($data)) { - $success = self::discoverServer($data); - } - } - - if (!$success && !empty($data) && DI::config()->get('system', 'poco_discovery') >= self::USERS_GCONTACTS_FALLBACK) { - Logger::info("Fetch contacts from users of the server " . $server["nurl"]); - self::discoverServerUsers($data, $server); - } - } - - $fields = ['last_poco_query' => DateTimeFormat::utcNow()]; - DBA::update('gserver', $fields, ['nurl' => $server["nurl"]]); - - return true; - } else { - // If the server hadn't replied correctly, then force a sanity check - GServer::check($server["url"], $server["network"], true); - - // If we couldn't reach the server, we will try it some time later - $fields = ['last_poco_query' => DateTimeFormat::utcNow()]; - DBA::update('gserver', $fields, ['nurl' => $server["nurl"]]); - - return false; - } - } - - private static function discoverServerUsers(array $data, array $server) - { - if (!isset($data['entry'])) { - return; - } - - foreach ($data['entry'] as $entry) { - $username = ''; - - if (isset($entry['urls'])) { - foreach ($entry['urls'] as $url) { - if ($url['type'] == 'profile') { - $profile_url = $url['value']; - $path_array = explode('/', parse_url($profile_url, PHP_URL_PATH)); - $username = end($path_array); - } - } - } - - if ($username != '') { - Logger::log('Fetch contacts for the user ' . $username . ' from the server ' . $server['nurl'], Logger::DEBUG); - - // Fetch all contacts from a given user from the other server - $url = $server['poco'] . '/' . $username . '/?fields=displayName,urls,photos,updated,network,aboutMe,currentLocation,tags,contactType,generation'; - - $curlResult = Network::curl($url); - - if ($curlResult->isSuccess()) { - $data = json_decode($curlResult->getBody(), true); - - if (!empty($data)) { - self::discoverServer($data, 3); - } - } - } - } - } - - private static function discoverServer(array $data, $default_generation = 0) - { - if (empty($data['entry'])) { - return false; - } - - $success = false; - - foreach ($data['entry'] as $entry) { - $profile_url = ''; - $profile_photo = ''; - $connect_url = ''; - $name = ''; - $network = ''; - $updated = DBA::NULL_DATETIME; - $location = ''; - $about = ''; - $keywords = ''; - $contact_type = -1; - $generation = $default_generation; - - if (!empty($entry['displayName'])) { - $name = $entry['displayName']; - } - - if (isset($entry['urls'])) { - foreach ($entry['urls'] as $url) { - if ($url['type'] == 'profile') { - $profile_url = $url['value']; - continue; - } - if ($url['type'] == 'webfinger') { - $connect_url = str_replace('acct:' , '', $url['value']); - continue; - } - } - } - - if (isset($entry['photos'])) { - foreach ($entry['photos'] as $photo) { - if ($photo['type'] == 'profile') { - $profile_photo = $photo['value']; - continue; - } - } - } - - if (isset($entry['updated'])) { - $updated = date(DateTimeFormat::MYSQL, strtotime($entry['updated'])); - } - - if (isset($entry['network'])) { - $network = $entry['network']; - } - - if (isset($entry['currentLocation'])) { - $location = $entry['currentLocation']; - } - - if (isset($entry['aboutMe'])) { - $about = HTML::toBBCode($entry['aboutMe']); - } - - if (isset($entry['generation']) && ($entry['generation'] > 0)) { - $generation = ++$entry['generation']; - } - - if (isset($entry['contactType']) && ($entry['contactType'] >= 0)) { - $contact_type = $entry['contactType']; - } - - if (isset($entry['tags'])) { - foreach ($entry['tags'] as $tag) { - $keywords = implode(", ", $tag); - } - } - - if ($generation > 0) { - $success = true; - - Logger::log("Store profile ".$profile_url, Logger::DEBUG); - - $gcontact = ["url" => $profile_url, - "name" => $name, - "network" => $network, - "photo" => $profile_photo, - "about" => $about, - "location" => $location, - "keywords" => $keywords, - "connect" => $connect_url, - "updated" => $updated, - "contact-type" => $contact_type, - "generation" => $generation]; - - try { - $gcontact = GContact::sanitize($gcontact); - GContact::update($gcontact); - } catch (Exception $e) { - Logger::log($e->getMessage(), Logger::DEBUG); - } - - Logger::log("Done for profile ".$profile_url, Logger::DEBUG); - } - } - return $success; - } -} diff --git a/src/Protocol/Relay.php b/src/Protocol/Relay.php new file mode 100644 index 0000000000..c982e0bc29 --- /dev/null +++ b/src/Protocol/Relay.php @@ -0,0 +1,345 @@ +. + * + */ + +namespace Friendica\Protocol; + +use Friendica\Content\Text\BBCode; +use Friendica\Core\Logger; +use Friendica\Core\Protocol; +use Friendica\Database\DBA; +use Friendica\DI; +use Friendica\Model\APContact; +use Friendica\Model\Contact; +use Friendica\Model\GServer; +use Friendica\Model\Item; +use Friendica\Model\Search; +use Friendica\Model\Tag; +use Friendica\Util\DateTimeFormat; +use Friendica\Util\Strings; + +/** + * Base class for relay handling + */ +class Relay +{ + /** + * Check if a post is wanted + * + * @param array $tags + * @param string $body + * @param int $authorid + * @param string $url + * @return boolean "true" is the post is wanted by the system + */ + public static function isSolicitedPost(array $tags, string $body, int $authorid, string $url, string $network = '') + { + $config = DI::config(); + + $subscribe = $config->get('system', 'relay_subscribe', false); + if ($subscribe) { + $scope = $config->get('system', 'relay_scope', SR_SCOPE_ALL); + } else { + $scope = SR_SCOPE_NONE; + } + + if ($scope == SR_SCOPE_NONE) { + Logger::info('Server does not accept relay posts - rejected', ['network' => $network, 'url' => $url]); + return false; + } + + if (Contact::isBlocked($authorid)) { + Logger::info('Author is blocked - rejected', ['author' => $authorid, 'network' => $network, 'url' => $url]); + return false; + } + + if (Contact::isHidden($authorid)) { + Logger::info('Author is hidden - rejected', ['author' => $authorid, 'network' => $network, 'url' => $url]); + return false; + } + + $systemTags = []; + $userTags = []; + $denyTags = []; + + if ($scope == SR_SCOPE_TAGS) { + $server_tags = $config->get('system', 'relay_server_tags'); + $tagitems = explode(',', mb_strtolower($server_tags)); + foreach ($tagitems AS $tag) { + $systemTags[] = trim($tag, '# '); + } + + if ($config->get('system', 'relay_user_tags')) { + $userTags = Search::getUserTags(); + } + } + + $tagList = array_unique(array_merge($systemTags, $userTags)); + + $deny_tags = $config->get('system', 'relay_deny_tags'); + $tagitems = explode(',', mb_strtolower($deny_tags)); + foreach ($tagitems AS $tag) { + $tag = trim($tag, '# '); + $denyTags[] = $tag; + } + + if (!empty($tagList) || !empty($denyTags)) { + $content = mb_strtolower(BBCode::toPlaintext($body, false)); + + foreach ($tags as $tag) { + $tag = mb_strtolower($tag); + if (in_array($tag, $denyTags)) { + Logger::info('Unwanted hashtag found - rejected', ['hashtag' => $tag, 'network' => $network, 'url' => $url]); + return false; + } + + if (in_array($tag, $tagList)) { + Logger::info('Subscribed hashtag found - accepted', ['hashtag' => $tag, 'network' => $network, 'url' => $url]); + return true; + } + + // We check with "strpos" for performance issues. Only when this is true, the regular expression check is used + // RegExp is taken from here: https://medium.com/@shiba1014/regex-word-boundaries-with-unicode-207794f6e7ed + if ((strpos($content, $tag) !== false) && preg_match('/(?<=[\s,.:;"\']|^)' . preg_quote($tag, '/') . '(?=[\s,.:;"\']|$)/', $content)) { + Logger::info('Subscribed hashtag found in content - accepted', ['hashtag' => $tag, 'network' => $network, 'url' => $url]); + return true; + } + } + } + + if ($scope == SR_SCOPE_ALL) { + Logger::info('Server accept all posts - accepted', ['network' => $network, 'url' => $url]); + return true; + } + + Logger::info('No matching hashtags found - rejected', ['network' => $network, 'url' => $url]); + return false; + } + + /** + * Update or insert a relay contact + * + * @param array $gserver Global server record + * @param array $fields Optional network specific fields + * @throws \Exception + */ + public static function updateContact(array $gserver, array $fields = []) + { + if (in_array($gserver['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN])) { + $system = APContact::getByURL($gserver['url'] . '/friendica'); + if (!empty($system['sharedinbox'])) { + Logger::info('Sucessfully probed for relay contact', ['server' => $gserver['url']]); + $id = Contact::updateFromProbeByURL($system['url']); + Logger::info('Updated relay contact', ['server' => $gserver['url'], 'id' => $id]); + return; + } + } + + $condition = ['uid' => 0, 'gsid' => $gserver['id'], 'contact-type' => Contact::TYPE_RELAY]; + $old = DBA::selectFirst('contact', [], $condition); + if (!DBA::isResult($old)) { + $condition = ['uid' => 0, 'nurl' => Strings::normaliseLink($gserver['url'])]; + $old = DBA::selectFirst('contact', [], $condition); + if (DBA::isResult($old)) { + $fields['gsid'] = $gserver['id']; + $fields['contact-type'] = Contact::TYPE_RELAY; + Logger::info('Assigning missing data for relay contact', ['server' => $gserver['url'], 'id' => $old['id']]); + } + } elseif (empty($fields)) { + Logger::info('No content to update, quitting', ['server' => $gserver['url']]); + return; + } + + if (DBA::isResult($old)) { + $fields['updated'] = DateTimeFormat::utcNow(); + + Logger::info('Update relay contact', ['server' => $gserver['url'], 'id' => $old['id'], 'fields' => $fields]); + DBA::update('contact', $fields, ['id' => $old['id']], $old); + } else { + $default = ['created' => DateTimeFormat::utcNow(), + 'name' => 'relay', 'nick' => 'relay', 'url' => $gserver['url'], + 'nurl' => Strings::normaliseLink($gserver['url']), + 'network' => Protocol::DIASPORA, 'uid' => 0, + 'batch' => $gserver['url'] . '/receive/public', + 'rel' => Contact::FOLLOWER, 'blocked' => false, + 'pending' => false, 'writable' => true, + 'gsid' => $gserver['id'], + 'baseurl' => $gserver['url'], 'contact-type' => Contact::TYPE_RELAY]; + + $fields = array_merge($default, $fields); + + Logger::info('Create relay contact', ['server' => $gserver['url'], 'fields' => $fields]); + Contact::insert($fields); + } + } + + /** + * Mark the relay contact of the given contact for archival + * This is called whenever there is a communication issue with the server. + * It avoids sending stuff to servers who don't exist anymore. + * The relay contact is a technical contact entry that exists once per server. + * + * @param array $contact of the relay contact + */ + public static function markForArchival(array $contact) + { + if (!empty($contact['contact-type']) && ($contact['contact-type'] == Contact::TYPE_RELAY)) { + // This is already the relay contact, we don't need to fetch it + $relay_contact = $contact; + } elseif (empty($contact['baseurl'])) { + if (!empty($contact['batch'])) { + $condition = ['uid' => 0, 'network' => Protocol::FEDERATED, 'batch' => $contact['batch'], 'contact-type' => Contact::TYPE_RELAY]; + $relay_contact = DBA::selectFirst('contact', [], $condition); + } else { + return; + } + } else { + $gserver = ['id' => $contact['gsid'] ?: GServer::getID($contact['baseurl'], true), + 'url' => $contact['baseurl'], 'network' => $contact['network']]; + $relay_contact = self::getContact($gserver, []); + } + + if (!empty($relay_contact)) { + Logger::info('Relay contact will be marked for archival', ['id' => $relay_contact['id'], 'url' => $relay_contact['url']]); + Contact::markForArchival($relay_contact); + } + } + + /** + * Return a list of relay servers + * + * The list contains not only the official relays but also servers that we serve directly + * + * @param integer $item_id id of the item that is sent + * @param array $contacts Previously fetched contacts + * @param array $networks Networks of the relay servers + * + * @return array of relay servers + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function getList(int $item_id, array $contacts, array $networks) + { + $serverlist = []; + + // Fetching relay servers + $serverdata = DI::config()->get("system", "relay_server"); + + if (!empty($serverdata)) { + $servers = explode(",", $serverdata); + foreach ($servers as $server) { + $gserver = DBA::selectFirst('gserver', ['id', 'url', 'network'], ['nurl' => Strings::normaliseLink($server)]); + if (DBA::isResult($gserver)) { + $serverlist[$gserver['id']] = $gserver; + } + } + } + + if (DI::config()->get("system", "relay_directly", false)) { + // We distribute our stuff based on the parent to ensure that the thread will be complete + $parent = Item::selectFirst(['uri-id'], ['id' => $item_id]); + if (!DBA::isResult($parent)) { + return; + } + + // Servers that want to get all content + $servers = DBA::select('gserver', ['id', 'url', 'network'], ['relay-subscribe' => true, 'relay-scope' => 'all']); + while ($server = DBA::fetch($servers)) { + $serverlist[$server['id']] = $server; + } + DBA::close($servers); + + // All tags of the current post + $tags = DBA::select('tag-view', ['name'], ['uri-id' => $parent['uri-id'], 'type' => Tag::HASHTAG]); + $taglist = []; + while ($tag = DBA::fetch($tags)) { + $taglist[] = $tag['name']; + } + DBA::close($tags); + + // All servers who wants content with this tag + $tagserverlist = []; + if (!empty($taglist)) { + $tagserver = DBA::select('gserver-tag', ['gserver-id'], ['tag' => $taglist]); + while ($server = DBA::fetch($tagserver)) { + $tagserverlist[] = $server['gserver-id']; + } + DBA::close($tagserver); + } + + // All adresses with the given id + if (!empty($tagserverlist)) { + $servers = DBA::select('gserver', ['id', 'url', 'network'], ['relay-subscribe' => true, 'relay-scope' => 'tags', 'id' => $tagserverlist]); + while ($server = DBA::fetch($servers)) { + $serverlist[$server['id']] = $server; + } + DBA::close($servers); + } + } + + // Now we are collecting all relay contacts + foreach ($serverlist as $gserver) { + // We don't send messages to ourselves + if (Strings::compareLink($gserver['url'], DI::baseUrl())) { + continue; + } + $contact = self::getContact($gserver); + if (empty($contact)) { + continue; + } + + if (in_array($contact['network'], $networks) && !in_array($contact['batch'], array_column($contacts, 'batch'))) { + $contacts[] = $contact; + } + } + + return $contacts; + } + + /** + * Return a contact for a given server address or creates a dummy entry + * + * @param array $gserver Global server record + * @param array $fields Fieldlist + * @return array with the contact + * @throws \Exception + */ + private static function getContact(array $gserver, array $fields = ['batch', 'id', 'url', 'name', 'network', 'protocol', 'archive', 'blocked']) + { + // Fetch the relay contact + $condition = ['uid' => 0, 'gsid' => $gserver['id'], 'contact-type' => Contact::TYPE_RELAY]; + $contact = DBA::selectFirst('contact', $fields, $condition); + if (DBA::isResult($contact)) { + if ($contact['archive'] || $contact['blocked']) { + return false; + } + return $contact; + } else { + self::updateContact($gserver); + + $contact = DBA::selectFirst('contact', $fields, $condition); + if (DBA::isResult($contact)) { + return $contact; + } + } + + // It should never happen that we arrive here + return []; + } +} diff --git a/src/Protocol/Salmon.php b/src/Protocol/Salmon.php index d082909ae5..169a4d0cbd 100644 --- a/src/Protocol/Salmon.php +++ b/src/Protocol/Salmon.php @@ -22,9 +22,9 @@ namespace Friendica\Protocol; use Friendica\Core\Logger; +use Friendica\DI; use Friendica\Network\Probe; use Friendica\Util\Crypto; -use Friendica\Util\Network; use Friendica\Util\Strings; use Friendica\Util\XML; @@ -72,13 +72,13 @@ class Salmon $ret[$x] = substr($ret[$x], 5); } } elseif (Strings::normaliseLink($ret[$x]) == 'http://') { - $ret[$x] = Network::fetchUrl($ret[$x]); + $ret[$x] = DI::httpRequest()->fetch($ret[$x]); } } } - Logger::log('Key located: ' . print_r($ret, true)); + Logger::notice('Key located', ['ret' => $ret]); if (count($ret) == 1) { // We only found one one key so we don't care if the hash matches. @@ -155,7 +155,7 @@ class Salmon $salmon = XML::fromArray($xmldata, $xml, false, $namespaces); // slap them - $postResult = Network::post($url, $salmon, [ + $postResult = DI::httpRequest()->post($url, $salmon, [ 'Content-type: application/magic-envelope+xml', 'Content-length: ' . strlen($salmon) ]); @@ -180,7 +180,7 @@ class Salmon $salmon = XML::fromArray($xmldata, $xml, false, $namespaces); // slap them - $postResult = Network::post($url, $salmon, [ + $postResult = DI::httpRequest()->post($url, $salmon, [ 'Content-type: application/magic-envelope+xml', 'Content-length: ' . strlen($salmon) ]); @@ -203,7 +203,7 @@ class Salmon $salmon = XML::fromArray($xmldata, $xml, false, $namespaces); // slap them - $postResult = Network::post($url, $salmon, [ + $postResult = DI::httpRequest()->post($url, $salmon, [ 'Content-type: application/magic-envelope+xml', 'Content-length: ' . strlen($salmon)]); $return_code = $postResult->getReturnCode(); @@ -229,7 +229,7 @@ class Salmon */ public static function salmonKey($pubkey) { - Crypto::pemToMe($pubkey, $m, $e); - return 'RSA' . '.' . Strings::base64UrlEncode($m, true) . '.' . Strings::base64UrlEncode($e, true); + Crypto::pemToMe($pubkey, $modulus, $exponent); + return 'RSA' . '.' . Strings::base64UrlEncode($modulus, true) . '.' . Strings::base64UrlEncode($exponent, true); } } diff --git a/src/Render/FriendicaSmarty.php b/src/Render/FriendicaSmarty.php index 2b06c88c91..5a1e7ed100 100644 --- a/src/Render/FriendicaSmarty.php +++ b/src/Render/FriendicaSmarty.php @@ -21,7 +21,6 @@ namespace Friendica\Render; -use Friendica\DI; use Smarty; use Friendica\Core\Renderer; @@ -34,26 +33,23 @@ class FriendicaSmarty extends Smarty public $filename; - function __construct() + function __construct(string $theme, array $theme_info) { parent::__construct(); - $a = DI::app(); - $theme = $a->getCurrentTheme(); - // setTemplateDir can be set to an array, which Smarty will parse in order. // The order is thus very important here $template_dirs = ['theme' => "view/theme/$theme/" . self::SMARTY3_TEMPLATE_FOLDER . "/"]; - if (!empty($a->theme_info['extends'])) { - $template_dirs = $template_dirs + ['extends' => "view/theme/" . $a->theme_info["extends"] . "/" . self::SMARTY3_TEMPLATE_FOLDER . "/"]; + if (!empty($theme_info['extends'])) { + $template_dirs = $template_dirs + ['extends' => "view/theme/" . $theme_info["extends"] . "/" . self::SMARTY3_TEMPLATE_FOLDER . "/"]; } $template_dirs = $template_dirs + ['base' => "view/" . self::SMARTY3_TEMPLATE_FOLDER . "/"]; $this->setTemplateDir($template_dirs); $this->setCompileDir('view/smarty3/compiled/'); - $this->setConfigDir('view/smarty3/config/'); - $this->setCacheDir('view/smarty3/cache/'); + $this->setConfigDir('view/smarty3/'); + $this->setCacheDir('view/smarty3/'); $this->left_delimiter = Renderer::getTemplateLeftDelimiter('smarty3'); $this->right_delimiter = Renderer::getTemplateRightDelimiter('smarty3'); @@ -63,13 +59,4 @@ class FriendicaSmarty extends Smarty // Don't report errors so verbosely $this->error_reporting = E_ALL & ~E_NOTICE; } - - function parsed($template = '') - { - if ($template) { - return $this->fetch('string:' . $template); - } - return $this->fetch('file:' . $this->filename); - } - -} \ No newline at end of file +} diff --git a/src/Render/FriendicaSmartyEngine.php b/src/Render/FriendicaSmartyEngine.php index 6984daa158..0f5ee21f27 100644 --- a/src/Render/FriendicaSmartyEngine.php +++ b/src/Render/FriendicaSmartyEngine.php @@ -23,56 +23,84 @@ namespace Friendica\Render; use Friendica\Core\Hook; use Friendica\DI; +use Friendica\Network\HTTPException\InternalServerErrorException; +use Friendica\Util\Strings; /** - * Smarty implementation of the Friendica template engine interface + * Smarty implementation of the Friendica template abstraction */ -class FriendicaSmartyEngine implements ITemplateEngine +final class FriendicaSmartyEngine extends TemplateEngine { static $name = "smarty3"; - public function __construct() + const FILE_PREFIX = 'file:'; + const STRING_PREFIX = 'string:'; + + /** @var FriendicaSmarty */ + private $smarty; + + /** + * @inheritDoc + */ + public function __construct(string $theme, array $theme_info) { - if (!is_writable(__DIR__ . '/../../view/smarty3/')) { - echo "ERROR: folder view/smarty3/ must be writable by webserver."; - exit(); + $this->theme = $theme; + $this->theme_info = $theme_info; + $this->smarty = new FriendicaSmarty($this->theme, $this->theme_info); + + if (!is_writable(DI::basePath() . '/view/smarty3')) { + $admin_message = DI::l10n()->t('The folder view/smarty3/ must be writable by webserver.'); + DI::logger()->critical($admin_message); + $message = is_site_admin() ? + $admin_message : + DI::l10n()->t('Friendica can\'t display this page at the moment, please contact the administrator.'); + throw new InternalServerErrorException($message); } } - // ITemplateEngine interface - public function replaceMacros($s, $r) + /** + * @inheritDoc + */ + public function testInstall(array &$errors = null) { - $template = ''; - if (gettype($s) === 'string') { - $template = $s; - $s = new FriendicaSmarty(); - } + $this->smarty->testInstall($errors); + } - $r['$APP'] = DI::app(); + /** + * @inheritDoc + */ + public function replaceMacros(string $template, array $vars) + { + if (!Strings::startsWith($template, self::FILE_PREFIX)) { + $template = self::STRING_PREFIX . $template; + } // "middleware": inject variables into templates $arr = [ - "template" => basename($s->filename), - "vars" => $r + 'template' => basename($this->smarty->filename), + 'vars' => $vars ]; - Hook::callAll("template_vars", $arr); - $r = $arr['vars']; + Hook::callAll('template_vars', $arr); + $vars = $arr['vars']; - foreach ($r as $key => $value) { + $this->smarty->clearAllAssign(); + + foreach ($vars as $key => $value) { if ($key[0] === '$') { $key = substr($key, 1); } - $s->assign($key, $value); + $this->smarty->assign($key, $value); } - return $s->parsed($template); + + return $this->smarty->fetch($template); } - public function getTemplateFile($file, $subDir = '') + /** + * @inheritDoc + */ + public function getTemplateFile(string $file, string $subDir = '') { - $a = DI::app(); - $template = new FriendicaSmarty(); - // Make sure $root ends with a slash / if ($subDir !== '' && substr($subDir, -1, 1) !== '/') { $subDir = $subDir . '/'; @@ -80,21 +108,20 @@ class FriendicaSmartyEngine implements ITemplateEngine $root = DI::basePath() . '/' . $subDir; - $theme = $a->getCurrentTheme(); - $filename = $template::SMARTY3_TEMPLATE_FOLDER . '/' . $file; + $filename = $this->smarty::SMARTY3_TEMPLATE_FOLDER . '/' . $file; - if (file_exists("{$root}view/theme/$theme/$filename")) { - $template_file = "{$root}view/theme/$theme/$filename"; - } elseif (!empty($a->theme_info['extends']) && file_exists(sprintf('%sview/theme/%s}/%s', $root, $a->theme_info['extends'], $filename))) { - $template_file = sprintf('%sview/theme/%s}/%s', $root, $a->theme_info['extends'], $filename); + if (file_exists("{$root}view/theme/$this->theme/$filename")) { + $template_file = "{$root}view/theme/$this->theme/$filename"; + } elseif (!empty($this->theme_info['extends']) && file_exists(sprintf('%sview/theme/%s}/%s', $root, $this->theme_info['extends'], $filename))) { + $template_file = sprintf('%sview/theme/%s}/%s', $root, $this->theme_info['extends'], $filename); } elseif (file_exists("{$root}/$filename")) { $template_file = "{$root}/$filename"; } else { $template_file = "{$root}view/$filename"; } - $template->filename = $template_file; + $this->smarty->filename = $template_file; - return $template; + return self::FILE_PREFIX . $template_file; } } diff --git a/src/Render/TemplateEngine.php b/src/Render/TemplateEngine.php new file mode 100644 index 0000000000..34ce03c5d2 --- /dev/null +++ b/src/Render/TemplateEngine.php @@ -0,0 +1,68 @@ +. + * + */ + +namespace Friendica\Render; + +/** + * Interface for template engines + */ +abstract class TemplateEngine +{ + /** @var string */ + static $name; + + /** @var string */ + protected $theme; + /** @var array */ + protected $theme_info; + + /** + * @param string $theme The current theme name + * @param array $theme_info The current theme info array + */ + abstract public function __construct(string $theme, array $theme_info); + + /** + * Checks the template engine is correctly installed and configured and reports error messages in the provided + * parameter or displays them directly if it's null. + * + * @param array|null $errors + */ + abstract public function testInstall(array &$errors = null); + + /** + * Returns the rendered template output from the template string and variables + * + * @param string $template + * @param array $vars + * @return string + */ + abstract public function replaceMacros(string $template, array $vars); + + /** + * Returns the template string from a file path and an optional sub-directory from the project root + * + * @param string $file + * @param string $subDir + * @return mixed + */ + abstract public function getTemplateFile(string $file, string $subDir = ''); +} diff --git a/src/Repository/FSuggest.php b/src/Repository/FSuggest.php index f7f6cef71b..c6c6b35601 100644 --- a/src/Repository/FSuggest.php +++ b/src/Repository/FSuggest.php @@ -78,16 +78,16 @@ class FSuggest extends BaseRepository } /** - * @param array $condition - * @param array $params + * @param array $condition + * @param array $params + * @param int|null $min_id * @param int|null $max_id - * @param int|null $since_id - * @param int $limit + * @param int $limit * @return Collection\FSuggests * @throws \Exception */ - public function selectByBoundaries(array $condition = [], array $params = [], int $max_id = null, int $since_id = null, int $limit = self::LIMIT) + public function selectByBoundaries(array $condition = [], array $params = [], int $min_id = null, int $max_id = null, int $limit = self::LIMIT) { - return parent::selectByBoundaries($condition, $params, $max_id, $since_id, $limit); + return parent::selectByBoundaries($condition, $params, $min_id, $max_id, $limit); } } diff --git a/src/Repository/Introduction.php b/src/Repository/Introduction.php index bde6edef6c..37e7637d8b 100644 --- a/src/Repository/Introduction.php +++ b/src/Repository/Introduction.php @@ -64,16 +64,16 @@ class Introduction extends BaseRepository } /** - * @param array $condition - * @param array $params + * @param array $condition + * @param array $params + * @param int|null $min_id * @param int|null $max_id - * @param int|null $since_id - * @param int $limit + * @param int $limit * @return Collection\Introductions * @throws \Exception */ - public function selectByBoundaries(array $condition = [], array $params = [], int $max_id = null, int $since_id = null, int $limit = self::LIMIT) + public function selectByBoundaries(array $condition = [], array $params = [], int $min_id = null, int $max_id = null, int $limit = self::LIMIT) { - return parent::selectByBoundaries($condition, $params, $max_id, $since_id, $limit); + return parent::selectByBoundaries($condition, $params, $min_id, $max_id, $limit); } } diff --git a/src/Repository/PermissionSet.php b/src/Repository/PermissionSet.php index ec0b91e0fe..c28dd0369c 100644 --- a/src/Repository/PermissionSet.php +++ b/src/Repository/PermissionSet.php @@ -25,7 +25,6 @@ use Friendica\BaseRepository; use Friendica\Collection; use Friendica\Database\Database; use Friendica\Model; -use Friendica\Model\Group; use Friendica\Network\HTTPException; use Friendica\Util\ACLFormatter; use Psr\Log\LoggerInterface; @@ -93,17 +92,17 @@ class PermissionSet extends BaseRepository } /** - * @param array $condition - * @param array $params + * @param array $condition + * @param array $params + * @param int|null $min_id * @param int|null $max_id - * @param int|null $since_id - * @param int $limit + * @param int $limit * @return Collection\PermissionSets * @throws \Exception */ - public function selectByBoundaries(array $condition = [], array $params = [], int $max_id = null, int $since_id = null, int $limit = self::LIMIT) + public function selectByBoundaries(array $condition = [], array $params = [], int $min_id = null, int $max_id = null, int $limit = self::LIMIT) { - return parent::selectByBoundaries($condition, $params, $max_id, $since_id, $limit); + return parent::selectByBoundaries($condition, $params, $min_id, $max_id, $limit); } /** @@ -162,9 +161,19 @@ class PermissionSet extends BaseRepository */ public function selectByContactId($contact_id, $uid) { + $cdata = Model\Contact::getPublicAndUserContacID($contact_id, $uid); + if (!empty($cdata)) { + $public_contact_str = '<' . $cdata['public'] . '>'; + $user_contact_str = '<' . $cdata['user'] . '>'; + $contact_id = $cdata['user']; + } else { + $public_contact_str = '<' . $contact_id . '>'; + $user_contact_str = ''; + } + $groups = []; - if ($this->dba->exists('contact', ['id' => $contact_id, 'uid' => $uid, 'blocked' => false])) { - $groups = Group::getIdsByContactId($contact_id); + if (!empty($user_contact_str) && $this->dba->exists('contact', ['id' => $contact_id, 'uid' => $uid, 'blocked' => false])) { + $groups = Model\Group::getIdsByContactId($contact_id); } $group_str = '<<>>'; // should be impossible to match @@ -172,11 +181,16 @@ class PermissionSet extends BaseRepository $group_str .= '|<' . preg_quote($group_id) . '>'; } - $contact_str = '<' . $contact_id . '>'; - - $condition = ["`uid` = ? AND (NOT (`deny_cid` REGEXP ? OR deny_gid REGEXP ?) - AND (allow_cid REGEXP ? OR allow_gid REGEXP ? OR (allow_cid = '' AND allow_gid = '')))", - $uid, $contact_str, $group_str, $contact_str, $group_str]; + if (!empty($user_contact_str)) { + $condition = ["`uid` = ? AND (NOT (`deny_cid` REGEXP ? OR `deny_cid` REGEXP ? OR deny_gid REGEXP ?) + AND (allow_cid REGEXP ? OR allow_cid REGEXP ? OR allow_gid REGEXP ? OR (allow_cid = '' AND allow_gid = '')))", + $uid, $user_contact_str, $public_contact_str, $group_str, + $user_contact_str, $public_contact_str, $group_str]; + } else { + $condition = ["`uid` = ? AND (NOT (`deny_cid` REGEXP ? OR deny_gid REGEXP ?) + AND (allow_cid REGEXP ? OR allow_gid REGEXP ? OR (allow_cid = '' AND allow_gid = '')))", + $uid, $public_contact_str, $group_str, $public_contact_str, $group_str]; + } return $this->select($condition); } diff --git a/src/Repository/ProfileField.php b/src/Repository/ProfileField.php index 7137191684..3f8b1599b0 100644 --- a/src/Repository/ProfileField.php +++ b/src/Repository/ProfileField.php @@ -87,17 +87,17 @@ class ProfileField extends BaseRepository } /** - * @param array $condition - * @param array $params + * @param array $condition + * @param array $params + * @param int|null $min_id * @param int|null $max_id - * @param int|null $since_id - * @param int $limit + * @param int $limit * @return Collection\ProfileFields * @throws \Exception */ - public function selectByBoundaries(array $condition = [], array $params = [], int $max_id = null, int $since_id = null, int $limit = self::LIMIT) + public function selectByBoundaries(array $condition = [], array $params = [], int $min_id = null, int $max_id = null, int $limit = self::LIMIT) { - return parent::selectByBoundaries($condition, $params, $max_id, $since_id, $limit); + return parent::selectByBoundaries($condition, $params, $min_id, $max_id, $limit); } /** diff --git a/src/App/Authentication.php b/src/Security/Authentication.php similarity index 97% rename from src/App/Authentication.php rename to src/Security/Authentication.php index 678bb0058c..5c6624a33f 100644 --- a/src/App/Authentication.php +++ b/src/Security/Authentication.php @@ -19,7 +19,7 @@ * */ -namespace Friendica\App; +namespace Friendica\Security; use Exception; use Friendica\App; @@ -41,7 +41,7 @@ use Friendica\Core\L10n; use Psr\Log\LoggerInterface; /** - * Handle Authentification, Session and Cookies + * Handle Authentication, Session and Cookies */ class Authentication { @@ -207,7 +207,7 @@ class Authentication // if it's an email address or doesn't resolve to a URL, fail. if ($noid || strpos($openid_url, '@') || !Network::isUrlValid($openid_url)) { - notice($this->l10n->t('Login failed.') . EOL); + notice($this->l10n->t('Login failed.')); $this->baseUrl->redirect(); } @@ -270,7 +270,7 @@ class Authentication } } catch (Exception $e) { $this->logger->warning('authenticate: failed login attempt', ['action' => 'login', 'username' => Strings::escapeTags($username), 'ip' => $_SERVER['REMOTE_ADDR']]); - info($this->l10n->t('Login failed. Please check your credentials.' . EOL)); + notice($this->l10n->t('Login failed. Please check your credentials.')); $this->baseUrl->redirect(); } @@ -374,7 +374,7 @@ class Authentication * that expires after one week (the default is when the browser is closed). * The cookie will be renewed automatically. * The week ensures that sessions will expire after some inactivity. - */; + */ if ($this->session->get('remember')) { $this->logger->info('Injecting cookie for remembered user ' . $user_record['nickname']); $this->cookie->set($user_record['uid'], $user_record['password'], $user_record['prvkey']); @@ -389,8 +389,6 @@ class Authentication info($this->l10n->t('Welcome %s', $user_record['username'])); info($this->l10n->t('Please upload a profile photo.')); $this->baseUrl->redirect('settings/profile/photo/new'); - } else { - info($this->l10n->t("Welcome back %s", $user_record['username'])); } } diff --git a/src/Util/ExAuth.php b/src/Security/ExAuth.php similarity index 79% rename from src/Util/ExAuth.php rename to src/Security/ExAuth.php index de13ee82f5..87f236d4ec 100644 --- a/src/Util/ExAuth.php +++ b/src/Security/ExAuth.php @@ -32,11 +32,17 @@ * */ -namespace Friendica\Util; +namespace Friendica\Security; -use Friendica\Database\DBA; +use Exception; +use Friendica\App; +use Friendica\Core\Config\IConfig; +use Friendica\Core\PConfig\IPConfig; +use Friendica\Database\Database; use Friendica\DI; use Friendica\Model\User; +use Friendica\Network\HTTPException; +use Friendica\Util\PidFile; class ExAuth { @@ -44,12 +50,43 @@ class ExAuth private $host; /** - * Create the class - * + * @var App\Mode */ - public function __construct() + private $appMode; + /** + * @var IConfig + */ + private $config; + /** + * @var IPConfig + */ + private $pConfig; + /** + * @var Database + */ + private $dba; + /** + * @var App\BaseURL + */ + private $baseURL; + + /** + * @param App\Mode $appMode + * @param IConfig $config + * @param IPConfig $pConfig + * @param Database $dba + * @param App\BaseURL $baseURL + * @throws Exception + */ + public function __construct(App\Mode $appMode, IConfig $config, IPConfig $pConfig, Database $dba, App\BaseURL $baseURL) { - $this->bDebug = (int) DI::config()->get('jabber', 'debug'); + $this->appMode = $appMode; + $this->config = $config; + $this->pConfig = $pConfig; + $this->dba = $dba; + $this->baseURL = $baseURL; + + $this->bDebug = (int)$config->get('jabber', 'debug'); openlog('auth_ejabberd', LOG_PID, LOG_USER); @@ -60,14 +97,18 @@ class ExAuth * Standard input reading function, executes the auth with the provided * parameters * - * @return null - * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public function readStdin() { + if (!$this->appMode->isNormal()) { + $this->writeLog(LOG_ERR, 'The node isn\'t ready.'); + return; + } + while (!feof(STDIN)) { // Quit if the database connection went down - if (!DBA::connected()) { + if (!$this->dba->isConnected()) { $this->writeLog(LOG_ERR, 'the database connection went down'); return; } @@ -123,7 +164,7 @@ class ExAuth * Check if the given username exists * * @param array $aCommand The command array - * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ private function isUser(array $aCommand) { @@ -142,9 +183,9 @@ class ExAuth $sUser = str_replace(['%20', '(a)'], [' ', '@'], $aCommand[1]); // Does the hostname match? So we try directly - if (DI::baseUrl()->getHostname() == $aCommand[2]) { + if ($this->baseURL->getHostname() == $aCommand[2]) { $this->writeLog(LOG_INFO, 'internal user check for ' . $sUser . '@' . $aCommand[2]); - $found = DBA::exists('user', ['nickname' => $sUser]); + $found = $this->dba->exists('user', ['nickname' => $sUser]); } else { $found = false; } @@ -173,7 +214,7 @@ class ExAuth * @param boolean $ssl Should the check be done via SSL? * * @return boolean Was the user found? - * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ private function checkUser($host, $user, $ssl) { @@ -181,7 +222,7 @@ class ExAuth $url = ($ssl ? 'https' : 'http') . '://' . $host . '/noscrape/' . $user; - $curlResult = Network::curl($url); + $curlResult = DI::httpRequest()->get($url); if (!$curlResult->isSuccess()) { return false; @@ -203,7 +244,7 @@ class ExAuth * Authenticate the given user and password * * @param array $aCommand The command array - * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws Exception */ private function auth(array $aCommand) { @@ -221,35 +262,29 @@ class ExAuth // We now check if the password match $sUser = str_replace(['%20', '(a)'], [' ', '@'], $aCommand[1]); + $Error = false; // Does the hostname match? So we try directly - if (DI::baseUrl()->getHostname() == $aCommand[2]) { - $this->writeLog(LOG_INFO, 'internal auth for ' . $sUser . '@' . $aCommand[2]); - - $aUser = DBA::selectFirst('user', ['uid', 'password', 'legacy_password'], ['nickname' => $sUser]); - if (DBA::isResult($aUser)) { - $uid = $aUser['uid']; - $success = User::authenticate($aUser, $aCommand[3], true); - $Error = $success === false; - } else { - $this->writeLog(LOG_WARNING, 'user not found: ' . $sUser); - $Error = true; - $uid = -1; - } - if ($Error) { + if ($this->baseURL->getHostname() == $aCommand[2]) { + try { + $this->writeLog(LOG_INFO, 'internal auth for ' . $sUser . '@' . $aCommand[2]); + User::getIdFromPasswordAuthentication($sUser, $aCommand[3], true); + } catch (HTTPException\ForbiddenException $ex) { + // User exists, authentication failed $this->writeLog(LOG_INFO, 'check against alternate password for ' . $sUser . '@' . $aCommand[2]); - $sPassword = DI::pConfig()->get($uid, 'xmpp', 'password', null, true); + $aUser = User::getByNickname($sUser, ['uid']); + $sPassword = $this->pConfig->get($aUser['uid'], 'xmpp', 'password', null, true); $Error = ($aCommand[3] != $sPassword); + } catch (\Throwable $ex) { + // User doesn't exist and any other failure case + $this->writeLog(LOG_WARNING, $ex->getMessage() . ': ' . $sUser); + $Error = true; } } else { $Error = true; } // If the hostnames doesn't match or there is some failure, we try to check remotely - if ($Error) { - $Error = !$this->checkCredentials($aCommand[2], $aCommand[1], $aCommand[3], true); - } - - if ($Error) { + if ($Error && !$this->checkCredentials($aCommand[2], $aCommand[1], $aCommand[3], true)) { $this->writeLog(LOG_WARNING, 'authentification failed for user ' . $sUser . '@' . $aCommand[2]); fwrite(STDOUT, pack('nn', 2, 0)); } else { @@ -297,7 +332,6 @@ class ExAuth * Set the hostname for this process * * @param string $host The hostname - * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ private function setHost($host) { @@ -309,7 +343,7 @@ class ExAuth $this->host = $host; - $lockpath = DI::config()->get('jabber', 'lockpath'); + $lockpath = $this->config->get('jabber', 'lockpath'); if (is_null($lockpath)) { $this->writeLog(LOG_INFO, 'No lockpath defined.'); return; diff --git a/src/Network/FKOAuth1.php b/src/Security/FKOAuth1.php similarity index 81% rename from src/Network/FKOAuth1.php rename to src/Security/FKOAuth1.php index 642fab111a..d7549a1e7c 100644 --- a/src/Network/FKOAuth1.php +++ b/src/Security/FKOAuth1.php @@ -19,14 +19,14 @@ * */ -namespace Friendica\Network; +namespace Friendica\Security; use Friendica\Core\Logger; use Friendica\Database\DBA; use Friendica\DI; -use OAuthServer; -use OAuthSignatureMethod_HMAC_SHA1; -use OAuthSignatureMethod_PLAINTEXT; +use Friendica\Security\OAuth1\OAuthServer; +use Friendica\Security\OAuth1\Signature\OAuthSignatureMethod_HMAC_SHA1; +use Friendica\Security\OAuth1\Signature\OAuthSignatureMethod_PLAINTEXT; /** * OAuth protocol @@ -51,12 +51,12 @@ class FKOAuth1 extends OAuthServer */ public function loginUser($uid) { - Logger::log("FKOAuth1::loginUser $uid"); + Logger::notice("FKOAuth1::loginUser $uid"); $a = DI::app(); $record = DBA::selectFirst('user', [], ['uid' => $uid, 'blocked' => 0, 'account_expired' => 0, 'account_removed' => 0, 'verified' => 1]); - if (!DBA::isResult($record)) { - Logger::log('FKOAuth1::loginUser failure: ' . print_r($_SERVER, true), Logger::DEBUG); + if (!DBA::isResult($record) || empty($uid)) { + Logger::info('FKOAuth1::loginUser failure', ['server' => $_SERVER]); header('HTTP/1.0 401 Unauthorized'); die('This api requires login'); } diff --git a/src/Network/FKOAuthDataStore.php b/src/Security/FKOAuthDataStore.php similarity index 95% rename from src/Network/FKOAuthDataStore.php rename to src/Security/FKOAuthDataStore.php index ee9a709152..972e00555c 100644 --- a/src/Network/FKOAuthDataStore.php +++ b/src/Security/FKOAuthDataStore.php @@ -19,21 +19,21 @@ * */ -namespace Friendica\Network; +namespace Friendica\Security; use Friendica\Core\Logger; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Util\Strings; -use OAuthConsumer; -use OAuthDataStore; -use OAuthToken; +use Friendica\Security\OAuth1\OAuthConsumer; +use Friendica\Security\OAuth1\OAuthDataStore; +use Friendica\Security\OAuth1\OAuthToken; define('REQUEST_TOKEN_DURATION', 300); define('ACCESS_TOKEN_DURATION', 31536000); /** - * OAuthDataStore class + * Friendica\Security\OAuth1\OAuthDataStore class */ class FKOAuthDataStore extends OAuthDataStore { diff --git a/src/Security/OAuth1/OAuthConsumer.php b/src/Security/OAuth1/OAuthConsumer.php new file mode 100644 index 0000000000..9ebff3eb67 --- /dev/null +++ b/src/Security/OAuth1/OAuthConsumer.php @@ -0,0 +1,22 @@ +key = $key; + $this->secret = $secret; + $this->callback_url = $callback_url; + } + + function __toString() + { + return "OAuthConsumer[key=$this->key,secret=$this->secret]"; + } +} diff --git a/src/Security/OAuth1/OAuthDataStore.php b/src/Security/OAuth1/OAuthDataStore.php new file mode 100644 index 0000000000..f4164255cd --- /dev/null +++ b/src/Security/OAuth1/OAuthDataStore.php @@ -0,0 +1,34 @@ +parameters = $parameters; + $this->http_method = $http_method; + $this->http_url = $http_url; + } + + + /** + * attempt to build up a request from what was passed to the server + * + * @param string|null $http_method + * @param string|null $http_url + * @param string|null $parameters + * + * @return OAuthRequest + */ + public static function from_request($http_method = null, $http_url = null, $parameters = null) + { + $scheme = (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] != "on") + ? 'http' + : 'https'; + @$http_url or $http_url = $scheme . + '://' . $_SERVER['HTTP_HOST'] . + ':' . + $_SERVER['SERVER_PORT'] . + $_SERVER['REQUEST_URI']; + @$http_method or $http_method = $_SERVER['REQUEST_METHOD']; + + // We weren't handed any parameters, so let's find the ones relevant to + // this request. + // If you run XML-RPC or similar you should use this to provide your own + // parsed parameter-list + if (!$parameters) { + // Find request headers + $request_headers = OAuthUtil::get_headers(); + + // Parse the query-string to find GET parameters + $parameters = OAuthUtil::parse_parameters($_SERVER['QUERY_STRING']); + + // It's a POST request of the proper content-type, so parse POST + // parameters and add those overriding any duplicates from GET + if ( + $http_method == "POST" + && @strstr( + $request_headers["Content-Type"], + "application/x-www-form-urlencoded" + ) + ) { + $post_data = OAuthUtil::parse_parameters( + file_get_contents(self::$POST_INPUT) + ); + $parameters = array_merge($parameters, $post_data); + } + + // We have a Authorization-header with OAuth data. Parse the header + // and add those overriding any duplicates from GET or POST + if (@substr($request_headers['Authorization'], 0, 6) == "OAuth ") { + $header_parameters = OAuthUtil::split_header( + $request_headers['Authorization'] + ); + $parameters = array_merge($parameters, $header_parameters); + } + } + // fix for friendica redirect system + + $http_url = substr($http_url, 0, strpos($http_url, $parameters['pagename']) + strlen($parameters['pagename'])); + unset($parameters['pagename']); + + return new OAuthRequest($http_method, $http_url, $parameters); + } + + /** + * pretty much a helper function to set up the request + * + * @param OAuthConsumer $consumer + * @param OAuthToken $token + * @param string $http_method + * @param string $http_url + * @param array|null $parameters + * + * @return OAuthRequest + */ + public static function from_consumer_and_token(OAuthConsumer $consumer, $http_method, $http_url, array $parameters = null, OAuthToken $token = null) + { + @$parameters or $parameters = []; + $defaults = [ + "oauth_version" => OAuthRequest::$version, + "oauth_nonce" => OAuthRequest::generate_nonce(), + "oauth_timestamp" => OAuthRequest::generate_timestamp(), + "oauth_consumer_key" => $consumer->key, + ]; + if ($token) + $defaults['oauth_token'] = $token->key; + + $parameters = array_merge($defaults, $parameters); + + return new OAuthRequest($http_method, $http_url, $parameters); + } + + public function set_parameter($name, $value, $allow_duplicates = true) + { + if ($allow_duplicates && isset($this->parameters[$name])) { + // We have already added parameter(s) with this name, so add to the list + if (is_scalar($this->parameters[$name])) { + // This is the first duplicate, so transform scalar (string) + // into an array so we can add the duplicates + $this->parameters[$name] = [$this->parameters[$name]]; + } + + $this->parameters[$name][] = $value; + } else { + $this->parameters[$name] = $value; + } + } + + public function get_parameter($name) + { + return isset($this->parameters[$name]) ? $this->parameters[$name] : null; + } + + public function get_parameters() + { + return $this->parameters; + } + + public function unset_parameter($name) + { + unset($this->parameters[$name]); + } + + /** + * The request parameters, sorted and concatenated into a normalized string. + * + * @return string + */ + public function get_signable_parameters() + { + // Grab all parameters + $params = $this->parameters; + + // Remove oauth_signature if present + // Ref: Spec: 9.1.1 ("The oauth_signature parameter MUST be excluded.") + if (isset($params['oauth_signature'])) { + unset($params['oauth_signature']); + } + + return OAuthUtil::build_http_query($params); + } + + /** + * Returns the base string of this request + * + * The base string defined as the method, the url + * and the parameters (normalized), each urlencoded + * and the concated with &. + */ + public function get_signature_base_string() + { + $parts = [ + $this->get_normalized_http_method(), + $this->get_normalized_http_url(), + $this->get_signable_parameters(), + ]; + + $parts = OAuthUtil::urlencode_rfc3986($parts); + + return implode('&', $parts); + } + + /** + * just uppercases the http method + */ + public function get_normalized_http_method() + { + return strtoupper($this->http_method); + } + + /** + * parses the url and rebuilds it to be + * scheme://host/path + */ + public function get_normalized_http_url() + { + $parts = parse_url($this->http_url); + + $port = @$parts['port']; + $scheme = $parts['scheme']; + $host = $parts['host']; + $path = @$parts['path']; + + $port or $port = ($scheme == 'https') ? '443' : '80'; + + if (($scheme == 'https' && $port != '443') + || ($scheme == 'http' && $port != '80') + ) { + $host = "$host:$port"; + } + return "$scheme://$host$path"; + } + + /** + * builds a url usable for a GET request + */ + public function to_url() + { + $post_data = $this->to_postdata(); + $out = $this->get_normalized_http_url(); + if ($post_data) { + $out .= '?' . $post_data; + } + return $out; + } + + /** + * builds the data one would send in a POST request + * + * @param bool $raw + * + * @return array|string + */ + public function to_postdata(bool $raw = false) + { + if ($raw) + return $this->parameters; + else + return OAuthUtil::build_http_query($this->parameters); + } + + /** + * builds the Authorization: header + * + * @param string|null $realm + * + * @return string + * @throws OAuthException + */ + public function to_header($realm = null) + { + $first = true; + if ($realm) { + $out = 'Authorization: OAuth realm="' . OAuthUtil::urlencode_rfc3986($realm) . '"'; + $first = false; + } else + $out = 'Authorization: OAuth'; + + foreach ($this->parameters as $k => $v) { + if (substr($k, 0, 5) != "oauth") continue; + if (is_array($v)) { + throw new OAuthException('Arrays not supported in headers'); + } + $out .= ($first) ? ' ' : ','; + $out .= OAuthUtil::urlencode_rfc3986($k) . + '="' . + OAuthUtil::urlencode_rfc3986($v) . + '"'; + $first = false; + } + return $out; + } + + public function __toString() + { + return $this->to_url(); + } + + + public function sign_request(Signature\OAuthSignatureMethod $signature_method, $consumer, $token) + { + $this->set_parameter( + "oauth_signature_method", + $signature_method->get_name(), + false + ); + $signature = $this->build_signature($signature_method, $consumer, $token); + $this->set_parameter("oauth_signature", $signature, false); + } + + public function build_signature(Signature\OAuthSignatureMethod $signature_method, $consumer, $token) + { + $signature = $signature_method->build_signature($this, $consumer, $token); + return $signature; + } + + /** + * util function: current timestamp + */ + private static function generate_timestamp() + { + return time(); + } + + /** + * util function: current nonce + */ + private static function generate_nonce() + { + return Strings::getRandomHex(32); + } +} diff --git a/src/Security/OAuth1/OAuthServer.php b/src/Security/OAuth1/OAuthServer.php new file mode 100644 index 0000000000..c8884f6336 --- /dev/null +++ b/src/Security/OAuth1/OAuthServer.php @@ -0,0 +1,290 @@ +data_store = $data_store; + } + + public function add_signature_method(Signature\OAuthSignatureMethod $signature_method) + { + $this->signature_methods[$signature_method->get_name()] = + $signature_method; + } + + // high level functions + + /** + * process a request_token request + * returns the request token on success + * + * @param OAuthRequest $request + * + * @return OAuthToken|null + * @throws OAuthException + */ + public function fetch_request_token(OAuthRequest $request) + { + $this->get_version($request); + + $consumer = $this->get_consumer($request); + + // no token required for the initial token request + $token = null; + + $this->check_signature($request, $consumer, $token); + + // Rev A change + $callback = $request->get_parameter('oauth_callback'); + $new_token = $this->data_store->new_request_token($consumer, $callback); + + return $new_token; + } + + /** + * process an access_token request + * returns the access token on success + * + * @param OAuthRequest $request + * + * @return object + * @throws OAuthException + */ + public function fetch_access_token(OAuthRequest $request) + { + $this->get_version($request); + + $consumer = $this->get_consumer($request); + + // requires authorized request token + $token = $this->get_token($request, $consumer, "request"); + + $this->check_signature($request, $consumer, $token); + + // Rev A change + $verifier = $request->get_parameter('oauth_verifier'); + $new_token = $this->data_store->new_access_token($token, $consumer, $verifier); + + return $new_token; + } + + /** + * verify an api call, checks all the parameters + * + * @param OAuthRequest $request + * + * @return array + * @throws OAuthException + */ + public function verify_request(OAuthRequest $request) + { + $this->get_version($request); + $consumer = $this->get_consumer($request); + $token = $this->get_token($request, $consumer, "access"); + $this->check_signature($request, $consumer, $token); + return [$consumer, $token]; + } + + // Internals from here + + /** + * version 1 + * + * @param OAuthRequest $request + * + * @return string + * @throws OAuthException + */ + private function get_version(OAuthRequest $request) + { + $version = $request->get_parameter("oauth_version"); + if (!$version) { + // Service Providers MUST assume the protocol version to be 1.0 if this parameter is not present. + // Chapter 7.0 ("Accessing Protected Ressources") + $version = '1.0'; + } + if ($version !== $this->version) { + throw new OAuthException("OAuth version '$version' not supported"); + } + return $version; + } + + /** + * figure out the signature with some defaults + * + * @param OAuthRequest $request + * + * @return Signature\OAuthSignatureMethod + * @throws OAuthException + */ + private function get_signature_method(OAuthRequest $request) + { + $signature_method = + @$request->get_parameter("oauth_signature_method"); + + if (!$signature_method) { + // According to chapter 7 ("Accessing Protected Ressources") the signature-method + // parameter is required, and we can't just fallback to PLAINTEXT + throw new OAuthException('No signature method parameter. This parameter is required'); + } + + if (!in_array( + $signature_method, + array_keys($this->signature_methods) + )) { + throw new OAuthException( + "Signature method '$signature_method' not supported " . + "try one of the following: " . + implode(", ", array_keys($this->signature_methods)) + ); + } + return $this->signature_methods[$signature_method]; + } + + /** + * try to find the consumer for the provided request's consumer key + * + * @param OAuthRequest $request + * + * @return OAuthConsumer + * @throws OAuthException + */ + private function get_consumer(OAuthRequest $request) + { + $consumer_key = @$request->get_parameter("oauth_consumer_key"); + if (!$consumer_key) { + throw new OAuthException("Invalid consumer key"); + } + + $consumer = $this->data_store->lookup_consumer($consumer_key); + if (!$consumer) { + throw new OAuthException("Invalid consumer"); + } + + return $consumer; + } + + /** + * try to find the token for the provided request's token key + * + * @param OAuthRequest $request + * @param $consumer + * @param string $token_type + * + * @return OAuthToken|null + * @throws OAuthException + */ + private function get_token(OAuthRequest &$request, $consumer, $token_type = "access") + { + $token_field = @$request->get_parameter('oauth_token'); + $token = $this->data_store->lookup_token( + $consumer, + $token_type, + $token_field + ); + if (!$token) { + throw new OAuthException("Invalid $token_type token: $token_field"); + } + return $token; + } + + /** + * all-in-one function to check the signature on a request + * should guess the signature method appropriately + * + * @param OAuthRequest $request + * @param OAuthConsumer $consumer + * @param OAuthToken|null $token + * + * @throws OAuthException + */ + private function check_signature(OAuthRequest $request, OAuthConsumer $consumer, OAuthToken $token = null) + { + // this should probably be in a different method + $timestamp = @$request->get_parameter('oauth_timestamp'); + $nonce = @$request->get_parameter('oauth_nonce'); + + $this->check_timestamp($timestamp); + $this->check_nonce($consumer, $token, $nonce, $timestamp); + + $signature_method = $this->get_signature_method($request); + + $signature = $request->get_parameter('oauth_signature'); + $valid_sig = $signature_method->check_signature( + $request, + $consumer, + $signature, + $token + ); + + if (!$valid_sig) { + throw new OAuthException("Invalid signature"); + } + } + + /** + * check that the timestamp is new enough + * + * @param int $timestamp + * + * @throws OAuthException + */ + private function check_timestamp($timestamp) + { + if (!$timestamp) + throw new OAuthException( + 'Missing timestamp parameter. The parameter is required' + ); + + // verify that timestamp is recentish + $now = time(); + if (abs($now - $timestamp) > $this->timestamp_threshold) { + throw new OAuthException( + "Expired timestamp, yours $timestamp, ours $now" + ); + } + } + + /** + * check that the nonce is not repeated + * + * @param OAuthConsumer $consumer + * @param OAuthToken $token + * @param string $nonce + * @param int $timestamp + * + * @throws OAuthException + */ + private function check_nonce(OAuthConsumer $consumer, OAuthToken $token, $nonce, int $timestamp) + { + if (!$nonce) + throw new OAuthException( + 'Missing nonce parameter. The parameter is required' + ); + + // verify that the nonce is uniqueish + $found = $this->data_store->lookup_nonce( + $consumer, + $token, + $nonce, + $timestamp + ); + if ($found) { + throw new OAuthException("Nonce already used: $nonce"); + } + } +} diff --git a/src/Security/OAuth1/OAuthToken.php b/src/Security/OAuth1/OAuthToken.php new file mode 100644 index 0000000000..749229e29b --- /dev/null +++ b/src/Security/OAuth1/OAuthToken.php @@ -0,0 +1,44 @@ +key = $key; + $this->secret = $secret; + } + + /** + * generates the basic string serialization of a token that a server + * would respond to request_token and access_token calls with + */ + function to_string() + { + return "oauth_token=" . + OAuthUtil::urlencode_rfc3986($this->key) . + "&oauth_token_secret=" . + OAuthUtil::urlencode_rfc3986($this->secret); + } + + function __toString() + { + return $this->to_string(); + } +} diff --git a/src/Security/OAuth1/OAuthUtil.php b/src/Security/OAuth1/OAuthUtil.php new file mode 100644 index 0000000000..7f6fbadffe --- /dev/null +++ b/src/Security/OAuth1/OAuthUtil.php @@ -0,0 +1,166 @@ + 0) { + $match = $matches[0]; + $header_name = $matches[2][0]; + $header_content = (isset($matches[5])) ? $matches[5][0] : $matches[4][0]; + if (preg_match('/^oauth_/', $header_name) || !$only_allow_oauth_parameters) { + $params[$header_name] = OAuthUtil::urldecode_rfc3986($header_content); + } + $offset = $match[1] + strlen($match[0]); + } + + if (isset($params['realm'])) { + unset($params['realm']); + } + + return $params; + } + + // helper to try to sort out headers for people who aren't running apache + public static function get_headers() + { + if (function_exists('apache_request_headers')) { + // we need this to get the actual Authorization: header + // because apache tends to tell us it doesn't exist + $headers = apache_request_headers(); + + // sanitize the output of apache_request_headers because + // we always want the keys to be Cased-Like-This and arh() + // returns the headers in the same case as they are in the + // request + $out = []; + foreach ($headers as $key => $value) { + $key = str_replace( + " ", + "-", + ucwords(strtolower(str_replace("-", " ", $key))) + ); + $out[$key] = $value; + } + } else { + // otherwise we don't have apache and are just going to have to hope + // that $_SERVER actually contains what we need + $out = []; + if (isset($_SERVER['CONTENT_TYPE'])) + $out['Content-Type'] = $_SERVER['CONTENT_TYPE']; + if (isset($_ENV['CONTENT_TYPE'])) + $out['Content-Type'] = $_ENV['CONTENT_TYPE']; + + foreach ($_SERVER as $key => $value) { + if (substr($key, 0, 5) == "HTTP_") { + // this is chaos, basically it is just there to capitalize the first + // letter of every word that is not an initial HTTP and strip HTTP + // code from przemek + $key = str_replace( + " ", + "-", + ucwords(strtolower(str_replace("_", " ", substr($key, 5)))) + ); + $out[$key] = $value; + } + } + } + return $out; + } + + // This function takes a input like a=b&a=c&d=e and returns the parsed + // parameters like this + // array('a' => array('b','c'), 'd' => 'e') + public static function parse_parameters($input) + { + if (!isset($input) || !$input) return []; + + $pairs = explode('&', $input); + + $parsed_parameters = []; + foreach ($pairs as $pair) { + $split = explode('=', $pair, 2); + $parameter = OAuthUtil::urldecode_rfc3986($split[0]); + $value = isset($split[1]) ? OAuthUtil::urldecode_rfc3986($split[1]) : ''; + + if (isset($parsed_parameters[$parameter])) { + // We have already recieved parameter(s) with this name, so add to the list + // of parameters with this name + + if (is_scalar($parsed_parameters[$parameter])) { + // This is the first duplicate, so transform scalar (string) into an array + // so we can add the duplicates + $parsed_parameters[$parameter] = [$parsed_parameters[$parameter]]; + } + + $parsed_parameters[$parameter][] = $value; + } else { + $parsed_parameters[$parameter] = $value; + } + } + return $parsed_parameters; + } + + public static function build_http_query($params) + { + if (!$params) return ''; + + // Urlencode both keys and values + $keys = OAuthUtil::urlencode_rfc3986(array_keys($params)); + $values = OAuthUtil::urlencode_rfc3986(array_values($params)); + $params = array_combine($keys, $values); + + // Parameters are sorted by name, using lexicographical byte value ordering. + // Ref: Spec: 9.1.1 (1) + uksort($params, 'strcmp'); + + $pairs = []; + foreach ($params as $parameter => $value) { + if (is_array($value)) { + // If two or more parameters share the same name, they are sorted by their value + // Ref: Spec: 9.1.1 (1) + natsort($value); + foreach ($value as $duplicate_value) { + $pairs[] = $parameter . '=' . $duplicate_value; + } + } else { + $pairs[] = $parameter . '=' . $value; + } + } + // For each parameter, the name is separated from the corresponding value by an '=' character (ASCII code 61) + // Each name-value pair is separated by an '&' character (ASCII code 38) + return implode('&', $pairs); + } +} diff --git a/src/Security/OAuth1/README.md b/src/Security/OAuth1/README.md new file mode 100644 index 0000000000..ba44e9fcae --- /dev/null +++ b/src/Security/OAuth1/README.md @@ -0,0 +1 @@ +This namespace contains the OAuth1 library for server and client usages \ No newline at end of file diff --git a/src/Security/OAuth1/Signature/OAuthSignatureMethod.php b/src/Security/OAuth1/Signature/OAuthSignatureMethod.php new file mode 100644 index 0000000000..52b10631fe --- /dev/null +++ b/src/Security/OAuth1/Signature/OAuthSignatureMethod.php @@ -0,0 +1,49 @@ +build_signature($request, $consumer, $token); + return ($built == $signature); + } +} diff --git a/src/Security/OAuth1/Signature/OAuthSignatureMethod_HMAC_SHA1.php b/src/Security/OAuth1/Signature/OAuthSignatureMethod_HMAC_SHA1.php new file mode 100644 index 0000000000..b7afe1d4c2 --- /dev/null +++ b/src/Security/OAuth1/Signature/OAuthSignatureMethod_HMAC_SHA1.php @@ -0,0 +1,46 @@ +get_signature_base_string(); + $request->base_string = $base_string; + + $key_parts = [ + $consumer->secret, + ($token) ? $token->secret : "", + ]; + + $key_parts = OAuthUtil::urlencode_rfc3986($key_parts); + $key = implode('&', $key_parts); + + + $r = base64_encode(hash_hmac('sha1', $base_string, $key, true)); + return $r; + } +} diff --git a/src/Security/OAuth1/Signature/OAuthSignatureMethod_PLAINTEXT.php b/src/Security/OAuth1/Signature/OAuthSignatureMethod_PLAINTEXT.php new file mode 100644 index 0000000000..acf5e0e0dd --- /dev/null +++ b/src/Security/OAuth1/Signature/OAuthSignatureMethod_PLAINTEXT.php @@ -0,0 +1,48 @@ +secret, + ($token) ? $token->secret : "", + ]; + + $key_parts = OAuthUtil::urlencode_rfc3986($key_parts); + $key = implode('&', $key_parts); + $request->base_string = $key; + + return $key; + } +} diff --git a/src/Security/OAuth1/Signature/OAuthSignatureMethod_RSA_SHA1.php b/src/Security/OAuth1/Signature/OAuthSignatureMethod_RSA_SHA1.php new file mode 100644 index 0000000000..1d60aadda8 --- /dev/null +++ b/src/Security/OAuth1/Signature/OAuthSignatureMethod_RSA_SHA1.php @@ -0,0 +1,76 @@ +get_signature_base_string(); + $request->base_string = $base_string; + + // Fetch the private key cert based on the request + $cert = $this->fetch_private_cert($request); + + // Pull the private key ID from the certificate + $privatekeyid = openssl_get_privatekey($cert); + + // Sign using the key + openssl_sign($base_string, $signature, $privatekeyid); + + // Release the key resource + openssl_free_key($privatekeyid); + + return base64_encode($signature); + } + + public function check_signature(OAuthRequest $request, \Friendica\Security\OAuth1\OAuthConsumer $consumer, $signature, \Friendica\Security\OAuth1\OAuthToken $token = null) + { + $decoded_sig = base64_decode($signature); + + $base_string = $request->get_signature_base_string(); + + // Fetch the public key cert based on the request + $cert = $this->fetch_public_cert($request); + + // Pull the public key ID from the certificate + $publickeyid = openssl_get_publickey($cert); + + // Check the computed signature against the one passed in the query + $ok = openssl_verify($base_string, $decoded_sig, $publickeyid); + + // Release the key resource + openssl_free_key($publickeyid); + + return $ok == 1; + } +} diff --git a/src/Util/Security.php b/src/Security/Security.php similarity index 99% rename from src/Util/Security.php rename to src/Security/Security.php index 4233382160..a75b9168b5 100644 --- a/src/Util/Security.php +++ b/src/Security/Security.php @@ -19,7 +19,7 @@ * */ -namespace Friendica\Util; +namespace Friendica\Security; use Friendica\Database\DBA; use Friendica\Model\Contact; diff --git a/src/Util/ACLFormatter.php b/src/Util/ACLFormatter.php index 7719daf456..0c53e08c93 100644 --- a/src/Util/ACLFormatter.php +++ b/src/Util/ACLFormatter.php @@ -84,7 +84,7 @@ final class ACLFormatter private function sanitizeItem(string &$item) { // The item is an ACL int value if (intval($item)) { - $item = '<' . intval(Strings::escapeTags(trim($item))) . '>'; + $item = '<' . intval($item) . '>'; // The item is a allowed ACL character } elseif (in_array($item, [Group::FOLLOWERS, Group::MUTUALS])) { $item = '<' . $item . '>'; diff --git a/src/Util/ConfigFileLoader.php b/src/Util/ConfigFileLoader.php index fc6685946c..60e82c04f1 100644 --- a/src/Util/ConfigFileLoader.php +++ b/src/Util/ConfigFileLoader.php @@ -97,27 +97,30 @@ class ConfigFileLoader * expected local.config.php * * @param Cache $config The config cache to load to + * @param array $server The $_SERVER array * @param bool $raw Setup the raw config format * * @throws Exception */ - public function setupCache(Cache $config, $raw = false) + public function setupCache(Cache $config, array $server = [], $raw = false) { // Load static config files first, the order is important - $config->load($this->loadStaticConfig('defaults')); - $config->load($this->loadStaticConfig('settings')); + $config->load($this->loadStaticConfig('defaults'), Cache::SOURCE_FILE); + $config->load($this->loadStaticConfig('settings'), Cache::SOURCE_FILE); // try to load the legacy config first - $config->load($this->loadLegacyConfig('htpreconfig'), true); - $config->load($this->loadLegacyConfig('htconfig'), true); + $config->load($this->loadLegacyConfig('htpreconfig'), Cache::SOURCE_FILE); + $config->load($this->loadLegacyConfig('htconfig'), Cache::SOURCE_FILE); // Now load every other config you find inside the 'config/' directory $this->loadCoreConfig($config); + $config->load($this->loadEnvConfig($server), Cache::SOURCE_ENV); + // In case of install mode, add the found basepath (because there isn't a basepath set yet if (!$raw && empty($config->get('system', 'basepath'))) { // Setting at least the basepath we know - $config->set('system', 'basepath', $this->baseDir); + $config->set('system', 'basepath', $this->baseDir, Cache::SOURCE_FILE); } } @@ -157,12 +160,12 @@ class ConfigFileLoader { // try to load legacy ini-files first foreach ($this->getConfigFiles(true) as $configFile) { - $config->load($this->loadINIConfigFile($configFile), true); + $config->load($this->loadINIConfigFile($configFile), Cache::SOURCE_FILE); } // try to load supported config at last to overwrite it foreach ($this->getConfigFiles() as $configFile) { - $config->load($this->loadConfigFile($configFile), true); + $config->load($this->loadConfigFile($configFile), Cache::SOURCE_FILE); } return []; @@ -192,6 +195,38 @@ class ConfigFileLoader } } + /** + * Tries to load environment specific variables, based on the `env.config.php` mapping table + * + * @param array $server The $_SERVER variable + * + * @return array The config array (empty if no config was found) + * + * @throws Exception if the configuration file isn't readable + */ + public function loadEnvConfig(array $server) + { + $filepath = $this->baseDir . DIRECTORY_SEPARATOR . // /var/www/html/ + self::STATIC_DIR . DIRECTORY_SEPARATOR . // static/ + "env.config.php"; // env.config.php + + if (!file_exists($filepath)) { + return []; + } + + $envConfig = $this->loadConfigFile($filepath); + + $return = []; + + foreach ($envConfig as $envKey => $configStructure) { + if (isset($server[$envKey])) { + $return[$configStructure[0]][$configStructure[1]] = $server[$envKey]; + } + } + + return $return; + } + /** * Get the config files of the config-directory * diff --git a/src/Util/Crypto.php b/src/Util/Crypto.php index 1b84a92f64..7b2c75a94a 100644 --- a/src/Util/Crypto.php +++ b/src/Util/Crypto.php @@ -21,12 +21,12 @@ namespace Friendica\Util; -use ASN_BASE; -use ASNValue; use Friendica\Core\Hook; use Friendica\Core\Logger; use Friendica\Core\System; use Friendica\DI; +use phpseclib\Crypt\RSA; +use phpseclib\Math\BigInteger; /** * Crypto class @@ -65,97 +65,6 @@ class Crypto } /** - * @param string $Der der formatted string - * @param bool $Private key type optional, default false - * @return string - */ - private static function DerToPem($Der, $Private = false) - { - //Encode: - $Der = base64_encode($Der); - //Split lines: - $lines = str_split($Der, 65); - $body = implode("\n", $lines); - //Get title: - $title = $Private ? 'RSA PRIVATE KEY' : 'PUBLIC KEY'; - //Add wrapping: - $result = "-----BEGIN {$title}-----\n"; - $result .= $body . "\n"; - $result .= "-----END {$title}-----\n"; - - return $result; - } - - /** - * @param string $Der der formatted string - * @return string - */ - private static function DerToRsa($Der) - { - //Encode: - $Der = base64_encode($Der); - //Split lines: - $lines = str_split($Der, 64); - $body = implode("\n", $lines); - //Get title: - $title = 'RSA PUBLIC KEY'; - //Add wrapping: - $result = "-----BEGIN {$title}-----\n"; - $result .= $body . "\n"; - $result .= "-----END {$title}-----\n"; - - return $result; - } - - /** - * @param string $Modulus modulo - * @param string $PublicExponent exponent - * @return string - */ - private static function pkcs8Encode($Modulus, $PublicExponent) - { - //Encode key sequence - $modulus = new ASNValue(ASNValue::TAG_INTEGER); - $modulus->SetIntBuffer($Modulus); - $publicExponent = new ASNValue(ASNValue::TAG_INTEGER); - $publicExponent->SetIntBuffer($PublicExponent); - $keySequenceItems = [$modulus, $publicExponent]; - $keySequence = new ASNValue(ASNValue::TAG_SEQUENCE); - $keySequence->SetSequence($keySequenceItems); - //Encode bit string - $bitStringValue = $keySequence->Encode(); - $bitStringValue = chr(0x00) . $bitStringValue; //Add unused bits byte - $bitString = new ASNValue(ASNValue::TAG_BITSTRING); - $bitString->Value = $bitStringValue; - //Encode body - $bodyValue = "\x30\x0d\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00" . $bitString->Encode(); - $body = new ASNValue(ASNValue::TAG_SEQUENCE); - $body->Value = $bodyValue; - //Get DER encoded public key: - $PublicDER = $body->Encode(); - return $PublicDER; - } - - /** - * @param string $Modulus modulo - * @param string $PublicExponent exponent - * @return string - */ - private static function pkcs1Encode($Modulus, $PublicExponent) - { - //Encode key sequence - $modulus = new ASNValue(ASNValue::TAG_INTEGER); - $modulus->SetIntBuffer($Modulus); - $publicExponent = new ASNValue(ASNValue::TAG_INTEGER); - $publicExponent->SetIntBuffer($PublicExponent); - $keySequenceItems = [$modulus, $publicExponent]; - $keySequence = new ASNValue(ASNValue::TAG_SEQUENCE); - $keySequence->SetSequence($keySequenceItems); - //Encode bit string - $bitStringValue = $keySequence->Encode(); - return $bitStringValue; - } - /** * @param string $m modulo * @param string $e exponent @@ -163,85 +72,46 @@ class Crypto */ public static function meToPem($m, $e) { - $der = self::pkcs8Encode($m, $e); - $key = self::DerToPem($der, false); - return $key; + $rsa = new RSA(); + $rsa->loadKey([ + 'e' => new BigInteger($e, 256), + 'n' => new BigInteger($m, 256) + ]); + return $rsa->getPublicKey(); } /** - * @param string $key key - * @param string $m modulo reference - * @param object $e exponent reference + * Transform RSA public keys to standard PEM output + * + * @param string $key A RSA public key + * + * @return string The PEM output of this key + */ + public static function rsaToPem(string $key) + { + $rsa = new RSA(); + $rsa->setPublicKey($key); + + return $rsa->getPublicKey(RSA::PUBLIC_FORMAT_PKCS8); + } + + /** + * Extracts the modulo and exponent reference from a public PEM key + * + * @param string $key public PEM key + * @param string $modulus (ref) modulo reference + * @param string $exponent (ref) exponent reference + * * @return void - * @throws \Exception */ - private static function pubRsaToMe($key, &$m, &$e) + public static function pemToMe(string $key, &$modulus, &$exponent) { - $lines = explode("\n", $key); - unset($lines[0]); - unset($lines[count($lines)]); - $x = base64_decode(implode('', $lines)); + $rsa = new RSA(); + $rsa->loadKey($key); + $rsa->setPublicKey(); - $r = ASN_BASE::parseASNString($x); - - $m = Strings::base64UrlDecode($r[0]->asnData[0]->asnData); - $e = Strings::base64UrlDecode($r[0]->asnData[1]->asnData); - } - - /** - * @param string $key key - * @return string - * @throws \Exception - */ - public static function rsaToPem($key) - { - self::pubRsaToMe($key, $m, $e); - return self::meToPem($m, $e); - } - - /** - * @param string $key key - * @return string - * @throws \Exception - */ - private static function pemToRsa($key) - { - self::pemToMe($key, $m, $e); - return self::meToRsa($m, $e); - } - - /** - * @param string $key key - * @param string $m modulo reference - * @param string $e exponent reference - * @return void - * @throws \Exception - */ - public static function pemToMe($key, &$m, &$e) - { - $lines = explode("\n", $key); - unset($lines[0]); - unset($lines[count($lines)]); - $x = base64_decode(implode('', $lines)); - - $r = ASN_BASE::parseASNString($x); - - if (isset($r[0])) { - $m = Strings::base64UrlDecode($r[0]->asnData[1]->asnData[0]->asnData[0]->asnData); - $e = Strings::base64UrlDecode($r[0]->asnData[1]->asnData[0]->asnData[1]->asnData); - } - } - - /** - * @param string $m modulo - * @param string $e exponent - * @return string - */ - private static function meToRsa($m, $e) - { - $der = self::pkcs1Encode($m, $e); - $key = self::DerToRsa($der); - return $key; + $modulus = $rsa->modulus->toBytes(); + $exponent = $rsa->exponent->toBytes(); } /** @@ -282,13 +152,13 @@ class Crypto /** * Encrypt a string with 'aes-256-cbc' cipher method. - * + * * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php - * + * * @param string $data * @param string $key The key used for encryption. * @param string $iv A non-NULL Initialization Vector. - * + * * @return string|boolean Encrypted string or false on failure. */ private static function encryptAES256CBC($data, $key, $iv) @@ -298,13 +168,13 @@ class Crypto /** * Decrypt a string with 'aes-256-cbc' cipher method. - * + * * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php - * + * * @param string $data * @param string $key The key used for decryption. * @param string $iv A non-NULL Initialization Vector. - * + * * @return string|boolean Decrypted string or false on failure. */ private static function decryptAES256CBC($data, $key, $iv) @@ -312,42 +182,6 @@ class Crypto return openssl_decrypt($data, 'aes-256-cbc', str_pad($key, 32, "\0"), OPENSSL_RAW_DATA, str_pad($iv, 16, "\0")); } - /** - * Encrypt a string with 'aes-256-ctr' cipher method. - * - * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php - * - * @param string $data - * @param string $key The key used for encryption. - * @param string $iv A non-NULL Initialization Vector. - * - * @return string|boolean Encrypted string or false on failure. - */ - private static function encryptAES256CTR($data, $key, $iv) - { - $key = substr($key, 0, 32); - $iv = substr($iv, 0, 16); - return openssl_encrypt($data, 'aes-256-ctr', str_pad($key, 32, "\0"), OPENSSL_RAW_DATA, str_pad($iv, 16, "\0")); - } - - /** - * Decrypt a string with 'aes-256-ctr' cipher method. - * - * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php - * - * @param string $data - * @param string $key The key used for decryption. - * @param string $iv A non-NULL Initialization Vector. - * - * @return string|boolean Decrypted string or false on failure. - */ - private static function decryptAES256CTR($data, $key, $iv) - { - $key = substr($key, 0, 32); - $iv = substr($iv, 0, 16); - return openssl_decrypt($data, 'aes-256-ctr', str_pad($key, 32, "\0"), OPENSSL_RAW_DATA, str_pad($iv, 16, "\0")); - } - /** * * Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php @@ -393,7 +227,7 @@ class Crypto // log the offending call so we can track it down if (!openssl_public_encrypt($key, $k, $pubkey)) { $x = debug_backtrace(); - Logger::log('RSA failed. ' . print_r($x[0], true)); + Logger::notice('RSA failed', ['trace' => $x[0]]); } $result['alg'] = $alg; @@ -461,11 +295,12 @@ class Crypto return; } - $alg = ((array_key_exists('alg', $data)) ? $data['alg'] : 'aes256cbc'); + $alg = $data['alg'] ?? 'aes256cbc'; if ($alg === 'aes256cbc') { - return self::encapsulateAes($data['data'], $prvkey); + return self::unencapsulateAes($data['data'], $prvkey); } - return self::encapsulateOther($data['data'], $prvkey, $alg); + + return self::unencapsulateOther($data, $prvkey, $alg); } /** diff --git a/src/Util/EMailer/MailBuilder.php b/src/Util/EMailer/MailBuilder.php index 7bdb978c81..38970a612d 100644 --- a/src/Util/EMailer/MailBuilder.php +++ b/src/Util/EMailer/MailBuilder.php @@ -49,7 +49,7 @@ abstract class MailBuilder /** @var LoggerInterface */ protected $logger; - /** @var string */ + /** @var string[][] */ protected $headers; /** @var string */ @@ -76,13 +76,14 @@ abstract class MailBuilder $hostname = substr($hostname, 0, strpos($hostname, ':')); } - $this->headers = ""; - $this->headers .= "Precedence: list\n"; - $this->headers .= "X-Friendica-Host: " . $hostname . "\n"; - $this->headers .= "X-Friendica-Platform: " . FRIENDICA_PLATFORM . "\n"; - $this->headers .= "X-Friendica-Version: " . FRIENDICA_VERSION . "\n"; - $this->headers .= "List-ID: \n"; - $this->headers .= "List-Archive: <" . $baseUrl->get() . "/notifications/system>\n"; + $this->headers = [ + 'Precedence' => ['list'], + 'X-Friendica-Host' => [$hostname], + 'X-Friendica-Platform' => [FRIENDICA_PLATFORM], + 'X-Friendica-Version' => [FRIENDICA_VERSION], + 'List-ID' => [''], + 'List-Archive' => ['<' . $baseUrl->get() . '/notifications/system>'], + ]; } /** @@ -159,15 +160,61 @@ abstract class MailBuilder } /** - * Adds new headers to the default headers + * Returns the current headers * - * @param string $headers New headers + * @return string[][] + */ + public function getHeaders() + { + return $this->headers; + } + + /** + * Sets the headers + * + * Expected format is + * [ + * 'Header1' => ['value1', 'value2', ...], + * 'Header2' => ['value3', 'value4', ...], + * ... + * ] + * + * @param string[][] $headers + * @return $this + */ + public function withHeaders(array $headers) + { + $this->headers = $headers; + + return $this; + } + + /** + * Adds a value to a header + * + * @param string $name The header name + * @param string $value The value of the header to add * * @return static */ - public function addHeaders(string $headers) + public function addHeader(string $name, string $value) { - $this->headers .= $headers; + $this->headers[$name][] = $value; + + return $this; + } + + /** + * Sets a value to a header (overwrites existing values) + * + * @param string $name The header name + * @param string $value The value to set + * + * @return static + */ + public function setHeader(string $name, string $value) + { + $this->headers[$name] = [$value]; return $this; } diff --git a/src/Util/Emailer.php b/src/Util/Emailer.php index 717366248f..ed6c7b331b 100644 --- a/src/Util/Emailer.php +++ b/src/Util/Emailer.php @@ -134,6 +134,17 @@ class Emailer return true; } + // @see https://github.com/friendica/friendica/issues/9142 + $countMessageId = 0; + foreach ($email->getAdditionalMailHeader() as $name => $value) { + if (strtolower($name) == 'message-id') { + $countMessageId += count($value); + } + } + if ($countMessageId > 0) { + $this->logger->warning('More than one Message-ID found - RFC violation', ['email' => $email]); + } + $email_textonly = false; if (!empty($email->getRecipientUid())) { $email_textonly = $this->pConfig->get($email->getRecipientUid(), 'system', 'email_textonly'); @@ -151,7 +162,7 @@ class Emailer . rand(10000, 99999); // generate a multipart/alternative message header - $messageHeader = $email->getAdditionalMailHeader() . + $messageHeader = $email->getAdditionalMailHeaderString() . "From: $fromName <{$fromAddress}>\n" . "Reply-To: $fromName <{$replyTo}>\n" . "MIME-Version: 1.0\n" . @@ -197,15 +208,34 @@ class Emailer return true; } - $res = mail( + $res = $this->mail( $hookdata['to'], $hookdata['subject'], $hookdata['body'], $hookdata['headers'], $hookdata['parameters'] ); + $this->logger->debug('header ' . 'To: ' . $email->getToAddress() . '\n' . $messageHeader); $this->logger->debug('return value ' . (($res) ? 'true' : 'false')); + return $res; } + + /** + * Wrapper around the mail() method (mainly used to overwrite for tests) + * @see mail() + * + * @param string $to Recipient of this mail + * @param string $subject Subject of this mail + * @param string $body Message body of this mail + * @param string $headers Headers of this mail + * @param string $parameters Additional (sendmail) parameters of this mail + * + * @return boolean true if the mail was successfully accepted for delivery, false otherwise. + */ + protected function mail(string $to, string $subject, string $body, string $headers, string $parameters) + { + return mail($to, $subject, $body, $headers, $parameters); + } } diff --git a/src/Util/HTTPSignature.php b/src/Util/HTTPSignature.php index e4d2e93ff0..cede21b3c4 100644 --- a/src/Util/HTTPSignature.php +++ b/src/Util/HTTPSignature.php @@ -21,11 +21,13 @@ namespace Friendica\Util; -use Friendica\Database\DBA; use Friendica\Core\Logger; +use Friendica\Database\Database; +use Friendica\Database\DBA; use Friendica\DI; -use Friendica\Model\User; use Friendica\Model\APContact; +use Friendica\Model\Contact; +use Friendica\Model\User; /** * Implements HTTP Signatures per draft-cavage-http-signatures-07. @@ -191,8 +193,10 @@ class HTTPSignature /** * @param string $header - * @return array associate array with + * @return array associative array with * - \e string \b keyID + * - \e string \b created + * - \e string \b expires * - \e string \b algorithm * - \e array \b headers * - \e string \b signature @@ -200,78 +204,55 @@ class HTTPSignature */ public static function parseSigheader($header) { - $ret = []; + // Remove obsolete folds + $header = preg_replace('/\n\s+/', ' ', $header); + + $token = "[!#$%&'*+.^_`|~0-9A-Za-z-]"; + + $quotedString = '"(?:\\\\.|[^"\\\\])*"'; + + $regex = "/($token+)=($quotedString|$token+)/ism"; + $matches = []; + preg_match_all($regex, $header, $matches, PREG_SET_ORDER); + + $headers = []; + foreach ($matches as $match) { + $headers[$match[1]] = trim($match[2] ?: $match[3], '"'); + } // if the header is encrypted, decrypt with (default) site private key and continue - if (preg_match('/iv="(.*?)"/ism', $header, $matches)) { - $header = self::decryptSigheader($header); + if (!empty($headers['iv'])) { + $header = self::decryptSigheader($headers, DI::config()->get('system', 'prvkey')); + return self::parseSigheader($header); } - if (preg_match('/keyId="(.*?)"/ism', $header, $matches)) { - $ret['keyId'] = $matches[1]; + $return = [ + 'keyId' => $headers['keyId'] ?? '', + 'algorithm' => $headers['algorithm'] ?? 'rsa-sha256', + 'created' => $headers['created'] ?? null, + 'expires' => $headers['expires'] ?? null, + 'headers' => explode(' ', $headers['headers'] ?? ''), + 'signature' => base64_decode(preg_replace('/\s+/', '', $headers['signature'] ?? '')), + ]; + + if (!empty($return['signature']) && !empty($return['algorithm']) && empty($return['headers'])) { + $return['headers'] = ['date']; } - if (preg_match('/algorithm="(.*?)"/ism', $header, $matches)) { - $ret['algorithm'] = $matches[1]; - } else { - $ret['algorithm'] = 'rsa-sha256'; - } - - if (preg_match('/headers="(.*?)"/ism', $header, $matches)) { - $ret['headers'] = explode(' ', $matches[1]); - } - - if (preg_match('/signature="(.*?)"/ism', $header, $matches)) { - $ret['signature'] = base64_decode(preg_replace('/\s+/', '', $matches[1])); - } - - if (!empty($ret['signature']) && !empty($ret['algorithm']) && empty($ret['headers'])) { - $ret['headers'] = ['date']; - } - - return $ret; + return $return; } /** - * @param string $header - * @param string $prvkey (optional), if not set use site private key - * - * @return array|string associative array, empty string if failue - * - \e string \b iv - * - \e string \b key - * - \e string \b alg - * - \e string \b data + * @param array $headers Signature headers + * @param string $prvkey The site private key + * @return string Decrypted signature string * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - private static function decryptSigheader($header, $prvkey = null) + private static function decryptSigheader(array $headers, string $prvkey) { - $iv = $key = $alg = $data = null; - - if (!$prvkey) { - $prvkey = DI::config()->get('system', 'prvkey'); - } - - $matches = []; - - if (preg_match('/iv="(.*?)"/ism', $header, $matches)) { - $iv = $matches[1]; - } - - if (preg_match('/key="(.*?)"/ism', $header, $matches)) { - $key = $matches[1]; - } - - if (preg_match('/alg="(.*?)"/ism', $header, $matches)) { - $alg = $matches[1]; - } - - if (preg_match('/data="(.*?)"/ism', $header, $matches)) { - $data = $matches[1]; - } - - if ($iv && $key && $alg && $data) { - return Crypto::unencapsulate(['iv' => $iv, 'key' => $key, 'alg' => $alg, 'data' => $data], $prvkey); + if (!empty($headers['iv']) && !empty($headers['key']) && !empty($headers['data'])) { + return Crypto::unencapsulate($headers, $prvkey); } return ''; @@ -318,7 +299,7 @@ class HTTPSignature $headers[] = 'Content-Type: application/activity+json'; - $postResult = Network::post($target, $content, $headers); + $postResult = DI::httpRequest()->post($target, $content, $headers); $return_code = $postResult->getReturnCode(); Logger::log('Transmit to ' . $target . ' returned ' . $return_code, Logger::DEBUG); @@ -335,14 +316,15 @@ class HTTPSignature * * @param string $url The URL of the inbox * @param boolean $success Transmission status + * @param boolean $shared The inbox is a shared inbox */ - static private function setInboxStatus($url, $success) + static public function setInboxStatus($url, $success, $shared = false) { $now = DateTimeFormat::utcNow(); $status = DBA::selectFirst('inbox-status', [], ['url' => $url]); if (!DBA::isResult($status)) { - DBA::insert('inbox-status', ['url' => $url, 'created' => $now]); + DBA::insert('inbox-status', ['url' => $url, 'created' => $now, 'shared' => $shared], Database::INSERT_IGNORE); $status = DBA::selectFirst('inbox-status', [], ['url' => $url]); } @@ -396,8 +378,7 @@ class HTTPSignature */ public static function fetch($request, $uid) { - $opts = ['accept_content' => 'application/activity+json, application/ld+json']; - $curlResult = self::fetchRaw($request, $uid, false, $opts); + $curlResult = self::fetchRaw($request, $uid); if (empty($curlResult)) { return false; @@ -424,46 +405,55 @@ class HTTPSignature * @param array $opts (optional parameters) assoziative array with: * 'accept_content' => supply Accept: header with 'accept_content' as the value * 'timeout' => int Timeout in seconds, default system config value or 60 seconds - * 'http_auth' => username:password - * 'novalidate' => do not validate SSL certs, default is to validate using our CA list * 'nobody' => only return the header * 'cookiejar' => path to cookie jar file * * @return object CurlResult * @throws \Friendica\Network\HTTPException\InternalServerErrorException */ - public static function fetchRaw($request, $uid = 0, $binary = false, $opts = []) + public static function fetchRaw($request, $uid = 0, $opts = ['accept_content' => 'application/activity+json, application/ld+json']) { + $header = []; + if (!empty($uid)) { $owner = User::getOwnerDataById($uid); if (!$owner) { return; } + } else { + $owner = User::getSystemAccount(); + if (!$owner) { + return; + } + } + if (!empty($owner['uprvkey'])) { // Header data that is about to be signed. $host = parse_url($request, PHP_URL_HOST); $path = parse_url($request, PHP_URL_PATH); $date = DateTimeFormat::utcNow(DateTimeFormat::HTTP); - $headers = ['Date: ' . $date, 'Host: ' . $host]; + $header = ['Date: ' . $date, 'Host: ' . $host]; $signed_data = "(request-target): get " . $path . "\ndate: ". $date . "\nhost: " . $host; $signature = base64_encode(Crypto::rsaSign($signed_data, $owner['uprvkey'], 'sha256')); - $headers[] = 'Signature: keyId="' . $owner['url'] . '#main-key' . '",algorithm="rsa-sha256",headers="(request-target) date host",signature="' . $signature . '"'; - } else { - $headers = []; + $header[] = 'Signature: keyId="' . $owner['url'] . '#main-key' . '",algorithm="rsa-sha256",headers="(request-target) date host",signature="' . $signature . '"'; } if (!empty($opts['accept_content'])) { - $headers[] = 'Accept: ' . $opts['accept_content']; + $header[] = 'Accept: ' . $opts['accept_content']; } $curl_opts = $opts; - $curl_opts['header'] = $headers; + $curl_opts['header'] = $header; - $curlResult = Network::curl($request, false, $curl_opts); + if (!empty($opts['nobody'])) { + $curlResult = DI::httpRequest()->head($request, $curl_opts); + } else { + $curlResult = DI::httpRequest()->get($request, $curl_opts); + } $return_code = $curlResult->getReturnCode(); Logger::log('Fetched for user ' . $uid . ' from ' . $request . ' returned ' . $return_code, Logger::DEBUG); @@ -498,7 +488,7 @@ class HTTPSignature } $headers = []; - $headers['(request-target)'] = strtolower($http_headers['REQUEST_METHOD']) . ' ' . $http_headers['REQUEST_URI']; + $headers['(request-target)'] = strtolower($http_headers['REQUEST_METHOD']) . ' ' . parse_url($http_headers['REQUEST_URI'], PHP_URL_PATH); // First take every header foreach ($http_headers as $k => $v) { @@ -534,6 +524,14 @@ class HTTPSignature $algorithm = null; + // Wildcard value where signing algorithm should be derived from keyId + // @see https://tools.ietf.org/html/draft-ietf-httpbis-message-signatures-00#section-4.1 + // Defaulting to SHA256 as it seems to be the prevalent implementation + // @see https://arewehs2019yet.vpzom.click + if ($sig_block['algorithm'] === 'hs2019') { + $algorithm = 'sha256'; + } + if ($sig_block['algorithm'] === 'rsa-sha256') { $algorithm = 'sha256'; } @@ -547,11 +545,22 @@ class HTTPSignature } $key = self::fetchKey($sig_block['keyId'], $actor); - if (empty($key)) { return false; } + if (!empty($key['url']) && !empty($key['type']) && ($key['type'] == 'Tombstone')) { + Logger::info('Actor is a tombstone', ['key' => $key]); + + // We now delete everything that we possibly knew from this actor + Contact::deleteContactByUrl($key['url']); + return false; + } + + if (empty($key['pubkey'])) { + return false; + } + if (!Crypto::rsaVerify($signed_data, $sig_block['signature'], $key['pubkey'], $algorithm)) { return false; } @@ -619,12 +628,12 @@ class HTTPSignature $profile = APContact::getByURL($url); if (!empty($profile)) { Logger::log('Taking key from id ' . $id, Logger::DEBUG); - return ['url' => $url, 'pubkey' => $profile['pubkey']]; + return ['url' => $url, 'pubkey' => $profile['pubkey'], 'type' => $profile['type']]; } elseif ($url != $actor) { $profile = APContact::getByURL($actor); if (!empty($profile)) { Logger::log('Taking key from actor ' . $actor, Logger::DEBUG); - return ['url' => $actor, 'pubkey' => $profile['pubkey']]; + return ['url' => $actor, 'pubkey' => $profile['pubkey'], 'type' => $profile['type']]; } } diff --git a/src/Util/Images.php b/src/Util/Images.php index 35f0cfc042..612a21ed54 100644 --- a/src/Util/Images.php +++ b/src/Util/Images.php @@ -184,7 +184,7 @@ class Images return $data; } - $img_str = Network::fetchUrl($url, true, 4); + $img_str = DI::httpRequest()->fetch($url, 4); if (!$img_str) { return []; @@ -200,7 +200,7 @@ class Images $stamp1 = microtime(true); file_put_contents($tempfile, $img_str); - DI::profiler()->saveTimestamp($stamp1, "file", System::callstack()); + DI::profiler()->saveTimestamp($stamp1, "file"); $data = getimagesize($tempfile); unlink($tempfile); diff --git a/src/Util/JsonLD.php b/src/Util/JsonLD.php index b4ff53fdb0..c211ebc2a0 100644 --- a/src/Util/JsonLD.php +++ b/src/Util/JsonLD.php @@ -173,12 +173,8 @@ class JsonLD * * @return array fetched element */ - public static function fetchElementArray($array, $element, $key = '@id') + public static function fetchElementArray($array, $element, $key = null, $type = null, $type_value = null) { - if (empty($array)) { - return null; - } - if (!isset($array[$element])) { return null; } @@ -191,12 +187,14 @@ class JsonLD $elements = []; foreach ($array[$element] as $entry) { - if (!is_array($entry)) { - $elements[] = $entry; + if (!is_array($entry) || is_null($key)) { + $item = $entry; } elseif (isset($entry[$key])) { - $elements[] = $entry[$key]; - } elseif (!empty($entry) || !is_array($entry)) { - $elements[] = $entry; + $item = $entry[$key]; + } + + if (isset($item) && (is_null($type) || is_null($type_value) || isset($item[$type]) && $item[$type] == $type_value)) { + $elements[] = $item; } } diff --git a/src/Util/Logger/ProfilerLogger.php b/src/Util/Logger/ProfilerLogger.php index 2f19409528..e0f18b2851 100644 --- a/src/Util/Logger/ProfilerLogger.php +++ b/src/Util/Logger/ProfilerLogger.php @@ -61,7 +61,7 @@ class ProfilerLogger implements LoggerInterface { $stamp1 = microtime(true); $this->logger->emergency($message, $context); - $this->profiler->saveTimestamp($stamp1, 'file', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'file'); } /** @@ -71,7 +71,7 @@ class ProfilerLogger implements LoggerInterface { $stamp1 = microtime(true); $this->logger->alert($message, $context); - $this->profiler->saveTimestamp($stamp1, 'file', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'file'); } /** @@ -81,7 +81,7 @@ class ProfilerLogger implements LoggerInterface { $stamp1 = microtime(true); $this->logger->critical($message, $context); - $this->profiler->saveTimestamp($stamp1, 'file', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'file'); } /** @@ -91,7 +91,7 @@ class ProfilerLogger implements LoggerInterface { $stamp1 = microtime(true); $this->logger->error($message, $context); - $this->profiler->saveTimestamp($stamp1, 'file', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'file'); } /** @@ -101,7 +101,7 @@ class ProfilerLogger implements LoggerInterface { $stamp1 = microtime(true); $this->logger->warning($message, $context); - $this->profiler->saveTimestamp($stamp1, 'file', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'file'); } /** @@ -111,7 +111,7 @@ class ProfilerLogger implements LoggerInterface { $stamp1 = microtime(true); $this->logger->notice($message, $context); - $this->profiler->saveTimestamp($stamp1, 'file', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'file'); } /** @@ -121,7 +121,7 @@ class ProfilerLogger implements LoggerInterface { $stamp1 = microtime(true); $this->logger->info($message, $context); - $this->profiler->saveTimestamp($stamp1, 'file', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'file'); } /** @@ -131,7 +131,7 @@ class ProfilerLogger implements LoggerInterface { $stamp1 = microtime(true); $this->logger->debug($message, $context); - $this->profiler->saveTimestamp($stamp1, 'file', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'file'); } /** @@ -141,6 +141,6 @@ class ProfilerLogger implements LoggerInterface { $stamp1 = microtime(true); $this->logger->log($level, $message, $context); - $this->profiler->saveTimestamp($stamp1, 'file', System::callstack()); + $this->profiler->saveTimestamp($stamp1, 'file'); } } diff --git a/src/Util/Network.php b/src/Util/Network.php index 6b73369d32..ec1427003a 100644 --- a/src/Util/Network.php +++ b/src/Util/Network.php @@ -21,346 +21,13 @@ namespace Friendica\Util; -use DOMDocument; -use DomXPath; use Friendica\Core\Hook; use Friendica\Core\Logger; -use Friendica\Core\System; use Friendica\DI; -use Friendica\Network\CurlResult; +use Friendica\Model\Contact; class Network { - /** - * Curl wrapper - * - * If binary flag is true, return binary results. - * Set the cookiejar argument to a string (e.g. "/tmp/friendica-cookies.txt") - * to preserve cookies from one request to the next. - * - * @param string $url URL to fetch - * @param bool $binary default false - * TRUE if asked to return binary results (file download) - * @param int $timeout Timeout in seconds, default system config value or 60 seconds - * @param string $accept_content supply Accept: header with 'accept_content' as the value - * @param string $cookiejar Path to cookie jar file - * @param int $redirects The recursion counter for internal use - default 0 - * - * @return string The fetched content - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function fetchUrl(string $url, bool $binary = false, int $timeout = 0, string $accept_content = '', string $cookiejar = '', int &$redirects = 0) - { - $ret = self::fetchUrlFull($url, $binary, $timeout, $accept_content, $cookiejar, $redirects); - - return $ret->getBody(); - } - - /** - * Curl wrapper with array of return values. - * - * Inner workings and parameters are the same as @ref fetchUrl but returns an array with - * all the information collected during the fetch. - * - * @param string $url URL to fetch - * @param bool $binary default false - * TRUE if asked to return binary results (file download) - * @param int $timeout Timeout in seconds, default system config value or 60 seconds - * @param string $accept_content supply Accept: header with 'accept_content' as the value - * @param string $cookiejar Path to cookie jar file - * @param int $redirects The recursion counter for internal use - default 0 - * - * @return CurlResult With all relevant information, 'body' contains the actual fetched content. - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function fetchUrlFull(string $url, bool $binary = false, int $timeout = 0, string $accept_content = '', string $cookiejar = '', int &$redirects = 0) - { - return self::curl( - $url, - $binary, - [ - 'timeout' => $timeout, - 'accept_content' => $accept_content, - 'cookiejar' => $cookiejar - ], - $redirects - ); - } - - /** - * fetches an URL. - * - * @param string $url URL to fetch - * @param bool $binary default false - * TRUE if asked to return binary results (file download) - * @param array $opts (optional parameters) assoziative array with: - * 'accept_content' => supply Accept: header with 'accept_content' as the value - * 'timeout' => int Timeout in seconds, default system config value or 60 seconds - * 'http_auth' => username:password - * 'novalidate' => do not validate SSL certs, default is to validate using our CA list - * 'nobody' => only return the header - * 'cookiejar' => path to cookie jar file - * 'header' => header array - * @param int $redirects The recursion counter for internal use - default 0 - * - * @return CurlResult - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function curl(string $url, bool $binary = false, array $opts = [], int &$redirects = 0) - { - $stamp1 = microtime(true); - - $a = DI::app(); - - if (strlen($url) > 1000) { - Logger::log('URL is longer than 1000 characters. Callstack: ' . System::callstack(20), Logger::DEBUG); - return CurlResult::createErrorCurl(substr($url, 0, 200)); - } - - $parts2 = []; - $parts = parse_url($url); - $path_parts = explode('/', $parts['path'] ?? ''); - foreach ($path_parts as $part) { - if (strlen($part) <> mb_strlen($part)) { - $parts2[] = rawurlencode($part); - } else { - $parts2[] = $part; - } - } - $parts['path'] = implode('/', $parts2); - $url = self::unparseURL($parts); - - if (self::isUrlBlocked($url)) { - Logger::log('domain of ' . $url . ' is blocked', Logger::DATA); - return CurlResult::createErrorCurl($url); - } - - $ch = @curl_init($url); - - if (($redirects > 8) || (!$ch)) { - return CurlResult::createErrorCurl($url); - } - - @curl_setopt($ch, CURLOPT_HEADER, true); - - if (!empty($opts['cookiejar'])) { - curl_setopt($ch, CURLOPT_COOKIEJAR, $opts["cookiejar"]); - curl_setopt($ch, CURLOPT_COOKIEFILE, $opts["cookiejar"]); - } - - // These settings aren't needed. We're following the location already. - // @curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - // @curl_setopt($ch, CURLOPT_MAXREDIRS, 5); - - if (!empty($opts['accept_content'])) { - curl_setopt( - $ch, - CURLOPT_HTTPHEADER, - ['Accept: ' . $opts['accept_content']] - ); - } - - if (!empty($opts['header'])) { - curl_setopt($ch, CURLOPT_HTTPHEADER, $opts['header']); - } - - @curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - @curl_setopt($ch, CURLOPT_USERAGENT, $a->getUserAgent()); - - $range = intval(DI::config()->get('system', 'curl_range_bytes', 0)); - - if ($range > 0) { - @curl_setopt($ch, CURLOPT_RANGE, '0-' . $range); - } - - // Without this setting it seems as if some webservers send compressed content - // This seems to confuse curl so that it shows this uncompressed. - /// @todo We could possibly set this value to "gzip" or something similar - curl_setopt($ch, CURLOPT_ENCODING, ''); - - if (!empty($opts['headers'])) { - @curl_setopt($ch, CURLOPT_HTTPHEADER, $opts['headers']); - } - - if (!empty($opts['nobody'])) { - @curl_setopt($ch, CURLOPT_NOBODY, $opts['nobody']); - } - - if (!empty($opts['timeout'])) { - @curl_setopt($ch, CURLOPT_TIMEOUT, $opts['timeout']); - } else { - $curl_time = DI::config()->get('system', 'curl_timeout', 60); - @curl_setopt($ch, CURLOPT_TIMEOUT, intval($curl_time)); - } - - // by default we will allow self-signed certs - // but you can override this - - $check_cert = DI::config()->get('system', 'verifyssl'); - @curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, (($check_cert) ? true : false)); - - if ($check_cert) { - @curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); - } - - $proxy = DI::config()->get('system', 'proxy'); - - if (strlen($proxy)) { - @curl_setopt($ch, CURLOPT_HTTPPROXYTUNNEL, 1); - @curl_setopt($ch, CURLOPT_PROXY, $proxy); - $proxyuser = @DI::config()->get('system', 'proxyuser'); - - if (strlen($proxyuser)) { - @curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxyuser); - } - } - - if (DI::config()->get('system', 'ipv4_resolve', false)) { - curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); - } - - if ($binary) { - @curl_setopt($ch, CURLOPT_BINARYTRANSFER, 1); - } - - // don't let curl abort the entire application - // if it throws any errors. - - $s = @curl_exec($ch); - $curl_info = @curl_getinfo($ch); - - // Special treatment for HTTP Code 416 - // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/416 - if (($curl_info['http_code'] == 416) && ($range > 0)) { - @curl_setopt($ch, CURLOPT_RANGE, ''); - $s = @curl_exec($ch); - $curl_info = @curl_getinfo($ch); - } - - $curlResponse = new CurlResult($url, $s, $curl_info, curl_errno($ch), curl_error($ch)); - - if ($curlResponse->isRedirectUrl()) { - $redirects++; - Logger::log('curl: redirect ' . $url . ' to ' . $curlResponse->getRedirectUrl()); - @curl_close($ch); - return self::curl($curlResponse->getRedirectUrl(), $binary, $opts, $redirects); - } - - @curl_close($ch); - - DI::profiler()->saveTimestamp($stamp1, 'network', System::callstack()); - - return $curlResponse; - } - - /** - * Send POST request to $url - * - * @param string $url URL to post - * @param mixed $params array of POST variables - * @param array $headers HTTP headers - * @param int $redirects Recursion counter for internal use - default = 0 - * @param int $timeout The timeout in seconds, default system config value or 60 seconds - * - * @return CurlResult The content - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function post(string $url, $params, array $headers = [], int $timeout = 0, int &$redirects = 0) - { - $stamp1 = microtime(true); - - if (self::isUrlBlocked($url)) { - Logger::log('post_url: domain of ' . $url . ' is blocked', Logger::DATA); - return CurlResult::createErrorCurl($url); - } - - $a = DI::app(); - $ch = curl_init($url); - - if (($redirects > 8) || (!$ch)) { - return CurlResult::createErrorCurl($url); - } - - Logger::log('post_url: start ' . $url, Logger::DATA); - - curl_setopt($ch, CURLOPT_HEADER, true); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, 1); - curl_setopt($ch, CURLOPT_POSTFIELDS, $params); - curl_setopt($ch, CURLOPT_USERAGENT, $a->getUserAgent()); - - if (DI::config()->get('system', 'ipv4_resolve', false)) { - curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); - } - - if (intval($timeout)) { - curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); - } else { - $curl_time = DI::config()->get('system', 'curl_timeout', 60); - curl_setopt($ch, CURLOPT_TIMEOUT, intval($curl_time)); - } - - if (!empty($headers)) { - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - } - - $check_cert = DI::config()->get('system', 'verifyssl'); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, (($check_cert) ? true : false)); - - if ($check_cert) { - @curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); - } - - $proxy = DI::config()->get('system', 'proxy'); - - if (strlen($proxy)) { - curl_setopt($ch, CURLOPT_HTTPPROXYTUNNEL, 1); - curl_setopt($ch, CURLOPT_PROXY, $proxy); - $proxyuser = DI::config()->get('system', 'proxyuser'); - if (strlen($proxyuser)) { - curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxyuser); - } - } - - // don't let curl abort the entire application - // if it throws any errors. - - $s = @curl_exec($ch); - - $curl_info = curl_getinfo($ch); - - $curlResponse = new CurlResult($url, $s, $curl_info, curl_errno($ch), curl_error($ch)); - - if ($curlResponse->isRedirectUrl()) { - $redirects++; - Logger::log('post_url: redirect ' . $url . ' to ' . $curlResponse->getRedirectUrl()); - curl_close($ch); - return self::post($curlResponse->getRedirectUrl(), $params, $headers, $redirects, $timeout); - } - - curl_close($ch); - - DI::profiler()->saveTimestamp($stamp1, 'network', System::callstack()); - - // Very old versions of Lighttpd don't like the "Expect" header, so we remove it when needed - if ($curlResponse->getReturnCode() == 417) { - $redirects++; - - if (empty($headers)) { - $headers = ['Expect:']; - } else { - if (!in_array('Expect:', $headers)) { - array_push($headers, 'Expect:'); - } - } - Logger::info('Server responds with 417, applying workaround', ['url' => $url]); - return self::post($url, $params, $headers, $redirects, $timeout); - } - - Logger::log('post_url: end ' . $url, Logger::DATA); - - return $curlResponse; - } /** * Return raw post data from a post request @@ -510,6 +177,35 @@ class Network return false; } + /** + * Checks if the provided url is on the list of domains where redirects are blocked. + * Returns true if it is or malformed URL, false if not. + * + * @param string $url The url to check the domain from + * + * @return boolean + */ + public static function isRedirectBlocked(string $url) + { + $host = @parse_url($url, PHP_URL_HOST); + if (!$host) { + return false; + } + + $no_redirect_list = DI::config()->get('system', 'no_redirect_list', []); + if (!$no_redirect_list) { + return false; + } + + foreach ($no_redirect_list as $no_redirect) { + if (fnmatch(strtolower($no_redirect), strtolower($host))) { + return true; + } + } + + return false; + } + /** * Check if email address is allowed to register here. * @@ -569,7 +265,7 @@ class Network Hook::callAll('avatar_lookup', $avatar); if (! $avatar['success']) { - $avatar['url'] = DI::baseUrl() . '/images/person-300.jpg'; + $avatar['url'] = DI::baseUrl() . Contact::DEFAULT_AVATAR_PHOTO; } Logger::log('Avatar: ' . $avatar['email'] . ' ' . $avatar['url'], Logger::DEBUG); @@ -626,123 +322,23 @@ class Network } /** - * Returns the original URL of the provided URL + * Add a missing base path (scheme and host) to a given url * - * This function strips tracking query params and follows redirections, either - * through HTTP code or meta refresh tags. Stops after 10 redirections. - * - * @todo Remove the $fetchbody parameter that generates an extraneous HEAD request - * - * @see ParseUrl::getSiteinfo - * - * @param string $url A user-submitted URL - * @param int $depth The current redirection recursion level (internal) - * @param bool $fetchbody Wether to fetch the body or not after the HEAD requests - * @return string A canonical URL - * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @param string $url + * @param string $basepath + * @return string url */ - public static function finalUrl(string $url, int $depth = 1, bool $fetchbody = false) + public static function addBasePath(string $url, string $basepath) { - $a = DI::app(); - - $url = self::stripTrackingQueryParams($url); - - if ($depth > 10) { + if (!empty(parse_url($url, PHP_URL_SCHEME)) || empty(parse_url($basepath, PHP_URL_SCHEME)) || empty($url) || empty(parse_url($url))) { return $url; } - $url = trim($url, "'"); + $base = ['scheme' => parse_url($basepath, PHP_URL_SCHEME), + 'host' => parse_url($basepath, PHP_URL_HOST)]; - $stamp1 = microtime(true); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_HEADER, 1); - curl_setopt($ch, CURLOPT_NOBODY, 1); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_USERAGENT, $a->getUserAgent()); - - curl_exec($ch); - $curl_info = @curl_getinfo($ch); - $http_code = $curl_info['http_code']; - curl_close($ch); - - DI::profiler()->saveTimestamp($stamp1, "network", System::callstack()); - - if ($http_code == 0) { - return $url; - } - - if (in_array($http_code, ['301', '302'])) { - if (!empty($curl_info['redirect_url'])) { - return self::finalUrl($curl_info['redirect_url'], ++$depth, $fetchbody); - } elseif (!empty($curl_info['location'])) { - return self::finalUrl($curl_info['location'], ++$depth, $fetchbody); - } - } - - // Check for redirects in the meta elements of the body if there are no redirects in the header. - if (!$fetchbody) { - return(self::finalUrl($url, ++$depth, true)); - } - - // if the file is too large then exit - if ($curl_info["download_content_length"] > 1000000) { - return $url; - } - - // if it isn't a HTML file then exit - if (!empty($curl_info["content_type"]) && !strstr(strtolower($curl_info["content_type"]), "html")) { - return $url; - } - - $stamp1 = microtime(true); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_HEADER, 0); - curl_setopt($ch, CURLOPT_NOBODY, 0); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_USERAGENT, $a->getUserAgent()); - - $body = curl_exec($ch); - curl_close($ch); - - DI::profiler()->saveTimestamp($stamp1, "network", System::callstack()); - - if (trim($body) == "") { - return $url; - } - - // Check for redirect in meta elements - $doc = new DOMDocument(); - @$doc->loadHTML($body); - - $xpath = new DomXPath($doc); - - $list = $xpath->query("//meta[@content]"); - foreach ($list as $node) { - $attr = []; - if ($node->attributes->length) { - foreach ($node->attributes as $attribute) { - $attr[$attribute->name] = $attribute->value; - } - } - - if (@$attr["http-equiv"] == 'refresh') { - $path = $attr["content"]; - $pathinfo = explode(";", $path); - foreach ($pathinfo as $value) { - if (substr(strtolower($value), 0, 4) == "url=") { - return self::finalUrl(substr($value, 4), ++$depth); - } - } - } - } - - return $url; + $parts = array_merge($base, parse_url('/' . ltrim($url, '/'))); + return self::unparseURL($parts); } /** diff --git a/src/Util/ParseUrl.php b/src/Util/ParseUrl.php index 62b5d007d8..15186b5737 100644 --- a/src/Util/ParseUrl.php +++ b/src/Util/ParseUrl.php @@ -26,7 +26,9 @@ use DOMXPath; use Friendica\Content\OEmbed; use Friendica\Core\Hook; use Friendica\Core\Logger; +use Friendica\Database\Database; use Friendica\Database\DBA; +use Friendica\DI; /** * Get information about a given URL @@ -55,14 +57,13 @@ class ParseUrl * to avoid endless loops * * @return array which contains needed data for embedding - * string 'url' => The url of the parsed page - * string 'type' => Content type - * string 'title' => The title of the content - * string 'text' => The description for the content - * string 'image' => A preview image of the content (only available - * if $no_geuessing = false - * array'images' = Array of preview pictures - * string 'keywords' => The tags which belong to the content + * string 'url' => The url of the parsed page + * string 'type' => Content type + * string 'title' => (optional) The title of the content + * string 'text' => (optional) The description for the content + * string 'image' => (optional) A preview image of the content (only available if $no_geuessing = false) + * array 'images' => (optional) Array of preview pictures + * string 'keywords' => (optional) The tags which belong to the content * * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @see ParseUrl::getSiteinfo() for more information about scraping @@ -91,7 +92,7 @@ class ParseUrl 'oembed' => $do_oembed, 'content' => serialize($data), 'created' => DateTimeFormat::utcNow() ], - true + Database::INSERT_UPDATE ); return $data; @@ -115,14 +116,13 @@ class ParseUrl * @param int $count Internal counter to avoid endless loops * * @return array which contains needed data for embedding - * string 'url' => The url of the parsed page - * string 'type' => Content type - * string 'title' => The title of the content - * string 'text' => The description for the content - * string 'image' => A preview image of the content (only available - * if $no_geuessing = false - * array'images' = Array of preview pictures - * string 'keywords' => The tags which belong to the content + * string 'url' => The url of the parsed page + * string 'type' => Content type + * string 'title' => (optional) The title of the content + * string 'text' => (optional) The description for the content + * string 'image' => (optional) A preview image of the content (only available if $no_guessing = false) + * array 'images' => (optional) Array of preview pictures + * string 'keywords' => (optional) The tags which belong to the content * * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @todo https://developers.google.com/+/plugins/snippet/ @@ -140,29 +140,28 @@ class ParseUrl */ public static function getSiteinfo($url, $no_guessing = false, $do_oembed = true, $count = 1) { - $siteinfo = []; - // Check if the URL does contain a scheme $scheme = parse_url($url, PHP_URL_SCHEME); if ($scheme == '') { - $url = 'http://' . trim($url, '/'); + $url = 'http://' . ltrim($url, '/'); } + $url = trim($url, "'\""); + + $url = Network::stripTrackingQueryParams($url); + + $siteinfo = [ + 'url' => $url, + 'type' => 'link', + ]; + if ($count > 10) { Logger::log('Endless loop detected for ' . $url, Logger::DEBUG); return $siteinfo; } - $url = trim($url, "'"); - $url = trim($url, '"'); - - $url = Network::stripTrackingQueryParams($url); - - $siteinfo['url'] = $url; - $siteinfo['type'] = 'link'; - - $curlResult = Network::curl($url); + $curlResult = DI::httpRequest()->get($url); if (!$curlResult->isSuccess()) { return $siteinfo; } @@ -203,12 +202,29 @@ class ParseUrl } } - // Fetch the first mentioned charset. Can be in body or header $charset = ''; - if (preg_match('/charset=(.*?)[\'"\s\n]/', $header, $matches)) { + // Look for a charset, first in headers + // Expected form: Content-Type: text/html; charset=ISO-8859-4 + if (preg_match('/charset=([a-z0-9-_.\/]+)/i', $header, $matches)) { $charset = trim(trim(trim(array_pop($matches)), ';,')); } + // Then in body that gets precedence + // Expected forms: + // - + // - + // - + // - + // We escape