diff --git a/README.md b/README.md index 2f1507c..00967e6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,15 @@ It sits behind your single sign-on (such as [Authelia](https://www.authelia.com/ - Undo send for a few seconds after sending - Recipient suggestions from your [Davis](https://github.com/tchapi/davis) address books (_optional_) +### Contacts (_optional, with Davis_) + +- Your address books, in the same three panes as mail: browse, filter, pick which book you look at +- Create, edit and delete contacts, with undo. What other apps wrote on a card and Postel does not show is left as it is +- Pictures, with a crop +- Download a contact, an address book or all of them as `.vcf`, and import `.vcf` files +- Possible duplicates pointed out on a card, merged in two clicks +- Senders you know show their name and picture from their card, and open it; the others can be added from the message + ### Interface - Light and dark mode, nine accent colours, two densities @@ -61,7 +70,7 @@ Comes as one Docker image (php-fpm and Caddy) for `amd64` and `arm64`, with a sa - An IMAP server that supports master users and the `SORT`, `THREAD`, `MOVE` and `PREVIEW` extensions. [Dovecot](https://www.dovecot.org/) does, and it is the only one tested (_2.3.19, as shipped with [docker-mailserver](https://docker-mailserver.github.io/docker-mailserver/latest/) 15_) - An SMTP submission server that accepts the same master user. With Dovecot as the SASL backend for Postfix, which is the usual setup, it does - A reverse proxy doing forward-auth, which sets `Remote-User`, `Remote-Email` and `Remote-Name` on each request. For instance Traefik or Caddy with Authelia - - [Davis](https://github.com/tchapi/davis) 5.6 or later, if you want recipient suggestions (_optional_) + - [Davis](https://github.com/tchapi/davis) 5.6 or later, if you want contacts and recipient suggestions (_optional_) To run it without Docker, you also need PHP 8.4 (with the `intl` extension), Composer 2 and Node 22 or later to build the frontend. @@ -74,7 +83,7 @@ Whatever the way, you need to: 1. Create a [Dovecot master user](#dovecot-master-user) for Postel 2. Put Postel behind a [reverse proxy with forward-auth](#-reverse-proxy-and-authentication) 3. Set the [configuration](#configuration) -4. Optionally, create a [Davis app key](#davis-app-key) for recipient suggestions +4. Optionally, create a [Davis app key](#davis-app-key) for contacts and recipient suggestions ## Configuration @@ -95,7 +104,7 @@ Everything is set with environment variables. | `SMTP_PORT` | `587` | Submission port | | `SMTP_ENCRYPTION` | `starttls` | `false`, `ssl` or `tls` (_implicit TLS, usually port 465_), or `starttls` (_usually port 587_) | | `SMTP_VALIDATE_CERT` | `true` | Check the SMTP server's certificate | -| `DAVIS_URL` | | The DAV root of your Davis server, for instance `https://dav.example.com/dav/`. Leave it empty to turn recipient suggestions off | +| `DAVIS_URL` | | The DAV root of your Davis server, for instance `https://dav.example.com/dav/`. Leave it empty to turn contacts and recipient suggestions off | | `DAVIS_APP_ID` | | The Davis app key's id (`app_
`) | | `DAVIS_APP_SECRET` | | The Davis app key's secret | | `DAVIS_LOGIN_ATTRIBUTE` | `username` | Which header is the Davis login: `username` (`Remote-User`) or `email` (`Remote-Email`) | @@ -127,7 +136,7 @@ The `` part comes from the forward-auth headers: the email (`Remote-Email` ## Davis app key -Recipient suggestions come from the user's [Davis](https://github.com/tchapi/davis) address books, read with an app key: Postel logs in as `*` with the key's secret, and Davis acts as ``. It needs Davis 5.6 or later. +Contacts and recipient suggestions come from the user's [Davis](https://github.com/tchapi/davis) address books, read and written with an app key: Postel logs in as `*` with the key's secret, and Davis acts as ``. It needs Davis 5.6 or later. 1. Turn app keys on in Davis with `APP_KEYS_ENABLED=true` 2. Create a key, for all users or only the ones using Postel: @@ -144,11 +153,15 @@ _The **App keys** page of the Davis dashboard does the same._ > [!NOTE] > -> Davis never creates an account for an app key: each user needs an existing Davis account with the same name. Users without one simply get no suggestions. +> Davis never creates an account for an app key: each user needs an existing Davis account with the same name. Users without one get an empty address book and no suggestions. +> +> The key lets Postel change the address books too: contacts created, edited or deleted in Postel are written to Davis. > > `--all-users` does not include Davis admins. If an admin uses Postel, add them with `--user`. -Postel keeps a copy of the names and addresses in its SQLite file, and checks Davis for changes at most every five minutes. Suggestions never wait for Davis. +Postel keeps a copy of the cards in its SQLite file, so the address book opens at once and suggestions never wait for Davis. It checks Davis for changes when you open your contacts, and otherwise at most every five minutes. A change made in Postel goes to Davis first, and is refused rather than written over a change made elsewhere in the meantime. + +Postel only speaks standard CardDAV to Davis. The app key is the one thing that needs Davis: it is what lets Postel act as the user without their password. # 🌐 Reverse proxy and authentication diff --git a/backend/.env.test b/backend/.env.test index 1b990ce..8a82333 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -10,3 +10,7 @@ SMTP_HOST=127.0.0.1 SMTP_PORT=1587 SMTP_ENCRYPTION=false DATABASE_URL="sqlite:///%kernel.project_dir%/var/data/test.db" +# A fake address book server answers, see tests/Contacts/FakeCardDav.php +DAVIS_URL=https://dav.example.test/dav/ +DAVIS_APP_ID=app_test +DAVIS_APP_SECRET=secret diff --git a/backend/composer.json b/backend/composer.json index 99bc94e..d1e112c 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -15,6 +15,7 @@ "doctrine/orm": "^3.7", "phpdocumentor/type-resolver": "^2.1", "phpstan/phpdoc-parser": "^2.3", + "sabre/vobject": "^5.0", "symfony/console": "7.4.*", "symfony/dotenv": "7.4.*", "symfony/flex": "^2", diff --git a/backend/composer.lock b/backend/composer.lock index 046f565..59ea3ac 100644 --- a/backend/composer.lock +++ b/backend/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": "792b8b84ee24f283511140f76856c4af", + "content-hash": "d7f4814a85f5d8c9dffe8dcf9b057971", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -2985,6 +2985,249 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "sabre/uri", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/uri.git", + "reference": "a926c749dddfb289b8a9b5218d16ac06affdc631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/uri/zipball/a926c749dddfb289b8a9b5218d16ac06affdc631", + "reference": "a926c749dddfb289b8a9b5218d16ac06affdc631", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.4" + }, + "type": "library", + "autoload": { + "files": [ + "lib/functions.php" + ], + "psr-4": { + "Sabre\\Uri\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + } + ], + "description": "Functions for making sense out of URIs.", + "homepage": "http://sabre.io/uri/", + "keywords": [ + "rfc3986", + "uri", + "url" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/uri/issues", + "source": "https://github.com/fruux/sabre-uri" + }, + "time": "2026-04-26T04:19:03+00:00" + }, + { + "name": "sabre/vobject", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/vobject.git", + "reference": "2533eb0d67b2030e4f2bdacac972beb556d50e0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/vobject/zipball/2533eb0d67b2030e4f2bdacac972beb556d50e0d", + "reference": "2533eb0d67b2030e4f2bdacac972beb556d50e0d", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^8.2", + "sabre/xml": "^3.0 || ^4.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/php-invoker": "^5.0", + "phpunit/phpunit": "^11.5", + "rector/rector": "^2.4" + }, + "suggest": { + "hoa/bench": "If you would like to run the benchmark scripts" + }, + "bin": [ + "bin/vobject", + "bin/generate_vcards" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabre\\VObject\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + }, + { + "name": "Dominik Tobschall", + "email": "dominik@fruux.com", + "homepage": "http://tobschall.de/", + "role": "Developer" + }, + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net", + "homepage": "http://mnt.io/", + "role": "Developer" + } + ], + "description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects", + "homepage": "http://sabre.io/vobject/", + "keywords": [ + "availability", + "freebusy", + "iCalendar", + "ical", + "ics", + "jCal", + "jCard", + "recurrence", + "rfc2425", + "rfc2426", + "rfc2739", + "rfc4770", + "rfc5545", + "rfc5546", + "rfc6321", + "rfc6350", + "rfc6351", + "rfc6474", + "rfc6638", + "rfc6715", + "rfc6868", + "vCalendar", + "vCard", + "vcf", + "xCal", + "xCard" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/vobject/issues", + "source": "https://github.com/fruux/sabre-vobject" + }, + "time": "2026-07-07T03:29:53+00:00" + }, + { + "name": "sabre/xml", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/xml.git", + "reference": "bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/xml/zipball/bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05", + "reference": "bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "lib-libxml": ">=2.6.20", + "php": "^8.2", + "sabre/uri": ">=2.0,<4.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.4" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Deserializer/functions.php", + "lib/Serializer/functions.php" + ], + "psr-4": { + "Sabre\\Xml\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + }, + { + "name": "Markus Staab", + "email": "markus.staab@redaxo.de", + "role": "Developer" + } + ], + "description": "sabre/xml is an XML library that you may not hate.", + "homepage": "https://sabre.io/xml/", + "keywords": [ + "XMLReader", + "XMLWriter", + "dom", + "xml" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/xml/issues", + "source": "https://github.com/fruux/sabre-xml" + }, + "time": "2026-04-27T10:56:01+00:00" + }, { "name": "symfony/cache", "version": "v7.4.21", diff --git a/backend/config/packages/framework.yaml b/backend/config/packages/framework.yaml index e2b61d9..a4e3884 100644 --- a/backend/config/packages/framework.yaml +++ b/backend/config/packages/framework.yaml @@ -19,3 +19,6 @@ framework: when@test: framework: test: true + # The address book server is a fake one in tests, see tests/Contacts/FakeCardDav.php + http_client: + mock_response_factory: App\Tests\Contacts\FakeCardDav diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 5465720..9f2f0c9 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -25,3 +25,8 @@ services: # please note that last definitions always *replace* previous ones ZBateson\MailMimeParser\MailMimeParser: ~ + +when@test: + services: + App\Tests\Contacts\FakeCardDav: + public: true diff --git a/backend/migrations/Version20261112222230.php b/backend/migrations/Version20261112222230.php new file mode 100644 index 0000000..2e34db1 --- /dev/null +++ b/backend/migrations/Version20261112222230.php @@ -0,0 +1,44 @@ +addSql('CREATE TABLE contact_card (id VARCHAR(32) NOT NULL, name VARCHAR(255) DEFAULT NULL, org VARCHAR(255) DEFAULT NULL, emails CLOB NOT NULL, phones CLOB NOT NULL, sort VARCHAR(255) NOT NULL, has_photo BOOLEAN NOT NULL, owner VARCHAR(255) NOT NULL, book VARCHAR(500) NOT NULL, href VARCHAR(500) NOT NULL, etag VARCHAR(255) NOT NULL, vcard CLOB NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX contact_card_owner ON contact_card (owner)'); + $this->addSql('CREATE INDEX contact_card_owner_book ON contact_card (owner, book)'); + // The cache can be thrown away: its rows have no card to point at, so it starts empty + $this->addSql('DROP TABLE contact'); + $this->addSql('CREATE TABLE contact (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, search VARCHAR(600) NOT NULL, owner VARCHAR(255) NOT NULL, book VARCHAR(500) NOT NULL, name VARCHAR(255) DEFAULT NULL, email VARCHAR(320) NOT NULL, card_id VARCHAR(32) NOT NULL, has_photo BOOLEAN NOT NULL)'); + $this->addSql('CREATE INDEX contact_owner ON contact (owner)'); + $this->addSql('CREATE INDEX contact_card_id ON contact (card_id)'); + $this->addSql('ALTER TABLE contact_sync ADD COLUMN available BOOLEAN DEFAULT 1 NOT NULL'); + // Every book is fetched again, on the next use rather than in five minutes + $this->addSql("UPDATE contact_sync SET books = '{}', checked_at = NULL"); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE contact_card'); + $this->addSql('DROP TABLE contact'); + $this->addSql('CREATE TABLE contact (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, search VARCHAR(600) NOT NULL, owner VARCHAR(255) NOT NULL, book VARCHAR(500) NOT NULL, name VARCHAR(255) DEFAULT NULL, email VARCHAR(320) NOT NULL, photo CLOB DEFAULT NULL, photo_mime VARCHAR(64) DEFAULT NULL)'); + $this->addSql('CREATE INDEX contact_owner ON contact (owner)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__contact_sync AS SELECT home, books, checked_at, owner FROM contact_sync'); + $this->addSql('DROP TABLE contact_sync'); + $this->addSql('CREATE TABLE contact_sync (home VARCHAR(500) DEFAULT NULL, books CLOB NOT NULL, checked_at DATETIME DEFAULT NULL, owner VARCHAR(255) NOT NULL, PRIMARY KEY (owner))'); + $this->addSql("INSERT INTO contact_sync (home, books, checked_at, owner) SELECT home, '{}', NULL, owner FROM __temp__contact_sync"); + $this->addSql('DROP TABLE __temp__contact_sync'); + } +} diff --git a/backend/phpstan.dist.neon b/backend/phpstan.dist.neon index cdf4921..21647a3 100644 --- a/backend/phpstan.dist.neon +++ b/backend/phpstan.dist.neon @@ -4,6 +4,8 @@ includes: parameters: level: 8 + stubFiles: + - stubs/sabre-vobject.stub paths: - src - tests diff --git a/backend/src/Contacts/AddressBook.php b/backend/src/Contacts/AddressBook.php new file mode 100644 index 0000000..b5b5e86 --- /dev/null +++ b/backend/src/Contacts/AddressBook.php @@ -0,0 +1,284 @@ +book($user, $bookId); + $uid = self::uuid(); + $card = CardDocument::blank($uid); + $card->apply($input); + + return $this->write($user, $book, $book.$uid.'.vcf', $card->toString(), null); + } + + public function update(RemoteUser $user, string $id, string $etag, CardInput $input): Card + { + return $this->change($user, $id, $etag, static fn (CardDocument $card) => $card->apply($input)); + } + + public function setPhoto(RemoteUser $user, string $id, string $etag, string $jpeg): Card + { + return $this->change($user, $id, $etag, static fn (CardDocument $card) => $card->setPhoto($jpeg)); + } + + public function removePhoto(RemoteUser $user, string $id, string $etag): Card + { + return $this->change($user, $id, $etag, static fn (CardDocument $card) => $card->removePhoto()); + } + + /** + * @return string the card as it was, so the user can undo + */ + public function delete(RemoteUser $user, string $id, string $etag): string + { + $card = $this->seen($user, $id, $etag); + $this->remove($user, $card); + $this->entityManager->flush(); + + return $card->getVcard(); + } + + /** + * Two cards of the same person become one: the first takes in what the + * other has and it lacks, then the other is deleted. In that order, so + * that a failure half way leaves a card too many, never one too few. + */ + public function merge(RemoteUser $user, string $id, string $etag, string $otherId, string $otherEtag): Card + { + $card = $this->seen($user, $id, $etag); + $other = $this->seen($user, $otherId, $otherEtag); + + $document = CardDocument::fromString($card->getVcard()); + $document->absorb(CardDocument::fromString($other->getVcard())); + + $merged = $this->rewrite($user, $card, $document); + $this->remove($user, $other); + $this->entityManager->flush(); + + return $merged; + } + + /** + * Adds every card of a .vcf file to a book, as new cards. A card that + * cannot be read, or that the server refuses, is counted and skipped. + * + * @return array{imported: int, failed: int} + */ + public function import(RemoteUser $user, string $bookId, string $file): array + { + $book = $this->book($user, $bookId); + $taken = $this->cards->uids($user->getUserIdentifier(), $book); + + $texts = CardDocument::splitAll($file); + // Cards past the limit are not tried, and say so + $failed = max(0, count($texts) - self::IMPORT_MAX_CARDS); + $cards = []; + foreach (array_slice($texts, 0, self::IMPORT_MAX_CARDS) as $text) { + try { + $document = CardDocument::fromString($text); + } catch (InvalidCard) { + ++$failed; + continue; + } + // A UID already in the book would make the server or other apps mix the two up + $uid = $document->uid(); + if (null === $uid || isset($taken[$uid])) { + $uid = self::uuid(); + $document->replaceUid($uid); + } + $taken[$uid] = true; + // The file name is new, never taken from the card + $cards[$book.self::uuid().'.vcf'] = $document->toString(); + } + + $imported = 0; + $owner = $user->getUserIdentifier(); + foreach (array_chunk($cards, self::IMPORT_AT_ONCE, true) as $some) { + $down = null; + foreach ($this->dav->putNew($user, $some) as $href => $etag) { + // Refused for what it is: the next card may well go in + if ($etag instanceof CardChanged || $etag instanceof CardRefused) { + ++$failed; + continue; + } + if ($etag instanceof DavisUnavailable) { + $down ??= $etag; + continue; + } + $vcard = $some[$href]; + if (null === $etag) { + // The server may have changed the card on the way in + ['etag' => $etag, 'vcard' => $vcard] = $this->dav->get($user, $href); + } + $this->contacts->store($owner, $book, $href, $etag, $vcard); + ++$imported; + } + // What went in is kept, even when the server then went away + $this->entityManager->flush(); + if (null !== $down) { + throw $down; + } + } + + return ['imported' => $imported, 'failed' => $failed]; + } + + /** + * @param callable(CardDocument): void $edit + */ + private function change(RemoteUser $user, string $id, string $etag, callable $edit): Card + { + $card = $this->seen($user, $id, $etag); + $document = CardDocument::fromString($card->getVcard()); + $edit($document); + + return $this->rewrite($user, $card, $document); + } + + /** + * The cached card, if it is the version the user's change was made on. + * + * @throws CardChanged when it is not: the cache then has the card as it is now + * @throws CardGone when there is no such card any more + */ + private function seen(RemoteUser $user, string $id, string $etag): Card + { + $card = $this->card($user, $id); + if ($card->getEtag() !== $etag) { + $this->readAgain($user, $card); + throw new CardChanged('The card is not at version '.$etag.' any more'); + } + + return $card; + } + + private function rewrite(RemoteUser $user, Card $card, CardDocument $document): Card + { + try { + return $this->write($user, $card->getBook(), $card->getHref(), $document->toString(), $card->getEtag()); + } catch (CardChanged $e) { + $this->readAgain($user, $card); + throw $e; + } + } + + private function remove(RemoteUser $user, Card $card): void + { + try { + $this->dav->delete($user, $card->getHref(), $card->getEtag()); + } catch (CardChanged $e) { + $this->readAgain($user, $card); + throw $e; + } + $this->contacts->forget($card); + } + + /** + * The server has another version of this card than the cache: fetch it, + * so the next thing the user sees and edits is the card as it is. + * + * @throws CardGone when the card is gone from the server; it leaves the cache too + */ + private function readAgain(RemoteUser $user, Card $card): void + { + try { + $now = $this->dav->get($user, $card->getHref()); + $this->contacts->store($card->getOwner(), $card->getBook(), $card->getHref(), $now['etag'], $now['vcard']); + } catch (CardGone $e) { + $this->contacts->forget($card); + $this->entityManager->flush(); + throw $e; + } catch (DavisUnavailable) { + // The regular sync will catch up + return; + } + $this->entityManager->flush(); + } + + /** + * PUT, then the cache. Without an ETag in the answer the server may have + * changed the card on the way in, so it is read back once. + */ + private function write(RemoteUser $user, string $book, string $href, string $vcard, ?string $ifMatch): Card + { + $etag = $this->dav->put($user, $href, $vcard, $ifMatch); + if (null === $etag) { + ['etag' => $etag, 'vcard' => $vcard] = $this->dav->get($user, $href); + } + $card = $this->contacts->store($user->getUserIdentifier(), $book, $href, $etag, $vcard); + $this->entityManager->flush(); + + return $card; + } + + /** + * A random UUID (version 4), for new UIDs and file names. + */ + private static function uuid(): string + { + $bytes = random_bytes(16); + $bytes[6] = chr(ord($bytes[6]) & 0x0F | 0x40); + $bytes[8] = chr(ord($bytes[8]) & 0x3F | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); + } + + private function card(RemoteUser $user, string $id): Card + { + return $this->cards->findOwned($user->getUserIdentifier(), $id) ?? throw new CardGone('No card '.$id); + } + + /** + * @return string the book's href + */ + private function book(RemoteUser $user, string $id): string + { + return ($this->contacts->book($user->getUserIdentifier(), $id) ?? throw new CardGone('No book '.$id))['href']; + } +} diff --git a/backend/src/Contacts/CardChanged.php b/backend/src/Contacts/CardChanged.php new file mode 100644 index 0000000..6bacaeb --- /dev/null +++ b/backend/src/Contacts/CardChanged.php @@ -0,0 +1,14 @@ + sync token by address book href + * @return array sync token and display name by address book href */ public function books(RemoteUser $user, string $home): array { - $xpath = $this->propfind($user, $home, 1, ''); + $xpath = $this->propfind($user, $home, 1, ''); $books = []; foreach ($this->elements($xpath, '//d:response[.//d:resourcetype/card:addressbook]') as $response) { $href = trim($this->text($xpath, 'd:href', $response)); - $books[$href] = trim($this->text($xpath, './/d:sync-token', $response)); + $books[$href] = [ + 'token' => trim($this->text($xpath, './/d:sync-token', $response)), + 'name' => trim($this->text($xpath, './/d:displayname', $response)), + ]; } return $books; } /** - * Every card of a book, with only FN and EMAIL in it. + * The ETag of every card in a book, nothing else (RFC 6352 section 8.6). * - * @return list + * @return array ETag by card href */ - public function cards(RemoteUser $user, string $book): array + public function etags(RemoteUser $user, string $book): array + { + $etags = []; + foreach ($this->report($user, $book, 'addressbook-query', '') as $href => $props) { + $etags[$href] = $props['etag']; + } + + return $etags; + } + + /** + * Every card of a book, whole. + * + * @return array by card href + */ + public function all(RemoteUser $user, string $book): array + { + return $this->withData($this->report($user, $book, 'addressbook-query', '')); + } + + /** + * These cards of a book, whole (RFC 6352 section 8.7). A card that is gone + * in the meantime is simply not in the answer. + * + * @param list $hrefs + * + * @return array by card href + */ + public function multiget(RemoteUser $user, string $book, array $hrefs): array + { + $refs = implode('', array_map(static fn (string $href) => ''.htmlspecialchars($href, ENT_XML1).'', $hrefs)); + + return $this->withData($this->report($user, $book, 'addressbook-multiget', ''.$refs)); + } + + /** + * One card, whole. + * + * @return array{etag: string, vcard: string} + */ + public function get(RemoteUser $user, string $href): array + { + $response = $this->send($user, 'GET', $href, ['headers' => ['Accept' => 'text/vcard']], [200]); + try { + $vcard = $response->getContent(); + $etag = $response->getHeaders()['etag'][0] ?? ''; + } catch (ExceptionInterface $e) { + throw new DavisUnavailable(sprintf('GET %s: %s', $href, $e->getMessage()), previous: $e); + } + if ('' === $etag || '' === trim($vcard)) { + throw new DavisUnavailable(sprintf('GET %s: no card or no ETag', $href)); + } + + return ['etag' => $etag, 'vcard' => $vcard]; + } + + /** + * Writes a card. With an ETag, only over that version of the card; without + * one, only if there is no card there yet. + * + * @return ?string the new ETag, or null when the server does not say (it + * may have changed the card, which must then be read again) + * + * @throws CardChanged when the card is not at that version any more + */ + public function put(RemoteUser $user, string $href, string $vcard, ?string $ifMatch): ?string + { + return $this->written($href, $this->settle($this->startPut($user, $href, $vcard, $ifMatch), 'PUT', $href, [200, 201, 204])); + } + + /** + * Writes several new cards side by side. CardDAV has no request that + * takes many cards, and each one costs the server a password check, so + * an import is only quick when its requests overlap. + * + * @template K of string + * + * @param array $cards vCard by href + * + * @return array by href: the new ETag (null when the server + * does not say), or why the card was not written + */ + public function putNew(RemoteUser $user, array $cards): array + { + // All sent before the first answer is read + $responses = []; + foreach ($cards as $href => $vcard) { + $responses[$href] = $this->startPut($user, $href, $vcard, null); + } + + $written = []; + foreach ($responses as $href => $response) { + try { + $written[$href] = $this->written($href, $this->settle($response, 'PUT', $href, [200, 201, 204])); + } catch (CardChanged|CardRefused|DavisUnavailable $e) { + $written[$href] = $e; + } + } + + return $written; + } + + private function startPut(RemoteUser $user, string $href, string $vcard, ?string $ifMatch): ResponseInterface + { + $condition = null === $ifMatch ? ['If-None-Match' => '*'] : ['If-Match' => $ifMatch]; + + return $this->start($user, 'PUT', $href, [ + 'headers' => ['Content-Type' => 'text/vcard; charset=utf-8'] + $condition, + 'body' => $vcard, + ]); + } + + /** + * @return ?string the ETag of a card just written, null when the server does not give one + */ + private function written(string $href, ResponseInterface $response): ?string + { + try { + $etag = $response->getHeaders(false)['etag'][0] ?? null; + } catch (ExceptionInterface $e) { + throw new DavisUnavailable(sprintf('PUT %s: %s', $href, $e->getMessage()), previous: $e); + } + + // A weak ETag says the server stored something else than what was sent + return null === $etag || str_starts_with($etag, 'W/') ? null : $etag; + } + + /** + * @throws CardChanged when the card is not at that version any more + */ + public function delete(RemoteUser $user, string $href, string $ifMatch): void + { + $this->send($user, 'DELETE', $href, ['headers' => ['If-Match' => $ifMatch]], [200, 204]); + } + + /** + * @return array by card href, only what the server found + */ + private function report(RemoteUser $user, string $book, string $report, string $content): array { $body = '' - .'' - .'' - .''; + .''.$content.''; $xpath = $this->request($user, 'REPORT', $book, 1, $body); $cards = []; - foreach ($this->elements($xpath, '//card:address-data') as $data) { - $cards[] = $data->textContent; + foreach ($this->elements($xpath, '//d:response') as $response) { + $href = trim($this->text($xpath, 'd:href', $response)); + // Only the properties the server could give, not those under a 404 propstat + $found = './/d:propstat[not(d:status) or contains(d:status, " 200 ")]/d:prop/'; + $etag = trim($this->text($xpath, $found.'d:getetag', $response)); + if ('' === $href || '' === $etag) { + continue; + } + $data = $this->elements($xpath, $found.'card:address-data', $response)[0] ?? null; + $cards[$href] = ['etag' => $etag, 'vcard' => $data?->textContent]; } return $cards; } + /** + * @param array $cards + * + * @return array + */ + private function withData(array $cards): array + { + $whole = []; + foreach ($cards as $href => $card) { + if (null !== $card['vcard'] && '' !== trim($card['vcard'])) { + $whole[$href] = ['etag' => $card['etag'], 'vcard' => $card['vcard']]; + } + } + + return $whole; + } + private function propfind(RemoteUser $user, string $url, int $depth, string $props): DOMXPath { $body = '' @@ -87,16 +255,11 @@ private function propfind(RemoteUser $user, string $url, int $depth, string $pro private function request(RemoteUser $user, string $method, string $url, int $depth, string $body): DOMXPath { + $response = $this->send($user, $method, $url, [ + 'headers' => ['Depth' => (string) $depth, 'Content-Type' => 'application/xml; charset=utf-8'], + 'body' => $body, + ], [207]); try { - $response = $this->http->request($method, $this->absolute($url), [ - 'auth_basic' => [$this->config->login($user), $this->config->secret], - 'headers' => ['Depth' => (string) $depth, 'Content-Type' => 'application/xml; charset=utf-8'], - 'body' => $body, - 'timeout' => 10, - ]); - if (207 !== $response->getStatusCode()) { - throw new DavisUnavailable(sprintf('%s %s: HTTP %d', $method, $url, $response->getStatusCode())); - } $xml = $response->getContent(); } catch (ExceptionInterface $e) { throw new DavisUnavailable(sprintf('%s %s: %s', $method, $url, $e->getMessage()), previous: $e); @@ -121,6 +284,59 @@ private function request(RemoteUser $user, string $method, string $url, int $dep return $xpath; } + /** + * @param array $options + * @param list $accepted the statuses that mean it worked + */ + private function send(RemoteUser $user, string $method, string $url, array $options, array $accepted): ResponseInterface + { + return $this->settle($this->start($user, $method, $url, $options), $method, $url, $accepted); + } + + /** + * Sends a request without waiting for its answer. + * + * @param array $options + */ + private function start(RemoteUser $user, string $method, string $url, array $options): ResponseInterface + { + try { + return $this->http->request($method, $this->absolute($url), $options + [ + 'auth_basic' => [$this->config->login($user), $this->config->secret], + 'timeout' => 10, + ]); + } catch (ExceptionInterface $e) { + throw new DavisUnavailable(sprintf('%s %s: %s', $method, $url, $e->getMessage()), previous: $e); + } + } + + /** + * Waits for the answer and says what it means. + * + * @param list $accepted + */ + private function settle(ResponseInterface $response, string $method, string $url, array $accepted): ResponseInterface + { + try { + $status = $response->getStatusCode(); + } catch (ExceptionInterface $e) { + throw new DavisUnavailable(sprintf('%s %s: %s', $method, $url, $e->getMessage()), previous: $e); + } + + $message = sprintf('%s %s: HTTP %d', $method, $url, $status); + // Reading a book or the home that fails means the server is not answering as it should + $onCard = in_array($method, ['GET', 'PUT', 'DELETE'], true); + + return match (true) { + in_array($status, $accepted, true) => $response, + $onCard && 412 === $status => throw new CardChanged($message), + $onCard && (404 === $status || 410 === $status) => throw new CardGone($message), + // Refused for what was sent, not because the server or the key is broken + in_array($status, [400, 403, 409, 413, 415, 422], true) && 'PUT' === $method => throw new CardRefused($message), + default => throw new DavisUnavailable($message), + }; + } + private function href(DOMXPath $xpath, string $query): string { $href = trim($this->text($xpath, $query)); diff --git a/backend/src/Contacts/CardDocument.php b/backend/src/Contacts/CardDocument.php new file mode 100644 index 0000000..534192a --- /dev/null +++ b/backend/src/Contacts/CardDocument.php @@ -0,0 +1,981 @@ +, + * phones: list, + * addresses: list, + * urls: list, + * birthday: ?string, note: ?string, photo: bool + * } + * @phpstan-type Summary array{name: ?string, org: ?string, emails: list, phones: list, hasPhoto: bool} + */ +final class CardDocument +{ + private const string PRODID = '-//Postel//Postel//EN'; + + /** The types the address book knows, in the order they win when a line has several */ + private const array PHONE_TYPES = ['cell', 'work', 'home']; + private const array PLACE_TYPES = ['work', 'home']; + + /** Properties shown as lists: removing one also removes its group, not these */ + private const array LISTS = ['EMAIL', 'TEL', 'ADR', 'URL']; + + /** Merging: what makes a card this card, never taken from another one */ + private const array OWN = ['VERSION', 'PRODID', 'UID', 'REV', 'X-ABUID']; + + /** Merging: a card can have several of these, so the other card's are added to this one's */ + private const array SEVERAL = ['EMAIL', 'TEL', 'ADR', 'URL', 'IMPP', 'X-SOCIALPROFILE', 'RELATED', 'X-ABRELATEDNAMES', 'X-ABDATE', 'CATEGORIES', 'KEY', 'LANG']; + + /** Merging: lines that only say something about another line of their Apple group */ + private const array GROUP_NOTES = ['X-ABLABEL', 'X-ABADR']; + + /** + * Each line read from the server, as vobject would write it back right + * after reading: a line that still writes the same was not edited. + * + * @var WeakMap + */ + private WeakMap $read; + + /** The place of the last line read, so lines taken from another card follow in their order */ + private int $lastLine = 0; + + private function __construct(private readonly VCardComponent $card) + { + /** @var WeakMap $read */ + $read = new WeakMap(); + $this->read = $read; + foreach ($card->children() as $child) { + if (!$child instanceof Property || !isset($child->lineString)) { + continue; + } + $this->lastLine = max($this->lastLine, $child->lineIndex ?? 0); + // Quoted-printable values (vCard 2.1) span several lines, the parser only keeps the first + $encoding = $child->parameters['ENCODING'] ?? null; + if (null !== $encoding && 'QUOTED-PRINTABLE' === strtoupper((string) $encoding->getValue())) { + continue; + } + $this->read[$child] = $child->serialize(); + } + } + + /** + * @throws InvalidCard + */ + public static function fromString(string $data): self + { + try { + $card = (new CardParser())->parse($data, CardParser::OPTION_FORGIVING); + } catch (Throwable $e) { + // The parser throws many kinds of errors on bad input, all mean the same here + throw new InvalidCard('Not a readable vCard: '.$e->getMessage(), previous: $e); + } + if (!$card instanceof VCardComponent) { + throw new InvalidCard('Not a vCard'); + } + self::writeUrisAsRead($card); + + return new self($card); + } + + /** + * A new, empty card. + */ + public static function blank(string $uid): self + { + return new self(new VCardComponent(['VERSION' => '3.0', 'PRODID' => self::PRODID, 'UID' => $uid, 'N' => ['', '', '', '', ''], 'FN' => ''], false)); + } + + /** + * Cuts a .vcf file into its cards, without reading them: a broken card + * does not take the others down with it. + * + * @return list + */ + public static function splitAll(string $file): array + { + if (str_starts_with($file, "\xEF\xBB\xBF")) { + $file = substr($file, 3); + } + + $cards = []; + $lines = []; + $depth = 0; + foreach (preg_split("/\r\n|\n|\r/", $file) ?: [] as $line) { + // A folded line starts with a space, so only the end is trimmed + $bare = strtoupper(rtrim($line)); + if ('BEGIN:VCARD' === $bare) { + ++$depth; + } + if ($depth > 0) { + $lines[] = $line; + } + if ('END:VCARD' === $bare && $depth > 0 && 0 === --$depth) { + $cards[] = implode("\r\n", $lines)."\r\n"; + $lines = []; + } + } + + return $cards; + } + + /** + * The card in the order it was read, new lines at the end. A line that + * was not edited is written exactly as it came, not as vobject would + * write it: vobject changes the escaping of some values it does not know + * (a ";" in an X- property, GEO), and other apps may care. + */ + public function toString(): string + { + $children = $this->card->children(); + $order = []; + foreach ($children as $i => $child) { + $order[$i] = match (true) { + 'VERSION' === $child->name => -1, + $child instanceof Property && isset($child->lineIndex) => $child->lineIndex, + default => PHP_INT_MAX, + }; + } + // Stable: lines added during this edit keep the order they were added in + $positions = array_keys($children); + array_multisort($order, SORT_ASC, SORT_NUMERIC, $positions, SORT_ASC, SORT_NUMERIC, $children); + + $out = "BEGIN:VCARD\r\n"; + foreach ($children as $child) { + $serialized = $child->serialize(); + if ($child instanceof Property && isset($this->read[$child], $child->lineString) && $this->read[$child] === $serialized) { + $serialized = self::fold($child->lineString); + } + $out .= $serialized; + } + + return $out."END:VCARD\r\n"; + } + + /** + * Lines of at most 75 bytes, never cutting a UTF-8 character (RFC 6350 section 3.2). + */ + private static function fold(string $line): string + { + // Each piece gets a line break and a space; the last one only needs the break + return substr(preg_replace('/((?:^.)?.{1,74}(?![\x80-\xbf]))/', "$1\r\n ", $line) ?? $line."\r\n ", 0, -1); + } + + public function uid(): ?string + { + return $this->text('UID'); + } + + /** + * Only for import, when the book already has a card with this UID. + */ + public function replaceUid(string $uid): void + { + $this->card->remove('UID'); + $this->card->add('UID', $uid); + } + + /** + * What the cache keeps beside the card, for the list. + * + * @return Summary + */ + public function summary(): array + { + return [ + 'name' => $this->name(), + 'org' => $this->org(), + 'emails' => array_values(array_filter(array_map(fn (Property $p) => $this->flat($p), $this->listed('EMAIL')), static fn (string $e) => '' !== $e)), + 'phones' => array_values(array_filter(array_map(fn (Property $p) => $this->phone($p), $this->listed('TEL')), static fn (string $t) => '' !== $t)), + 'hasPhoto' => null !== $this->photo(), + ]; + } + + /** + * The card as the address book shows it. + * + * @return Model + */ + public function toArray(): array + { + $n = $this->first('N'); + $parts = null === $n ? [] : $this->parts($n); + + $emails = []; + foreach ($this->listed('EMAIL') as $key => $p) { + $emails[] = ['key' => $key, 'value' => $this->flat($p), 'type' => self::type($p, self::PLACE_TYPES), 'label' => $this->label($p)]; + } + $phones = []; + foreach ($this->listed('TEL') as $key => $p) { + $phones[] = ['key' => $key, 'value' => $this->phone($p), 'type' => self::type($p, self::PHONE_TYPES), 'label' => $this->label($p)]; + } + $addresses = []; + foreach ($this->listed('ADR') as $key => $p) { + $adr = array_pad($this->parts($p), 7, ''); + $addresses[] = ['key' => $key, 'type' => self::type($p, self::PLACE_TYPES), 'street' => $adr[2], 'city' => $adr[3], 'region' => $adr[4], 'postalCode' => $adr[5], 'country' => $adr[6]]; + } + $urls = []; + foreach ($this->listed('URL') as $key => $p) { + $urls[] = ['key' => $key, 'value' => $this->flat($p)]; + } + + return [ + 'name' => $this->name(), + 'firstName' => self::nullIfEmpty($parts[1] ?? ''), + 'lastName' => self::nullIfEmpty($parts[0] ?? ''), + 'org' => $this->org(), + 'title' => $this->text('TITLE'), + 'emails' => $emails, + 'phones' => $phones, + 'addresses' => $addresses, + 'urls' => $urls, + 'birthday' => $this->birthday(), + 'note' => $this->text('NOTE'), + 'photo' => null !== $this->photo(), + ]; + } + + /** + * Applies what the editor sent. A field equal to what the card already + * shows is not written, so lines the user did not change stay byte for byte. + */ + public function apply(CardInput $input): void + { + $before = $this->toArray(); + $first = trim($input->firstName ?? ''); + $last = trim($input->lastName ?? ''); + $org = trim($input->org ?? ''); + + $namesChanged = $first !== ($before['firstName'] ?? '') || $last !== ($before['lastName'] ?? ''); + $orgChanged = $org !== ($before['org'] ?? ''); + if ($namesChanged) { + $this->setNames($first, $last); + } + if ($orgChanged) { + $this->setOrg($org); + } + $this->setText('TITLE', trim($input->title ?? ''), $before['title']); + $this->setText('NOTE', trim(str_replace(["\r\n", "\r"], "\n", $input->note ?? '')), $before['note']); + $this->setEmails($input->emails); + $this->setPhones($input->phones); + $this->setAddresses($input->addresses); + $this->setUrls($input->urls); + if (($input->birthday ?? '') !== ($before['birthday'] ?? '')) { + $this->setBirthday($input->birthday ?? ''); + } + + // FN follows the names. One written by hand ("Dr Bob Martin Jr") is only + // replaced when the names themselves change, not when the organisation does. + $fn = $this->text('FN'); + $derived = self::displayName($before['firstName'] ?? '', $before['lastName'] ?? '', $before['org'] ?? '', $before['emails'][0]['value'] ?? ''); + if ($namesChanged || null === $fn || $fn === $derived) { + $now = self::displayName($first, $last, $org, trim($input->emails[0]->value ?? '')); + if ('' !== $now) { + $this->setText('FN', $now, $fn); + } + } + + $this->touch(); + } + + /** + * Takes in what another card of the same person has and this one lacks. + * Nothing of this card is replaced: + * + * - emails, phones, addresses, links and the other lines a card can have + * several of are added, unless this card already has the same one; + * - a line a card has once (name, organisation, birthday, picture...) is + * taken only when this card has none; + * - two different notes are both kept, one under the other. + * + * Lines are copied as they were read, with their Apple group and its label. + * + * @throws InvalidCard when the other card cannot be brought to this one's version + */ + public function absorb(self $other): void + { + if ($other->isVersion4() !== $this->isVersion4()) { + try { + $converted = $other->card->convert($this->card->getDocumentType()); + } catch (Throwable $e) { + throw new InvalidCard('The two cards are of different versions: '.$e->getMessage(), previous: $e); + } + // Converted lines are new ones: they have no original text to keep + $other = new self($converted); + } + + $groups = []; + foreach ($other->card->children() as $line) { + if (!$line instanceof Property || in_array($line->name, self::OWN, true) || in_array($line->name, self::GROUP_NOTES, true)) { + continue; + } + if ('NOTE' === $line->name) { + $this->absorbNote($other->flat($line)); + continue; + } + if (!$this->lacks($line, $other)) { + continue; + } + + if (null === $line->group || '' === $line->group) { + $this->adopt($line, null, $other); + continue; + } + // The whole group comes along, under a name that is free on this card + $group = $groups[strtolower($line->group)] ??= $this->freeGroup(); + $this->adopt($line, $group, $other); + foreach ($other->card->select($line->group.'.') as $note) { + if ($note instanceof Property && in_array($note->name, self::GROUP_NOTES, true) && !$this->has($group, $note->name)) { + $this->adopt($note, $group, $other); + } + } + } + + $this->touch(); + } + + /** + * Whether this card is missing that line of another card. + */ + private function lacks(Property $line, self $other): bool + { + $name = (string) $line->name; + if ('' === $other->identity($line)) { + return false; + } + if (in_array($name, self::SEVERAL, true)) { + foreach ($this->listed($name) as $mine) { + if ($this->identity($mine) === $other->identity($line)) { + return false; + } + } + + return true; + } + + // Once per card: only when this card has none, and only the other card's first + foreach ($this->listed($name) as $mine) { + if ('' !== $this->identity($mine)) { + return false; + } + } + + return $other->first($name) === $line; + } + + /** + * What tells two lines of the same name apart; empty for a line that says nothing. + */ + private function identity(Property $line): string + { + return match ($line->name) { + 'EMAIL', 'URL' => mb_strtolower($this->flat($line)), + // The same number whatever its spaces and dashes + 'TEL' => preg_replace('/\D+/', '', $this->phone($line)) ?: mb_strtolower($this->phone($line)), + // The same place, post office box or not + 'ADR' => trim(mb_strtolower(implode("\n", array_slice(array_pad($this->parts($line), 7, ''), 2, 5)))), + default => trim($line instanceof Binary ? (string) $line->getValue() : implode('', $this->parts($line))), + }; + } + + private function absorbNote(string $theirs): void + { + $mine = $this->text('NOTE') ?? ''; + if ('' === $theirs || str_contains($mine, $theirs)) { + return; + } + $this->setText('NOTE', '' === $mine ? $theirs : $mine."\n\n".$theirs, $mine); + } + + /** + * Copies a line of another card to the end of this one, written as it was read. + */ + private function adopt(Property $line, ?string $group, self $from): void + { + // An empty line of that name gives way: a card has one N, one FN + if (!in_array($line->name, self::SEVERAL, true) && !in_array($line->name, self::GROUP_NOTES, true)) { + $this->card->remove((string) $line->name); + } + + $copy = clone $line; + $copy->group = $group; + // After this card's own lines, in the order they are taken + $copy->lineIndex = ++$this->lastLine; + $this->card->add($copy); + + if (!isset($from->read[$line], $line->lineString)) { + return; + } + $text = $line->lineString; + if (null !== $line->group && '' !== $line->group) { + $text = (null === $group ? '' : $group.'.').substr($text, strlen($line->group) + 1); + } + $copy->lineString = $text; + $this->read[$copy] = $copy->serialize(); + } + + /** + * "item7" when the card's groups go up to item6. + */ + private function freeGroup(): string + { + $last = 0; + foreach ($this->card->children() as $line) { + if ($line instanceof Property && null !== $line->group && 1 === preg_match('/^item(\d+)$/i', $line->group, $m)) { + $last = max($last, (int) $m[1]); + } + } + + return 'item'.($last + 1); + } + + private function has(string $group, string $name): bool + { + foreach ($this->card->select($group.'.'.$name) as $line) { + if ($line instanceof Property) { + return true; + } + } + + return false; + } + + /** + * The name a card goes by when nobody wrote one: "first last", else the + * organisation, else the first address. + */ + private static function displayName(string $first, string $last, string $org, string $email): string + { + $name = trim($first.' '.$last); + + return '' !== $name ? $name : ('' !== $org ? $org : $email); + } + + /** + * The card's inline picture, if it is one of the types the app serves. + * + * @return ?array{mime: string, data: string} + */ + public function photo(): ?array + { + foreach ($this->listed('PHOTO') as $p) { + $mime = null; + $data = null; + if ($p instanceof Binary) { + $data = $p->getValue(); + foreach ($p->parameters as $name => $parameter) { + // "TYPE=JPEG", "MEDIATYPE=image/jpeg", or 2.1's bare "PHOTO;JPEG" + $type = strtoupper(null === $parameter->getValue() ? (string) $name : (in_array($name, ['TYPE', 'MEDIATYPE'], true) ? $parameter->getValue() : '')); + $mime ??= VCard::PHOTO_TYPES[$type] ?? (in_array(strtolower($type), VCard::PHOTO_TYPES, true) ? strtolower($type) : null); + } + } elseif ($p instanceof Uri && 1 === preg_match('~^data:(image/[a-z0-9.+-]+);base64,(.*)$~is', $this->flat($p), $m)) { + $mime = strtolower($m[1]); + $data = base64_decode(preg_replace('/\s+/', '', $m[2]) ?? '', true); + } + if (is_string($data) && '' !== $data && in_array($mime, VCard::PHOTO_TYPES, true)) { + return ['mime' => $mime, 'data' => $data]; + } + } + + return null; + } + + /** + * Replaces the picture with this JPEG, in the way the card's version writes it. + */ + public function setPhoto(string $jpeg): void + { + $this->card->remove('PHOTO'); + if ($this->isVersion4()) { + $this->card->add(new PlainUri($this->card, 'PHOTO', 'data:image/jpeg;base64,'.base64_encode($jpeg))); + } else { + $this->card->add('PHOTO', $jpeg, ['ENCODING' => 'b', 'TYPE' => 'JPEG']); + } + $this->touch(); + } + + public function removePhoto(): void + { + $this->card->remove('PHOTO'); + $this->touch(); + } + + /** + * vobject 5 escapes the commas of a URI when writing it, but not all of + * them when reading: an edited link would grow a backslash, and a 4.0 + * picture ("data:image/jpeg;base64,...") would break. URIs are swapped + * for a property that writes them as they are. + */ + private static function writeUrisAsRead(VCardComponent $card): void + { + $children = $card->children(); + $swapped = false; + foreach ($children as $i => $child) { + if (!$child instanceof Uri || $child instanceof PlainUri) { + continue; + } + $plain = new PlainUri($card, (string) $child->name, $child->getValue(), [], $child->group); + $plain->parameters = $child->parameters; + if (isset($child->lineIndex, $child->lineString)) { + $plain->lineIndex = $child->lineIndex; + $plain->lineString = $child->lineString; + } + $children[$i] = $plain; + $swapped = true; + } + if (!$swapped) { + return; + } + // Components have no "replace": empty it and add everything back + foreach ($children as $child) { + $card->remove((string) $child->name); + } + foreach ($children as $child) { + $card->add($child); + } + } + + private function name(): ?string + { + return $this->text('FN') ?? $this->org() ?? $this->summaryEmail(); + } + + private function summaryEmail(): ?string + { + foreach ($this->listed('EMAIL') as $p) { + if ('' !== $this->flat($p)) { + return $this->flat($p); + } + } + + return null; + } + + private function org(): ?string + { + $org = $this->first('ORG'); + + return null === $org ? null : self::nullIfEmpty($this->parts($org)[0] ?? ''); + } + + /** + * "1984-03-12", or "--03-12" for a birthday without a year: written so + * in 4.0, or by Apple as year 1604 with X-APPLE-OMIT-YEAR. + */ + private function birthday(): ?string + { + $bday = $this->first('BDAY'); + if (null === $bday) { + return null; + } + $raw = $this->flat($bday); + if (1 === preg_match('/^(\d{4})-?(\d{2})-?(\d{2})(T.*)?$/', $raw, $m)) { + $omitted = ($bday->parameters['X-APPLE-OMIT-YEAR'] ?? null)?->getValue(); + + return $omitted === $m[1] ? "--{$m[2]}-{$m[3]}" : "{$m[1]}-{$m[2]}-{$m[3]}"; + } + if (1 === preg_match('/^--(\d{2})-?(\d{2})$/', $raw, $m)) { + return "--{$m[1]}-{$m[2]}"; + } + + return null; + } + + private function setNames(string $first, string $last): void + { + $n = $this->first('N'); + if (null === $n) { + $this->card->add('N', [$last, $first, '', '', '']); + + return; + } + // Additional names, prefixes and suffixes are kept + $parts = array_pad($this->parts($n), 5, ''); + $parts[0] = $last; + $parts[1] = $first; + $n->setParts($parts); + } + + private function setOrg(string $org): void + { + $property = $this->first('ORG'); + if (null === $property) { + if ('' !== $org) { + $this->card->add('ORG', [$org]); + } + + return; + } + // Departments after the name are kept + $parts = $this->parts($property); + $parts[0] = $org; + if ('' === implode('', $parts)) { + $this->card->remove($property); + } else { + $property->setParts($parts); + } + } + + /** + * Sets the first property of that name, removes it when empty. + */ + private function setText(string $name, string $value, ?string $before): void + { + if ($value === ($before ?? '')) { + return; + } + $property = $this->first($name); + if ('' === $value) { + if (null !== $property) { + $this->card->remove($property); + } + } elseif (null === $property) { + $this->card->add($name, $value); + } else { + $property->setValue($value); + } + } + + /** + * @param list $rows + */ + private function setEmails(array $rows): void + { + $existing = $this->listed('EMAIL'); + $kept = []; + foreach ($rows as $row) { + $value = trim($row->value); + $property = $this->claim($existing, $row->key, $kept); + if (null === $property) { + $this->card->add('EMAIL', $value, $this->typeParameter($row->type)); + continue; + } + if ($value !== $this->flat($property)) { + $property->setValue($value); + } + $this->setType($property, $row->type, self::PLACE_TYPES); + } + $this->removeUnclaimed($existing, $kept); + } + + /** + * @param list $rows + */ + private function setPhones(array $rows): void + { + $existing = $this->listed('TEL'); + $kept = []; + foreach ($rows as $row) { + $value = trim($row->value); + $property = $this->claim($existing, $row->key, $kept); + if (null === $property) { + $this->card->add('TEL', $value, $this->typeParameter($row->type)); + continue; + } + if ($value !== $this->phone($property)) { + // A 4.0 "tel:" URI stays one + $property->setValue($property instanceof Uri ? 'tel:'.$value : $value); + } + $this->setType($property, $row->type, self::PHONE_TYPES); + } + $this->removeUnclaimed($existing, $kept); + } + + /** + * @param list $rows + */ + private function setAddresses(array $rows): void + { + $existing = $this->listed('ADR'); + $kept = []; + foreach ($rows as $row) { + if ($row->isEmpty()) { + continue; + } + $fields = [trim($row->street), trim($row->city), trim($row->region), trim($row->postalCode), trim($row->country)]; + $property = $this->claim($existing, $row->key, $kept); + if (null === $property) { + $this->card->add('ADR', ['', '', ...$fields], $this->typeParameter($row->type)); + continue; + } + // The post office box and extended address are kept + $parts = array_pad($this->parts($property), 7, ''); + if (array_slice($parts, 2, 5) !== $fields) { + $property->setParts([$parts[0], $parts[1], ...$fields, ...array_slice($parts, 7)]); + } + $this->setType($property, $row->type, self::PLACE_TYPES); + } + $this->removeUnclaimed($existing, $kept); + } + + /** + * @param list $rows + */ + private function setUrls(array $rows): void + { + $existing = $this->listed('URL'); + $kept = []; + foreach ($rows as $row) { + $value = trim($row->value); + $property = $this->claim($existing, $row->key, $kept); + if (null === $property) { + $this->card->add(new PlainUri($this->card, 'URL', $value)); + } elseif ($value !== $this->flat($property)) { + $property->setValue($value); + } + } + $this->removeUnclaimed($existing, $kept); + } + + private function setBirthday(string $birthday): void + { + $this->card->remove('BDAY'); + if (1 !== preg_match('/^(\d{4}|-)-(\d{2})-(\d{2})$/', $birthday, $m)) { + return; + } + $year = '-' === $m[1] ? '--' : $m[1]; + $this->card->add('BDAY', $this->isVersion4() ? $year.$m[2].$m[3] : ('--' === $year ? '--' : $year.'-').$m[2].'-'.$m[3]); + } + + /** + * The existing property a row points at, once: a key sent twice, or one + * that is not on the card, makes a new line. + * + * @param list $existing + * @param array $kept + */ + private function claim(array $existing, ?int $key, array &$kept): ?Property + { + if (null === $key || !isset($existing[$key]) || isset($kept[$key])) { + return null; + } + $kept[$key] = true; + + return $existing[$key]; + } + + /** + * Lines the editor did not send back are removed, with the lines of their + * Apple group (its X-ABLabel, for instance). + * + * @param list $existing + * @param array $kept + */ + private function removeUnclaimed(array $existing, array $kept): void + { + foreach ($existing as $key => $property) { + if (isset($kept[$key])) { + continue; + } + $this->card->remove($property); + if (null === $property->group || '' === $property->group) { + continue; + } + foreach ($this->card->select($property->group.'.') as $sibling) { + if ($sibling instanceof Property && !in_array($sibling->name, self::LISTS, true)) { + $this->card->remove($sibling); + } + } + } + } + + /** + * home, work or cell, else other. Only the TYPE values the app knows are + * read; "pref", "voice" or "internet" are left alone. + * + * @param list $known + */ + private static function type(Property $property, array $known): string + { + $values = array_map(strtolower(...), self::typeValues($property)); + foreach ($known as $type) { + if (in_array($type, $values, true)) { + return $type; + } + } + + return 'other'; + } + + /** + * Changes the type only when it changed, and only the TYPE values the app knows. + * + * @param list $known + */ + private function setType(Property $property, string $type, array $known): void + { + if (self::type($property, $known) === $type) { + return; + } + $parameter = $property->parameters['TYPE'] ?? null; + $values = array_values(array_filter( + self::typeValues($property), + static fn (string $v) => !in_array(strtolower($v), self::PHONE_TYPES, true), + )); + if ('other' !== $type) { + $values[] = $this->isVersion4() ? $type : strtoupper($type); + } + if ([] === $values) { + unset($property->parameters['TYPE']); + } elseif ($parameter instanceof Parameter) { + $parameter->setParts($values); + } else { + $property->add('TYPE', $values); + } + } + + /** + * The TYPE values, also when a 4.0 card quotes them as one: TYPE="cell,voice". + * + * @return list + */ + private static function typeValues(Property $property): array + { + $values = []; + foreach (($property->parameters['TYPE'] ?? null)?->getParts() ?? [] as $part) { + foreach (explode(',', (string) $part) as $value) { + if ('' !== trim($value)) { + $values[] = trim($value); + } + } + } + + return $values; + } + + /** + * @return array + */ + private function typeParameter(string $type): array + { + if ('other' === $type) { + return []; + } + + return ['TYPE' => $this->isVersion4() ? $type : strtoupper($type)]; + } + + private function touch(): void + { + $this->card->remove('REV'); + $this->card->add('REV', gmdate('Ymd\THis\Z')); + } + + private function isVersion4(): bool + { + return VCardComponent::VCARD40 === $this->card->getDocumentType(); + } + + /** + * Apple names a line with an X-ABLabel in the same group, "_$!!$_" for its own labels. + */ + private function label(Property $property): ?string + { + if (null === $property->group || '' === $property->group) { + return null; + } + $label = array_values($this->card->select($property->group.'.X-ABLABEL'))[0] ?? null; + if (!$label instanceof Property) { + return null; + } + $text = $this->flat($label); + + return self::nullIfEmpty(1 === preg_match('/^_\$!<(.*)>!\$_$/', $text, $m) ? $m[1] : $text); + } + + private function phone(Property $property): string + { + $value = $this->flat($property); + + return $property instanceof Uri && 0 === stripos($value, 'tel:') ? substr($value, 4) : $value; + } + + /** + * @return list + */ + private function listed(string $name): array + { + return array_values(array_filter($this->card->select($name), static fn ($p) => $p instanceof Property)); + } + + private function first(string $name): ?Property + { + return $this->listed($name)[0] ?? null; + } + + private function text(string $name): ?string + { + $property = $this->first($name); + + return null === $property ? null : self::nullIfEmpty($this->flat($property)); + } + + /** + * The value as one string. Text values split on unescaped commas, which + * many writers leave in notes and titles: they are joined back. + */ + private function flat(Property $property): string + { + if ($property instanceof Uri || $property instanceof Binary) { + $value = $property->getValue(); + + return is_string($value) ? trim($value) : ''; + } + + return trim(implode(',', $this->parts($property))); + } + + /** + * @return list + */ + private function parts(Property $property): array + { + return array_values(array_map( + static fn ($part) => is_array($part) ? implode(',', array_map(strval(...), $part)) : (string) $part, + $property->getParts(), + )); + } + + private static function nullIfEmpty(string $text): ?string + { + $text = trim($text); + + return '' === $text ? null : $text; + } +} diff --git a/backend/src/Contacts/CardGone.php b/backend/src/Contacts/CardGone.php new file mode 100644 index 0000000..19f44dd --- /dev/null +++ b/backend/src/Contacts/CardGone.php @@ -0,0 +1,14 @@ +lineIndex = $this->startLine; + $property->lineString = $line; + // The parser upper-cases "item1.EMAIL"; Apple writes the group in lower case + if (null !== $property->group && str_starts_with(strtoupper($line), strtoupper($property->group).'.')) { + $property->group = substr($line, 0, strlen($property->group)); + } + } + + return $property; + } +} diff --git a/backend/src/Contacts/CardRefused.php b/backend/src/Contacts/CardRefused.php new file mode 100644 index 0000000..075a93e --- /dev/null +++ b/backend/src/Contacts/CardRefused.php @@ -0,0 +1,12 @@ + */ private array $refreshAfterResponse = []; @@ -33,6 +40,7 @@ public function __construct( private readonly DavisConfig $config, private readonly CardDavClient $dav, private readonly ContactRepository $contacts, + private readonly CardRepository $cards, private readonly ContactSyncRepository $syncs, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, @@ -63,7 +71,7 @@ public function search(RemoteUser $user, string $query): array * * @param list $emails * - * @return array + * @return array */ public function lookup(RemoteUser $user, array $emails): array { @@ -74,6 +82,46 @@ public function lookup(RemoteUser $user, array $emails): array return $this->contacts->namesByEmail($this->warm($user), $emails); } + /** + * The user's address books, by name. + * + * @return list + */ + public function books(string $owner): array + { + $books = []; + foreach ($this->syncs->find($owner)?->getBooks() ?? [] as $href => $book) { + // A book without a display name goes by the last part of its address + $name = '' !== $book['name'] ? $book['name'] : rawurldecode(basename(rtrim($href, '/'))); + $books[] = ['id' => Card::idFor($owner, $href), 'href' => $href, 'name' => $name]; + } + usort($books, static fn (array $a, array $b) => strnatcasecmp($a['name'], $b['name'])); + + return $books; + } + + /** + * @return ?array{id: string, href: string, name: string} + */ + public function book(string $owner, string $id): ?array + { + foreach ($this->books($owner) as $book) { + if ($book['id'] === $id) { + return $book; + } + } + + return null; + } + + /** + * False when the last refresh could not reach the server. + */ + public function available(string $owner): bool + { + return $this->syncs->find($owner)?->isAvailable() ?? false; + } + /** * The picture on the card of this address, from the cache as it is. * @@ -84,8 +132,18 @@ public function photo(RemoteUser $user, string $email): ?array if (!$this->config->enabled()) { return null; } + $owner = $user->getUserIdentifier(); + $id = $this->contacts->findCardWithPhoto($owner, $email); + $card = null === $id ? null : $this->cards->findOwned($owner, $id); + if (null === $card) { + return null; + } - return $this->contacts->findPhoto($user->getUserIdentifier(), $email); + try { + return CardDocument::fromString($card->getVcard())->photo(); + } catch (InvalidCard) { + return null; + } } /** @@ -93,7 +151,7 @@ public function photo(RemoteUser $user, string $email): ?array * * @return string the owner key of the cache */ - private function warm(RemoteUser $user): string + public function warm(RemoteUser $user): string { $owner = $user->getUserIdentifier(); $checkedAt = $this->syncs->find($owner)?->getCheckedAt(); @@ -108,11 +166,14 @@ private function warm(RemoteUser $user): string } /** - * Fetches the address books whose sync token moved, drops the ones that - * are gone. Davis being away is logged, not thrown: autocomplete keeps - * what it has. + * Brings the cache in line with the server: for each book whose sync + * token moved (or that has none), the ETags of its cards are compared + * with the cached ones and only the cards that differ are fetched. The + * server being away is logged, not thrown: the cache stays as it was. + * + * @return bool whether any card or book changed */ - public function refresh(RemoteUser $user): void + public function refresh(RemoteUser $user): bool { $owner = $user->getUserIdentifier(); $sync = $this->syncs->find($owner); @@ -120,9 +181,10 @@ public function refresh(RemoteUser $user): void $sync = new ContactSync($owner); $this->entityManager->persist($sync); } - // Even when it fails, so a Davis that is down is asked again in minutes, not on every keystroke + // Even when it fails, so a server that is down is asked again in minutes, not on every keystroke $sync->checked(new DateTimeImmutable()); + $changed = false; try { $cached = $sync->getHome(); $home = $cached ?? $this->dav->home($user); @@ -139,40 +201,72 @@ public function refresh(RemoteUser $user): void $sync->setHome($home); $known = $sync->getBooks(); - foreach ($books as $book => $token) { - if ('' !== $token && ($known[$book] ?? null) === $token) { - continue; + foreach ($books as $book => $state) { + $previous = $known[$book] ?? null; + if (null === $previous || $previous['name'] !== $state['name']) { + $changed = true; + } + if ('' === $state['token'] || ($previous['token'] ?? null) !== $state['token']) { + $changed = $this->syncBook($user, $book) || $changed; } - // Fetched before anything is dropped, so a failure leaves the book as it was - $cards = $this->dav->cards($user, $book); - $this->entityManager->wrapInTransaction(function () use ($owner, $book, $cards): void { - $this->contacts->deleteBook($owner, $book); - foreach ($cards as $card) { - foreach (VCard::addresses($card) as $address) { - $photo = $address['photo']; - $this->entityManager->persist(new Contact( - $owner, - $book, - $address['name'], - $address['email'], - null === $photo ? null : base64_encode($photo['data']), - $photo['mime'] ?? null, - )); - } - } - }); - $known[$book] = $token; + // Saved after each book, so a failure on the next one does not fetch this one again + $known[$book] = $state; + $sync->setBooks($known); } foreach (array_keys(array_diff_key($known, $books)) as $gone) { $this->contacts->deleteBook($owner, $gone); + $this->cards->deleteBook($owner, $gone); unset($known[$gone]); + $changed = true; } $sync->setBooks($known); + $sync->setAvailable(true); } catch (DavisUnavailable $e) { - $this->logger->warning('Contacts: Davis unavailable', ['exception' => $e, 'user' => $owner]); + $this->logger->warning('Contacts: address book server unavailable', ['exception' => $e, 'user' => $owner]); + $sync->setAvailable(false); } $this->entityManager->flush(); + + return $changed; + } + + /** + * Puts a card in the cache as the server has it, or updates it. + */ + public function store(string $owner, string $book, string $href, string $etag, string $vcard): Card + { + $card = $this->cards->findOwned($owner, Card::idFor($owner, $href)); + if (null === $card) { + $card = new Card($owner, $book, $href, $etag, $vcard); + $this->entityManager->persist($card); + } else { + $card->replace($etag, $vcard); + $this->contacts->deleteCard($owner, $card->getId()); + } + + try { + $summary = CardDocument::fromString($vcard)->summary(); + } catch (InvalidCard) { + // Kept anyway, under what the simple reader finds in it + $addresses = VCard::addresses($vcard); + $emails = array_column($addresses, 'email'); + $summary = ['name' => $addresses[0]['name'] ?? $emails[0] ?? null, 'org' => null, 'emails' => $emails, 'phones' => [], 'hasPhoto' => false]; + } + $card->describe($summary['name'], $summary['org'], $summary['emails'], $summary['phones'], $summary['hasPhoto']); + + // Autocomplete keeps one row per address, as it always did + foreach (VCard::addresses($vcard) as $address) { + $this->entityManager->persist(new Contact($owner, $book, $address['name'], $address['email'], $card->getId(), $summary['hasPhoto'])); + } + + return $card; + } + + public function forget(Card $card): void + { + $this->contacts->deleteCard($card->getOwner(), $card->getId()); + $this->entityManager->remove($card); } public function onTerminate(): void @@ -183,4 +277,41 @@ public function onTerminate(): void $this->refresh($user); } } + + /** + * @return bool whether any card changed + */ + private function syncBook(RemoteUser $user, string $book): bool + { + $owner = $user->getUserIdentifier(); + $cached = $this->cards->byHref($owner, $book); + + // Everything is fetched before anything is dropped, so a failure leaves the book as it was + if ([] === $cached) { + $fetched = $this->dav->all($user, $book); + $gone = []; + } else { + $etags = $this->dav->etags($user, $book); + $stale = array_keys(array_filter($etags, static fn (string $etag, string $href) => $etag !== ($cached[$href] ?? null)?->getEtag(), ARRAY_FILTER_USE_BOTH)); + $fetched = []; + foreach (array_chunk($stale, self::MULTIGET_SIZE) as $hrefs) { + $fetched += $this->dav->multiget($user, $book, array_map(strval(...), $hrefs)); + } + $gone = array_diff_key($cached, $etags); + } + if ([] === $fetched && [] === $gone) { + return false; + } + + $this->entityManager->wrapInTransaction(function () use ($owner, $book, $fetched, $gone): void { + foreach ($gone as $card) { + $this->forget($card); + } + foreach ($fetched as $href => $card) { + $this->store($owner, $book, $href, $card['etag'], $card['vcard']); + } + }); + + return true; + } } diff --git a/backend/src/Contacts/DavisUnavailable.php b/backend/src/Contacts/DavisUnavailable.php index 9241e9b..ab2a783 100644 --- a/backend/src/Contacts/DavisUnavailable.php +++ b/backend/src/Contacts/DavisUnavailable.php @@ -9,6 +9,6 @@ /** * Davis refused the app key, is down or answered something unexpected. */ -final class DavisUnavailable extends RuntimeException +class DavisUnavailable extends RuntimeException { } diff --git a/backend/src/Contacts/InvalidCard.php b/backend/src/Contacts/InvalidCard.php new file mode 100644 index 0000000..a4873d1 --- /dev/null +++ b/backend/src/Contacts/InvalidCard.php @@ -0,0 +1,14 @@ + self::MAX_PIXELS) { + throw new InvalidArgumentException('Image too large'); + } + $source = @imagecreatefromstring($bytes); + if (false === $source) { + throw new InvalidArgumentException('Unreadable image'); + } + + $scale = min(1, self::SIZE / max($width, $height)); + $w = max(1, (int) round($width * $scale)); + $h = max(1, (int) round($height * $scale)); + $target = imagecreatetruecolor($w, $h); + if (false === $target) { + throw new InvalidArgumentException('Image too large'); + } + // Transparent parts of a PNG or GIF become white rather than black + imagefill($target, 0, 0, (int) imagecolorallocate($target, 255, 255, 255)); + imagecopyresampled($target, $source, 0, 0, 0, 0, $w, $h, $width, $height); + + ob_start(); + imagejpeg($target, null, 85); + + return (string) ob_get_clean(); + } +} diff --git a/backend/src/Contacts/PlainUri.php b/backend/src/Contacts/PlainUri.php new file mode 100644 index 0000000..388d757 --- /dev/null +++ b/backend/src/Contacts/PlainUri.php @@ -0,0 +1,40 @@ + + */ + #[Override] + public function parameters(): array + { + return $this->parameters; + } + + #[Override] + public function getRawMimeDirValue(): string + { + $value = $this->value; + + return (string) (is_array($value) ? ($value[0] ?? '') : $value); + } +} diff --git a/backend/src/Contacts/Request/AddressInput.php b/backend/src/Contacts/Request/AddressInput.php new file mode 100644 index 0000000..65f0017 --- /dev/null +++ b/backend/src/Contacts/Request/AddressInput.php @@ -0,0 +1,33 @@ +street.$this->city.$this->region.$this->postalCode.$this->country); + } +} diff --git a/backend/src/Contacts/Request/CardInput.php b/backend/src/Contacts/Request/CardInput.php new file mode 100644 index 0000000..9571d28 --- /dev/null +++ b/backend/src/Contacts/Request/CardInput.php @@ -0,0 +1,75 @@ + $emails + * @param list $phones + * @param list $addresses + * @param list $urls + */ + public function __construct( + #[Assert\Length(max: 200)] + public ?string $firstName = null, + #[Assert\Length(max: 200)] + public ?string $lastName = null, + #[Assert\Length(max: 200)] + public ?string $org = null, + #[Assert\Length(max: 200)] + public ?string $title = null, + #[Assert\Valid] + #[Assert\Count(max: 50)] + public array $emails = [], + #[Assert\Valid] + #[Assert\Count(max: 50)] + public array $phones = [], + #[Assert\Valid] + #[Assert\Count(max: 20)] + public array $addresses = [], + #[Assert\Valid] + #[Assert\Count(max: 20)] + public array $urls = [], + /** "1984-03-12", or "--03-12" without a year */ + #[Assert\Regex('/^(\d{4}|-)-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/')] + public ?string $birthday = null, + #[Assert\Length(max: 10000)] + public ?string $note = null, + /** The book a new card goes to */ + #[Assert\Regex('/^[a-f0-9]{32}$/')] + public ?string $book = null, + /** The version of the card the edit was made on */ + #[Assert\Regex(Etag::PATTERN)] + public ?string $etag = null, + ) { + } + + /** + * A card needs something to be called by. + * + * @psalm-suppress PossiblyUnusedMethod called by the validator + */ + #[Assert\IsTrue(message: 'A contact needs a first name, a last name, an organisation or an email address.')] + public function hasName(): bool + { + foreach ([$this->firstName, $this->lastName, $this->org] as $text) { + if ('' !== trim($text ?? '')) { + return true; + } + } + + return [] !== $this->emails; + } +} diff --git a/backend/src/Contacts/Request/EmailInput.php b/backend/src/Contacts/Request/EmailInput.php new file mode 100644 index 0000000..557ffd6 --- /dev/null +++ b/backend/src/Contacts/Request/EmailInput.php @@ -0,0 +1,22 @@ + 'image/jpeg', 'JPG' => 'image/jpeg', 'PNG' => 'image/png', 'GIF' => 'image/gif', 'WEBP' => 'image/webp']; + /** Picture types the app reads and serves, by the name vCard 3.0 gives them */ + public const array PHOTO_TYPES = ['JPEG' => 'image/jpeg', 'JPG' => 'image/jpeg', 'PNG' => 'image/png', 'GIF' => 'image/gif', 'WEBP' => 'image/webp']; /** * One entry per address: a card with a work and a home address gives two. diff --git a/backend/src/Controller/ContactCardsController.php b/backend/src/Controller/ContactCardsController.php new file mode 100644 index 0000000..eaee988 --- /dev/null +++ b/backend/src/Controller/ContactCardsController.php @@ -0,0 +1,327 @@ +owner($user); + $this->contacts->warm($user); + $books = $this->contacts->books($owner); + $bookIds = array_column($books, 'id', 'href'); + + $cards = []; + foreach ($this->cards->findAllOwned($owner) as $card) { + // A card of a book that is not listed any more is on its way out + if (isset($bookIds[$card->getBook()])) { + $cards[] = [ + 'id' => $card->getId(), + 'book' => $bookIds[$card->getBook()], + 'etag' => $card->getEtag(), + 'name' => $card->getName(), + 'org' => $card->getOrg(), + 'emails' => $card->getEmails(), + 'phones' => $card->getPhones(), + 'photo' => $card->hasPhoto(), + ]; + } + } + + return new JsonResponse([ + 'available' => $this->contacts->available($owner), + 'books' => array_map(static fn (array $b) => ['id' => $b['id'], 'name' => $b['name']], $books), + 'cards' => $cards, + ]); + } + + /** + * Asks the server now rather than in a few minutes. + */ + #[Route('/refresh', methods: ['POST'])] + public function refresh(#[CurrentUser] RemoteUser $user): JsonResponse + { + $owner = $this->owner($user); + $changed = $this->contacts->refresh($user); + + return new JsonResponse(['available' => $this->contacts->available($owner), 'changed' => $changed]); + } + + #[Route('/cards/{id}', methods: ['GET'], requirements: ['id' => self::ID])] + public function show(#[CurrentUser] RemoteUser $user, string $id): JsonResponse + { + return new JsonResponse($this->model($this->card($user, $id))); + } + + #[Route('/cards', methods: ['POST'])] + public function create(#[CurrentUser] RemoteUser $user, #[MapRequestPayload] CardInput $input): JsonResponse + { + $this->owner($user); + if (null === $input->book) { + throw new UnprocessableEntityHttpException('A new contact needs an address book'); + } + + return new JsonResponse($this->model($this->addressBook->create($user, $input->book, $input)), Response::HTTP_CREATED); + } + + #[Route('/cards/{id}', methods: ['PUT'], requirements: ['id' => self::ID])] + public function update(#[CurrentUser] RemoteUser $user, string $id, #[MapRequestPayload] CardInput $input): JsonResponse + { + $this->owner($user); + + return new JsonResponse($this->model($this->addressBook->update($user, $id, self::etag($input->etag), $input))); + } + + /** + * Answers with the card as it was, which the client can import again to undo. + */ + #[Route('/cards/{id}', methods: ['DELETE'], requirements: ['id' => self::ID])] + public function delete(#[CurrentUser] RemoteUser $user, string $id, #[MapQueryParameter] ?string $etag = null): JsonResponse + { + $this->owner($user); + + return new JsonResponse(['vcard' => $this->addressBook->delete($user, $id, self::etag($etag))]); + } + + /** + * A multipart form with the picture in "file" and the card's "etag". + */ + #[Route('/cards/{id}/photo', methods: ['PUT'], requirements: ['id' => self::ID])] + public function setPhoto(#[CurrentUser] RemoteUser $user, string $id, Request $request): JsonResponse + { + $this->owner($user); + $etag = self::etag($request->request->getString('etag')); + $bytes = self::upload($request, self::PHOTO_MAX_BYTES); + try { + $jpeg = Picture::jpeg($bytes); + } catch (InvalidArgumentException $e) { + throw new UnprocessableEntityHttpException($e->getMessage(), $e); + } + + return new JsonResponse($this->model($this->addressBook->setPhoto($user, $id, $etag, $jpeg))); + } + + /** + * This card takes in what `other` has and it lacks, then `other` is deleted. + * Both ETags are needed: the merge is made on the two cards the user looked at. + */ + #[Route('/cards/{id}/merge', methods: ['POST'], requirements: ['id' => self::ID])] + public function merge(#[CurrentUser] RemoteUser $user, string $id, Request $request): JsonResponse + { + $this->owner($user); + $payload = $request->getPayload(); + $other = $payload->getString('other'); + if (1 !== preg_match('/^'.self::ID.'$/', $other) || $other === $id) { + throw new UnprocessableEntityHttpException('Expected "other", the id of another card'); + } + + return new JsonResponse($this->model($this->addressBook->merge($user, $id, self::etag($payload->getString('etag')), $other, self::etag($payload->getString('otherEtag'))))); + } + + #[Route('/cards/{id}/photo', methods: ['DELETE'], requirements: ['id' => self::ID])] + public function removePhoto(#[CurrentUser] RemoteUser $user, string $id, #[MapQueryParameter] ?string $etag = null): JsonResponse + { + $this->owner($user); + + return new JsonResponse($this->model($this->addressBook->removePhoto($user, $id, self::etag($etag)))); + } + + /** + * A .vcf file in "file", each of its cards added as a new one. + */ + #[Route('/books/{id}/import', methods: ['POST'], requirements: ['id' => self::ID])] + public function import(#[CurrentUser] RemoteUser $user, string $id, Request $request): JsonResponse + { + $this->owner($user); + + return new JsonResponse($this->addressBook->import($user, $id, self::upload($request, AddressBook::IMPORT_MAX_BYTES))); + } + + /** + * The client asks for it with ?v=, so a changed picture has a new address. + */ + #[Route('/cards/{id}/photo', methods: ['GET'], requirements: ['id' => self::ID])] + public function photo(#[CurrentUser] RemoteUser $user, string $id): Response + { + $card = $this->card($user, $id); + try { + $photo = $card->hasPhoto() ? CardDocument::fromString($card->getVcard())->photo() : null; + } catch (InvalidCard) { + $photo = null; + } + if (null === $photo) { + throw new NotFoundHttpException('No picture on this card'); + } + + return new Response($photo['data'], Response::HTTP_OK, [ + 'Content-Type' => $photo['mime'], + 'Cache-Control' => 'private, max-age=31536000, immutable', + 'X-Content-Type-Options' => 'nosniff', + ]); + } + + #[Route('/cards/{id}/vcf', methods: ['GET'], requirements: ['id' => self::ID])] + public function cardVcf(#[CurrentUser] RemoteUser $user, string $id): Response + { + $card = $this->card($user, $id); + + return self::vcf([$card], $card->getName() ?? 'contact'); + } + + /** + * One book, as it is on the server a moment ago. + */ + #[Route('/books/{id}/vcf', methods: ['GET'], requirements: ['id' => self::ID])] + public function bookVcf(#[CurrentUser] RemoteUser $user, string $id): Response + { + $owner = $this->owner($user); + $this->contacts->refresh($user); + $book = $this->contacts->book($owner, $id) ?? throw new NotFoundHttpException('No such address book'); + + return self::vcf($this->cards->findAllOwned($owner, $book['href']), $book['name']); + } + + /** + * Every book in one file. + */ + #[Route('/vcf', methods: ['GET'])] + public function allVcf(#[CurrentUser] RemoteUser $user): Response + { + $owner = $this->owner($user); + $this->contacts->refresh($user); + $hrefs = array_column($this->contacts->books($owner), 'href'); + + return self::vcf(array_values(array_filter($this->cards->findAllOwned($owner), static fn (Card $c) => in_array($c->getBook(), $hrefs, true))), 'contacts'); + } + + /** + * The card as the address book shows it. A card the app cannot read is + * still shown, with what the cache knows of it. + * + * @return array + */ + private function model(Card $card): array + { + try { + $model = CardDocument::fromString($card->getVcard())->toArray(); + } catch (InvalidCard) { + $model = [ + 'name' => $card->getName(), 'firstName' => null, 'lastName' => null, 'org' => $card->getOrg(), 'title' => null, + 'emails' => array_map(static fn (int $key, string $email) => ['key' => $key, 'value' => $email, 'type' => 'other', 'label' => null], array_keys($card->getEmails()), $card->getEmails()), + 'phones' => [], 'addresses' => [], 'urls' => [], 'birthday' => null, 'note' => null, 'photo' => false, + ]; + } + + return ['id' => $card->getId(), 'book' => Card::idFor($card->getOwner(), $card->getBook()), 'etag' => $card->getEtag()] + $model; + } + + private static function etag(?string $etag): string + { + if (!Etag::valid($etag)) { + throw new UnprocessableEntityHttpException('Expected the "etag" the card was read with'); + } + + return (string) $etag; + } + + private static function upload(Request $request, int $maxBytes): string + { + $file = $request->files->get('file'); + if (!$file instanceof UploadedFile || !$file->isValid()) { + throw new UnprocessableEntityHttpException('Expected a "file" field'); + } + if ($file->getSize() > $maxBytes) { + throw new HttpException(Response::HTTP_REQUEST_ENTITY_TOO_LARGE, 'file_too_large'); + } + + return $file->getContent(); + } + + private function card(RemoteUser $user, string $id): Card + { + return $this->cards->findOwned($this->owner($user), $id) ?? throw new NotFoundHttpException('contact_not_found'); + } + + private function owner(RemoteUser $user): string + { + if (!$this->config->enabled()) { + throw new NotFoundHttpException('contacts_disabled'); + } + + return $user->getUserIdentifier(); + } + + /** + * @param list $cards + */ + private static function vcf(array $cards, string $name): Response + { + $body = implode('', array_map(static fn (Card $c) => rtrim($c->getVcard(), "\r\n")."\r\n", $cards)); + + // No path, quote or control character from a contact's name reaches the header + $filename = trim(preg_replace('/[\x00-\x1f\x7f\/\\\\"%]+/u', ' ', $name) ?? '') ?: 'contact'; + // Old browsers get the name without accents + $plain = preg_replace('/\p{Mn}+/u', '', (string) Normalizer::normalize($filename, Normalizer::FORM_D)) ?? ''; + $fallback = trim(preg_replace('/[^A-Za-z0-9 ._-]+/', '_', $plain) ?? '') ?: 'contact'; + + return new Response($body, Response::HTTP_OK, [ + 'Content-Type' => 'text/vcard; charset=utf-8', + 'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename.'.vcf', $fallback.'.vcf'), + 'X-Content-Type-Options' => 'nosniff', + 'Cache-Control' => 'private, no-store', + ]); + } +} diff --git a/backend/src/Controller/ContactsController.php b/backend/src/Controller/ContactsController.php index a86e04e..f9d2491 100644 --- a/backend/src/Controller/ContactsController.php +++ b/backend/src/Controller/ContactsController.php @@ -34,8 +34,8 @@ public function search(#[CurrentUser] RemoteUser $user, #[MapQueryParameter] str } /** - * Which of these addresses are in the user's address books, under what name, and with a picture or not: - * `{"bob@example.test": {"name": "Bob Martin", "photo": true}, "anon@example.test": {"name": null, "photo": false}}`. + * Which of these addresses are in the user's address books, under what name, with a picture or not, and on which card: + * `{"bob@example.test": {"name": "Bob Martin", "photo": true, "id": "
"}, "anon@example.test": {"name": null, "photo": false, "id": "
"}}`. * * @param array $emails `?emails[]=a@x&emails[]=b@y` */ diff --git a/backend/src/Controller/MeController.php b/backend/src/Controller/MeController.php index dbbc694..40faffd 100644 --- a/backend/src/Controller/MeController.php +++ b/backend/src/Controller/MeController.php @@ -4,6 +4,7 @@ namespace App\Controller; +use App\Contacts\DavisConfig; use App\Security\RemoteUser; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\JsonResponse; @@ -15,6 +16,7 @@ final class MeController public function __construct( #[Autowire(env: 'LOGOUT_URL')] private readonly string $logoutUrl, + private readonly DavisConfig $davis, ) { } @@ -26,6 +28,8 @@ public function __invoke(#[CurrentUser] RemoteUser $user): JsonResponse 'email' => $user->email, 'displayName' => $user->displayName, 'logoutUrl' => '' === $this->logoutUrl ? null : $this->logoutUrl, + // The address book shows only when there is one to show + 'contacts' => $this->davis->enabled(), ]); } } diff --git a/backend/src/Entity/Card.php b/backend/src/Entity/Card.php new file mode 100644 index 0000000..d317fdf --- /dev/null +++ b/backend/src/Entity/Card.php @@ -0,0 +1,150 @@ + */ + #[ORM\Column(type: Types::JSON)] + private array $emails = []; + + /** @var list */ + #[ORM\Column(type: Types::JSON)] + private array $phones = []; + + /** The name folded, so the list sorts "Émile" with the E */ + #[ORM\Column(length: 255)] + private string $sort = ''; + + #[ORM\Column] + private bool $hasPhoto = false; + + public function __construct( + #[ORM\Column(length: 255)] + private string $owner, + #[ORM\Column(length: 500)] + private string $book, + #[ORM\Column(length: 500)] + private string $href, + #[ORM\Column(length: 255)] + private string $etag, + #[ORM\Column(type: Types::TEXT)] + private string $vcard, + ) { + $this->id = self::idFor($owner, $href); + } + + /** + * Ids are derived from the href, so they stay the same across refreshes + * and say nothing about the server. + */ + public static function idFor(string $owner, string $href): string + { + return substr(hash('sha256', $owner."\n".$href), 0, 32); + } + + /** + * @param list $emails + * @param list $phones + */ + public function describe(?string $name, ?string $org, array $emails, array $phones, bool $hasPhoto): void + { + $this->name = null === $name ? null : mb_substr($name, 0, 255); + $this->org = null === $org ? null : mb_substr($org, 0, 255); + $this->emails = $emails; + $this->phones = $phones; + $this->sort = mb_substr(Contact::fold($name ?? ''), 0, 255); + $this->hasPhoto = $hasPhoto; + } + + public function replace(string $etag, string $vcard): void + { + $this->etag = $etag; + $this->vcard = $vcard; + } + + public function getId(): string + { + return $this->id; + } + + public function getOwner(): string + { + return $this->owner; + } + + public function getBook(): string + { + return $this->book; + } + + public function getHref(): string + { + return $this->href; + } + + public function getEtag(): string + { + return $this->etag; + } + + public function getVcard(): string + { + return $this->vcard; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getOrg(): ?string + { + return $this->org; + } + + /** + * @return list + */ + public function getEmails(): array + { + return $this->emails; + } + + /** + * @return list + */ + public function getPhones(): array + { + return $this->phones; + } + + public function hasPhoto(): bool + { + return $this->hasPhoto; + } +} diff --git a/backend/src/Entity/Contact.php b/backend/src/Entity/Contact.php index 1d3032d..c2244c9 100644 --- a/backend/src/Entity/Contact.php +++ b/backend/src/Entity/Contact.php @@ -5,16 +5,16 @@ namespace App\Entity; use App\Repository\ContactRepository; -use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Normalizer; /** - * One address of a Davis contact, cached for autocomplete. + * One address of a contact card, cached for autocomplete and for names on mail. */ #[ORM\Entity(repositoryClass: ContactRepository::class)] #[ORM\Table(name: 'contact')] #[ORM\Index(name: 'contact_owner', columns: ['owner'])] +#[ORM\Index(name: 'contact_card_id', columns: ['card_id'])] class Contact { #[ORM\Id] @@ -36,11 +36,11 @@ public function __construct( private ?string $name, #[ORM\Column(length: 320)] private string $email, - /** The card's picture, base64 so it travels through any database as text */ - #[ORM\Column(type: Types::TEXT, nullable: true)] - private ?string $photo = null, - #[ORM\Column(length: 64, nullable: true)] - private ?string $photoMime = null, + /** The card it comes from, to drop its rows when the card changes */ + #[ORM\Column(length: 32)] + private string $cardId, + #[ORM\Column] + private bool $hasPhoto = false, ) { $this->search = self::fold(($name ?? '').' '.$email); } @@ -61,20 +61,4 @@ public function getEmail(): string { return $this->email; } - - public function getPhotoMime(): ?string - { - return $this->photoMime; - } - - /** The picture's bytes, or null without one */ - public function getPhoto(): ?string - { - if (null === $this->photo) { - return null; - } - $data = base64_decode($this->photo, true); - - return false === $data ? null : $data; - } } diff --git a/backend/src/Entity/ContactSync.php b/backend/src/Entity/ContactSync.php index 0a2240c..7870794 100644 --- a/backend/src/Entity/ContactSync.php +++ b/backend/src/Entity/ContactSync.php @@ -19,10 +19,14 @@ class ContactSync #[ORM\Column(length: 500, nullable: true)] private ?string $home = null; - /** @var array sync token by address book href */ + /** @var array sync token and name by address book href */ #[ORM\Column(type: Types::JSON)] private array $books = []; + /** False when the last refresh could not reach the server */ + #[ORM\Column(options: ['default' => true])] + private bool $available = true; + #[ORM\Column(nullable: true)] private ?DateTimeImmutable $checkedAt = null; @@ -44,7 +48,7 @@ public function setHome(?string $home): void } /** - * @return array + * @return array */ public function getBooks(): array { @@ -52,13 +56,23 @@ public function getBooks(): array } /** - * @param array $books + * @param array $books */ public function setBooks(array $books): void { $this->books = $books; } + public function isAvailable(): bool + { + return $this->available; + } + + public function setAvailable(bool $available): void + { + $this->available = $available; + } + public function getCheckedAt(): ?DateTimeImmutable { return $this->checkedAt; diff --git a/backend/src/Http/ApiExceptionSubscriber.php b/backend/src/Http/ApiExceptionSubscriber.php index fabd83f..ce44866 100644 --- a/backend/src/Http/ApiExceptionSubscriber.php +++ b/backend/src/Http/ApiExceptionSubscriber.php @@ -4,6 +4,10 @@ namespace App\Http; +use App\Contacts\CardChanged; +use App\Contacts\CardGone; +use App\Contacts\DavisUnavailable; +use App\Contacts\InvalidCard; use App\Imap\Exception\ImapCommandFailed; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -17,7 +21,7 @@ use Webklex\PHPIMAP\Exceptions\ConnectionFailedException; /** - * Every error under /api is JSON, IMAP failures included. + * Every error under /api is JSON, IMAP and address book failures included. */ final class ApiExceptionSubscriber implements EventSubscriberInterface { @@ -47,6 +51,10 @@ public function onException(ExceptionEvent $event): void $exception instanceof AuthFailedException => [Response::HTTP_BAD_GATEWAY, 'imap_auth_failed'], $exception instanceof ImapCommandFailed => [Response::HTTP_BAD_GATEWAY, 'imap_command_failed'], $exception instanceof ConnectionFailedException => [Response::HTTP_BAD_GATEWAY, 'imap_unreachable'], + $exception instanceof CardChanged => [Response::HTTP_CONFLICT, 'contact_changed'], + $exception instanceof CardGone => [Response::HTTP_NOT_FOUND, 'contact_not_found'], + $exception instanceof DavisUnavailable => [Response::HTTP_BAD_GATEWAY, 'contacts_unavailable'], + $exception instanceof InvalidCard => [Response::HTTP_UNPROCESSABLE_ENTITY, 'contact_unreadable'], default => [Response::HTTP_INTERNAL_SERVER_ERROR, 'internal_error'], }; diff --git a/backend/src/Repository/CardRepository.php b/backend/src/Repository/CardRepository.php new file mode 100644 index 0000000..b0a07d2 --- /dev/null +++ b/backend/src/Repository/CardRepository.php @@ -0,0 +1,91 @@ + + */ +final class CardRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Card::class); + } + + public function findOwned(string $owner, string $id): ?Card + { + return $this->findOneBy(['owner' => $owner, 'id' => $id]); + } + + /** + * @return list sorted by name + */ + public function findAllOwned(string $owner, ?string $book = null): array + { + $criteria = ['owner' => $owner]; + if (null !== $book) { + $criteria['book'] = $book; + } + + return $this->findBy($criteria, ['sort' => 'ASC', 'id' => 'ASC']); + } + + /** + * @return array by href + */ + public function byHref(string $owner, string $book): array + { + $cards = []; + foreach ($this->findBy(['owner' => $owner, 'book' => $book]) as $card) { + $cards[$card->getHref()] = $card; + } + + return $cards; + } + + /** + * The UIDs of a book's cards, read from the cached text. + * + * @return array + */ + public function uids(string $owner, string $book): array + { + /** @var list $cards */ + $cards = $this->createQueryBuilder('c') + ->select('c.vcard') + ->andWhere('c.owner = :owner AND c.book = :book') + ->setParameter('owner', $owner) + ->setParameter('book', $book) + ->getQuery() + ->getSingleColumnResult(); + + $uids = []; + foreach ($cards as $vcard) { + // "UID:x" or "UID;VALUE=text:x", on an unfolded line + if (1 === preg_match('/^UID(?:;[^:\r\n]*)?:([^\r\n]*)\r?$/mi', preg_replace("/\r?\n[ \t]/", '', $vcard) ?? $vcard, $m)) { + $uids[trim($m[1])] = true; + } + } + + return $uids; + } + + public function deleteBook(string $owner, string $book): void + { + $this->createQueryBuilder('c') + ->delete() + ->where('c.owner = :owner AND c.book = :book') + ->setParameter('owner', $owner) + ->setParameter('book', $book) + ->getQuery() + ->execute(); + } +} diff --git a/backend/src/Repository/ContactRepository.php b/backend/src/Repository/ContactRepository.php index d874258..aad8f4e 100644 --- a/backend/src/Repository/ContactRepository.php +++ b/backend/src/Repository/ContactRepository.php @@ -65,11 +65,11 @@ public function search(string $owner, string $query, int $limit): array /** * For each of these addresses that is in a book: the name on file (null for a - * card without one) and whether the card has a picture. + * card without one), whether the card has a picture, and the card's id. * * @param list $emails * - * @return array + * @return array */ public function namesByEmail(string $owner, array $emails): array { @@ -77,23 +77,27 @@ public function namesByEmail(string $owner, array $emails): array return []; } - /** @var list $rows */ + /** @var list $rows */ $rows = $this->createQueryBuilder('c') - ->select('c.email', 'c.name', 'c.photoMime') + ->select('c.email', 'c.name', 'c.hasPhoto', 'c.cardId') ->andWhere('c.owner = :owner') ->andWhere('c.email IN (:emails)') ->setParameter('owner', $owner) ->setParameter('emails', array_map(strtolower(...), $emails)) ->addOrderBy('c.name') + ->addOrderBy('c.cardId') ->getQuery() ->getArrayResult(); $found = []; foreach ($rows as $row) { - // The same address in two books: the first name wins, a picture from any book counts - $entry = $found[$row['email']] ?? ['name' => null, 'photo' => false]; - $entry['name'] ??= $row['name']; - $entry['photo'] = $entry['photo'] || null !== $row['photoMime']; + // The same address in two books: the first card with a name wins, a picture from any book counts + $entry = $found[$row['email']] ?? ['name' => null, 'photo' => false, 'id' => $row['cardId']]; + if (null === $entry['name'] && null !== $row['name']) { + $entry['name'] = $row['name']; + $entry['id'] = $row['cardId']; + } + $entry['photo'] = $entry['photo'] || $row['hasPhoto']; $found[$row['email']] = $entry; } @@ -101,29 +105,34 @@ public function namesByEmail(string $owner, array $emails): array } /** - * The picture on this address's card, from whichever book has one. - * - * @return ?array{mime: string, data: string} + * The card of this address that has a picture, from whichever book. */ - public function findPhoto(string $owner, string $email): ?array + public function findCardWithPhoto(string $owner, string $email): ?string { - $contacts = $this->createQueryBuilder('c') + /** @var list $ids */ + $ids = $this->createQueryBuilder('c') + ->select('c.cardId') ->andWhere('c.owner = :owner') ->andWhere('c.email = :email') - ->andWhere('c.photoMime IS NOT NULL') + ->andWhere('c.hasPhoto = true') ->setParameter('owner', $owner) ->setParameter('email', strtolower($email)) ->setMaxResults(1) ->getQuery() - ->getResult(); - $contact = $contacts[0] ?? null; - if (!$contact instanceof Contact) { - return null; - } - $data = $contact->getPhoto(); - $mime = $contact->getPhotoMime(); + ->getSingleColumnResult(); - return null === $data || null === $mime ? null : ['mime' => $mime, 'data' => $data]; + return $ids[0] ?? null; + } + + public function deleteCard(string $owner, string $cardId): void + { + $this->createQueryBuilder('c') + ->delete() + ->where('c.owner = :owner AND c.cardId = :card') + ->setParameter('owner', $owner) + ->setParameter('card', $cardId) + ->getQuery() + ->execute(); } /** diff --git a/backend/stubs/sabre-vobject.stub b/backend/stubs/sabre-vobject.stub new file mode 100644 index 0000000..e25f1a0 --- /dev/null +++ b/backend/stubs/sabre-vobject.stub @@ -0,0 +1,13 @@ + + * @implements \ArrayAccess + */ +abstract class Node implements \IteratorAggregate, \ArrayAccess, \Countable, \JsonSerializable +{ +} diff --git a/backend/tests/Contacts/CardDocumentTest.php b/backend/tests/Contacts/CardDocumentTest.php new file mode 100644 index 0000000..97d718a --- /dev/null +++ b/backend/tests/Contacts/CardDocumentTest.php @@ -0,0 +1,531 @@ + + */ + public static function fixtures(): iterable + { + foreach (glob(self::FIXTURES.'*.vcf') ?: [] as $file) { + yield basename($file) => [basename($file)]; + } + } + + #[DataProvider('fixtures')] + public function testSavingWithoutChangesKeepsEveryLine(string $fixture): void + { + $card = self::read($fixture); + $before = self::lines((string) file_get_contents(self::FIXTURES.$fixture)); + + $card->apply(self::input($card->toArray())); + + self::assertSame(self::without($before, 'REV'), self::without(self::lines($card->toString()), 'REV')); + } + + #[DataProvider('fixtures')] + public function testEditingTheTitleLeavesTheRestAlone(string $fixture): void + { + $card = self::read($fixture); + $before = self::lines((string) file_get_contents(self::FIXTURES.$fixture)); + + $card->apply(self::input($card->toArray(), ['title' => 'Captain'])); + + $after = self::lines($card->toString()); + self::assertContains('TITLE:Captain', $after); + self::assertSame(self::without($before, 'REV', 'TITLE'), self::without($after, 'REV', 'TITLE')); + } + + public function testUnknownPropertiesComeOutAsTheyWentIn(): void + { + $card = self::read('unknown-properties.vcf'); + + $card->apply(self::input($card->toArray(), ['note' => 'New note'])); + + $out = $card->toString(); + foreach ([ + 'X-FOO;X-BAR="a,b";X-BAZ=c:weird\,value;with parts', + 'X-EMPTY:', + 'NICKNAME:Oddy,Od', + 'CATEGORIES:friends,work', + 'GEO:48.85;2.35', + 'PHOTO;VALUE=URI:https://example.test/pic.jpg', + ] as $line) { + self::assertStringContainsString($line."\r\n", self::unfold($out)); + } + } + + public function testAppleGroupsAndLabelsSurviveAnEdit(): void + { + $card = self::read('apple-grouped.vcf'); + $model = $card->toArray(); + self::assertSame('Other', $model['emails'][0]['label']); + self::assertSame('Boat', $model['phones'][0]['label']); + + $card->apply(self::input($model, ['emails' => [['key' => 0, 'value' => 'grace@new.example', 'type' => 'other']]])); + + $out = self::unfold($card->toString()); + // Updated in place: same group, same parameters, its label still next to it + self::assertStringContainsString("item1.EMAIL;TYPE=INTERNET,pref:grace@new.example\r\n", $out); + // Lines nobody edited come out exactly as they went in + self::assertStringContainsString('item1.X-ABLabel:_$!!$_'."\r\n", $out); + self::assertStringContainsString("item3.ADR;type=HOME;type=pref:;;1 quai du Port;Marseille;;13002;France\r\nitem3.X-ABADR:fr\r\n", $out); + self::assertStringContainsString("X-SOCIALPROFILE;type=mastodon:https://social.example/@grace\r\n", $out); + self::assertStringContainsString("IMPP;X-SERVICE-TYPE=Jabber;type=HOME:xmpp:grace@chat.example\r\n", $out); + } + + public function testARowWithAKeyIsUpdatedWithoutOneAddedAndAMissingKeyRemoved(): void + { + $card = self::read('apple-grouped.vcf'); + $model = $card->toArray(); + + $card->apply(self::input($model, [ + // The boat phone (key 0) is dropped, a new one comes in + 'phones' => [['value' => '+33 7 00 00 00 09', 'type' => 'cell']], + 'urls' => [['key' => 0, 'value' => 'https://grace.example/new'], ['value' => 'https://other.example/a,b']], + ])); + + $out = self::unfold($card->toString()); + self::assertStringNotContainsString('+33 6 00 00 00 04', $out); + // The removed phone takes its label with it, the email's label stays + self::assertStringNotContainsString('Boat', $out); + self::assertStringContainsString('item1.X-ABLabel', $out); + self::assertStringContainsString("TEL;TYPE=CELL:+33 7 00 00 00 09\r\n", $out); + self::assertStringContainsString("item4.URL;TYPE=pref:https://grace.example/new\r\n", $out); + self::assertStringContainsString('item4.X-ABLabel:_$!!$_'."\r\n", $out); + self::assertStringContainsString("URL:https://other.example/a,b\r\n", $out); + } + + public function testAnAddressKeepsItsPostOfficeBox(): void + { + $card = self::read('plain-3.0.vcf'); + $model = $card->toArray(); + self::assertSame(['key' => 0, 'type' => 'home', 'street' => '12 rue des Lilas', 'city' => 'Lyon', 'region' => '', 'postalCode' => '69001', 'country' => 'France'], $model['addresses'][0]); + + $address = ['key' => 0, 'type' => 'home', 'street' => '14 rue des Lilas', 'city' => 'Lyon', 'region' => '', 'postalCode' => '69001', 'country' => 'France']; + $card->apply(self::input($model, ['addresses' => [$address, ['type' => 'work', 'street' => '1 place Bellecour', 'city' => 'Lyon']]])); + + $out = self::unfold($card->toString()); + self::assertStringContainsString("ADR;TYPE=HOME:BP 12;BĂąt. B;14 rue des Lilas;Lyon;;69001;France\r\n", $out); + self::assertStringContainsString("ADR;TYPE=WORK:;;1 place Bellecour;Lyon;;;\r\n", $out); + } + + public function testTheNameIsRecomputedOnlyWhenNamesOrOrganisationChange(): void + { + $card = self::read('plain-3.0.vcf'); + // FN is not "first last" on purpose: an unrelated edit must not rewrite it + $card->apply(self::input($card->toArray(), ['note' => 'Hello'])); + self::assertStringContainsString("FN:Émile Durand\r\n", $card->toString()); + + $card->apply(self::input($card->toArray(), ['firstName' => 'Émilie'])); + + $out = $card->toString(); + self::assertStringContainsString("FN:Émilie Durand\r\n", $out); + // Additional names, prefix and suffix are kept + self::assertStringContainsString("N:Durand;Émilie;Jean;Dr;Jr\r\n", $out); + // The department after the organisation is kept too + self::assertStringContainsString("ORG:Example;Research\r\n", $out); + } + + public function testWithoutNamesTheOrganisationThenTheEmailNameTheCard(): void + { + $card = self::read('plain-3.0.vcf'); + $card->apply(self::input($card->toArray(), ['firstName' => null, 'lastName' => null])); + self::assertSame('Example', $card->toArray()['name']); + + $card->apply(self::input($card->toArray(), ['org' => null])); + self::assertSame('emile@example.test', $card->toArray()['name']); + self::assertStringContainsString("ORG:;Research\r\n", $card->toString()); + } + + public function testTypesReadAsFourWordsAndOnlyTheTypeIsRewritten(): void + { + $card = self::read('plain-3.0.vcf'); + $model = $card->toArray(); + self::assertSame(['work', 'home'], array_column($model['emails'], 'type')); + self::assertSame(['cell', 'work'], array_column($model['phones'], 'type')); + self::assertSame('cell', self::read('version-4.0.vcf')->toArray()['phones'][0]['type']); + + $emails = $model['emails']; + $emails[1]['type'] = 'work'; + $phones = $model['phones']; + $phones[0]['type'] = 'other'; + $card->apply(self::input($model, ['emails' => $emails, 'phones' => $phones])); + + $out = self::unfold($card->toString()); + // INTERNET, PREF and VOICE are not the app's business and stay + self::assertStringContainsString("EMAIL;TYPE=INTERNET,PREF,WORK:emile@home.example\r\n", $out); + self::assertStringContainsString("TEL;TYPE=VOICE:+33 6 00 00 00 01\r\n", $out); + self::assertStringContainsString("EMAIL;TYPE=INTERNET;TYPE=WORK:emile@example.test\r\n", $out); + } + + public function testRevisionIsSetAndUidKept(): void + { + $card = self::read('plain-3.0.vcf'); + $card->apply(self::input($card->toArray(), ['title' => 'Boss'])); + + $out = $card->toString(); + self::assertStringContainsString("UID:plain-30\r\n", $out); + self::assertStringNotContainsString('REV:2020', $out); + self::assertMatchesRegularExpression('/^REV:'.gmdate('Ymd').'T\d{6}Z\r$/m', $out); + } + + public function testABlankCardIsVersion3WithAUid(): void + { + $card = CardDocument::blank('new-uid'); + $card->apply(self::input(['emails' => [], 'phones' => [], 'addresses' => [], 'urls' => []], ['emails' => [['value' => 'solo@example.test', 'type' => 'home']]])); + + $lines = self::lines($card->toString()); + self::assertSame('VERSION:3.0', $lines[1]); + self::assertContains('UID:new-uid', $lines); + self::assertContains('PRODID:-//Postel//Postel//EN', $lines); + self::assertContains('EMAIL;TYPE=HOME:solo@example.test', $lines); + // Only an address to go by: it names the card + self::assertContains('FN:solo@example.test', $lines); + self::assertSame('new-uid', $card->uid()); + } + + public function testThePictureIsWrittenTheWayEachVersionWantsIt(): void + { + $jpeg = "\xFF\xD8\xFF\xE0fake"; + + $v3 = self::read('photo-3.0.vcf'); + $v3->setPhoto($jpeg); + self::assertStringContainsString('PHOTO;ENCODING=b;TYPE=JPEG:'.base64_encode($jpeg)."\r\n", $v3->toString()); + self::assertSame(['mime' => 'image/jpeg', 'data' => $jpeg], $v3->photo()); + + $v4 = self::read('photo-4.0.vcf'); + $v4->setPhoto($jpeg); + self::assertStringContainsString('PHOTO:data:image/jpeg;base64,'.base64_encode($jpeg)."\r\n", $v4->toString()); + self::assertSame(['mime' => 'image/jpeg', 'data' => $jpeg], $v4->photo()); + + $v4->removePhoto(); + self::assertNull($v4->photo()); + self::assertStringNotContainsString('PHOTO', $v4->toString()); + } + + public function testThePictureIsReadInEveryEncoding(): void + { + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAC0lEQVQImWNgQAYAAA4AAbGa6gYAAAAASUVORK5CYII=', true); + foreach (['photo-3.0.vcf', 'photo-4.0.vcf', 'photo-2.1.vcf'] as $fixture) { + $card = self::read($fixture); + self::assertSame(['mime' => 'image/png', 'data' => $png], $card->photo(), $fixture); + self::assertTrue($card->toArray()['photo']); + } + // A link is not a picture the app can serve + self::assertNull(self::read('unknown-properties.vcf')->photo()); + } + + public function testA4PictureAndALinkWithACommaAreNotMangled(): void + { + $card = self::read('version-4.0.vcf'); + $card->apply(self::input($card->toArray(), ['title' => 'Chef'])); + self::assertStringContainsString("URL:https://example.test/a,b\r\n", $card->toString()); + + $photo = self::read('photo-4.0.vcf'); + $before = self::unfold($photo->toString()); + $photo->apply(self::input($photo->toArray(), ['title' => 'Chef'])); + self::assertStringContainsString('PHOTO:data:image/png;base64,iVBOR', $before); + self::assertStringContainsString('PHOTO:data:image/png;base64,iVBOR', self::unfold($photo->toString())); + } + + public function testABirthdayWithoutAYearIsReadAsSuchAndLeftAlone(): void + { + $apple = self::read('apple-grouped.vcf'); + self::assertSame('--11-02', $apple->toArray()['birthday']); + $apple->apply(self::input($apple->toArray(), ['title' => 'Skipper'])); + self::assertStringContainsString("BDAY;X-APPLE-OMIT-YEAR=1604:1604-11-02\r\n", $apple->toString()); + + $v4 = self::read('version-4.0.vcf'); + self::assertSame('--07-04', $v4->toArray()['birthday']); + $v4->apply(self::input($v4->toArray(), ['birthday' => '1990-07-04'])); + self::assertStringContainsString("BDAY:19900704\r\n", $v4->toString()); + + $apple->apply(self::input($apple->toArray(), ['birthday' => '1990-11-02'])); + self::assertStringContainsString("BDAY:1990-11-02\r\n", $apple->toString()); + self::assertSame('1984-03-12', self::read('plain-3.0.vcf')->toArray()['birthday']); + } + + public function testAFoldedEscapedNoteReadsAsText(): void + { + $card = self::read('folded-note.vcf'); + + self::assertSame("First line\nsecond line, with a comma; and a semicolon. This note is long enough to be folded over two lines.", $card->toArray()['note']); + } + + public function testTheModelOfACard(): void + { + self::assertSame([ + 'name' => 'NoĂ©mie Roux', + 'firstName' => 'NoĂ©mie', + 'lastName' => 'Roux', + 'org' => null, + 'title' => null, + 'emails' => [['key' => 0, 'value' => 'noemie@work.example', 'type' => 'work', 'label' => null]], + 'phones' => [['key' => 0, 'value' => '+33-6-00-00-00-03', 'type' => 'cell', 'label' => null]], + 'addresses' => [], + 'urls' => [['key' => 0, 'value' => 'https://example.test/a,b']], + 'birthday' => '--07-04', + 'note' => null, + 'photo' => false, + ], self::read('version-4.0.vcf')->toArray()); + + self::assertSame( + ['name' => 'Plumber', 'org' => 'Fix-It', 'emails' => [], 'phones' => ['+33 4 00 00 00 02'], 'hasPhoto' => false], + self::read('no-email.vcf')->summary(), + ); + } + + public function testAFileIsCutIntoCards(): void + { + $file = "\xEF\xBB\xBF".implode('', array_map(static fn (string $f) => (string) file_get_contents(self::FIXTURES.$f), ['plain-3.0.vcf', 'no-email.vcf'])) + ."BEGIN:VCARD\nVERSION:3.0\nFN:Broken\nEND:VCARD\nnoise between cards\nBEGIN:VCARD\nFN:Cut short\n"; + + $cards = CardDocument::splitAll($file); + + self::assertCount(3, $cards); + self::assertSame('no-email', CardDocument::fromString($cards[1])->uid()); + self::assertSame("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Broken\r\nEND:VCARD\r\n", $cards[2]); + } + + public function testAnImportedCardCanGetANewUid(): void + { + $card = self::read('plain-3.0.vcf'); + + $card->replaceUid('fresh'); + + self::assertSame('fresh', $card->uid()); + self::assertSame(1, substr_count($card->toString(), 'UID:')); + } + + public function testSomethingElseIsNotACard(): void + { + $this->expectException(InvalidCard::class); + + CardDocument::fromString("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"); + } + + public function testTheNameWrittenByHandStaysWhenOnlyTheOrganisationChanges(): void + { + $card = CardDocument::fromString("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:x\r\nFN:Dr Bob Martin Jr\r\nN:Martin;Bob;;;\r\nORG:Old\r\nEND:VCARD\r\n"); + + $card->apply(self::input($card->toArray(), ['org' => 'New'])); + self::assertSame('Dr Bob Martin Jr', $card->toArray()['name']); + + $card->apply(self::input($card->toArray(), ['firstName' => 'Robert'])); + self::assertSame('Robert Martin', $card->toArray()['name']); + } + + public function testACardNamedAfterItsOrganisationFollowsIt(): void + { + $card = CardDocument::fromString("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:x\r\nFN:Old\r\nN:;;;;\r\nORG:Old\r\nEND:VCARD\r\n"); + + $card->apply(self::input($card->toArray(), ['org' => 'New'])); + + self::assertSame('New', $card->toArray()['name']); + } + + public function testMergingAddsWhatIsMissingAndReplacesNothing(): void + { + $card = self::read('plain-3.0.vcf'); + $before = self::lines($card->toString()); + $other = CardDocument::fromString(implode("\r\n", [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:other', 'FN:E. Durand', 'N:Durand;E.;;;', 'ORG:Elsewhere', 'NICKNAME:Mimile', + 'EMAIL;TYPE=HOME:EMILE@example.test', 'EMAIL;TYPE=WORK:emile@other.example', + 'TEL;TYPE=CELL:+33600000001', 'TEL;TYPE=HOME:+33 4 00 00 00 09', + 'ADR;TYPE=WORK:;;12 Rue des Lilas;lyon;;69001;France', 'ADR;TYPE=WORK:;;1 place Bellecour;Lyon;;69002;France', + 'URL:https://example.test/emile', 'URL:https://other.example', + 'BDAY:1990-01-01', 'NOTE:Met in Lyon', 'X-CUSTOM;X-PARAM=a:one;two', 'REV:2021-01-01T00:00:00Z', 'END:VCARD', '', + ])); + + $card->absorb($other); + + $after = self::lines($card->toString()); + $kept = self::without($before, 'REV', 'END'); + self::assertSame($kept, array_slice($after, 0, count($kept)), 'every line of the card is still there, in place'); + self::assertSame([ + 'NICKNAME:Mimile', + 'EMAIL;TYPE=WORK:emile@other.example', + 'TEL;TYPE=HOME:+33 4 00 00 00 09', + 'ADR;TYPE=WORK:;;1 place Bellecour;Lyon;;69002;France', + 'URL:https://other.example', + 'X-CUSTOM;X-PARAM=a:one;two', + 'NOTE:Met in Lyon', + ], array_values(array_diff(self::without($after, 'REV', 'END'), $before))); + self::assertSame('plain-30', $card->uid()); + self::assertSame('Émile Durand', $card->toArray()['name']); + self::assertSame('1984-03-12', $card->toArray()['birthday']); + } + + public function testMergingFillsAnEmptyNameAndJoinsTwoNotes(): void + { + $card = CardDocument::fromString("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:x\r\nFN:\r\nN:;;;;\r\nEMAIL:bob@example.test\r\nNOTE:First note\r\nEND:VCARD\r\n"); + $other = CardDocument::fromString("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:y\r\nFN:Bob Martin\r\nN:Martin;Bob;;;\r\nEMAIL:bob@example.test\r\nNOTE:Second note\r\nEND:VCARD\r\n"); + + $card->absorb($other); + $card->absorb($other); + + $model = $card->toArray(); + self::assertSame(['Bob Martin', 'Bob', 'Martin'], [$model['name'], $model['firstName'], $model['lastName']]); + self::assertSame("First note\n\nSecond note", $model['note'], 'merged twice, the note is there once'); + self::assertCount(1, $model['emails']); + self::assertSame(1, substr_count($card->toString(), "\r\nN:")); + self::assertSame(1, substr_count($card->toString(), "\r\nFN:")); + } + + public function testMergingBringsAppleGroupsAlongUnderFreeNames(): void + { + $card = self::read('apple-grouped.vcf'); + $before = self::lines($card->toString()); + $other = CardDocument::fromString(implode("\r\n", [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:other', 'FN:Grace M.', 'N:M.;Grace;;;', + 'item1.EMAIL;type=INTERNET:grace@boat.example', 'item1.X-ABLabel:Boat', + 'item2.EMAIL;type=INTERNET:GRACE@example.test', 'item2.X-ABLabel:Old', + 'item3.X-ABDATE:2020-06-01', 'item3.X-ABLabel:_$!!$_', + 'IMPP;X-SERVICE-TYPE=Jabber;type=HOME:xmpp:grace@chat.example', + 'X-SOCIALPROFILE;type=twitter:https://twitter.example/grace', + 'END:VCARD', '', + ])); + + $card->absorb($other); + + $after = self::lines($card->toString()); + self::assertSame([ + 'item5.EMAIL;type=INTERNET:grace@boat.example', + 'item5.X-ABLabel:Boat', + 'item6.X-ABDATE:2020-06-01', + 'item6.X-ABLabel:_$!!$_', + 'X-SOCIALPROFILE;type=twitter:https://twitter.example/grace', + ], array_values(array_diff(self::without($after, 'REV', 'END'), $before))); + $emails = $card->toArray()['emails']; + self::assertSame(['Other', 'Boat'], array_column($emails, 'label')); + } + + public function testMergingACardOfAnotherVersionConvertsIt(): void + { + $card = self::read('plain-3.0.vcf'); + + $card->absorb(self::read('version-4.0.vcf')); + + $model = $card->toArray(); + self::assertContains('noemie@work.example', array_column($model['emails'], 'value')); + self::assertContains('https://example.test/a,b', array_column($model['urls'], 'value')); + self::assertStringContainsString("VERSION:3.0\r\n", $card->toString()); + self::assertSame('1984-03-12', $model['birthday']); + // Still a card the reader takes back + self::assertSame($model['emails'], CardDocument::fromString($card->toString())->toArray()['emails']); + } + + private static function read(string $fixture): CardDocument + { + return CardDocument::fromString((string) file_get_contents(self::FIXTURES.$fixture)); + } + + /** + * What the editor would send for this model, with some fields changed. + * + * @param array $model + * @param array $changes + */ + private static function input(array $model, array $changes = []): CardInput + { + $data = array_merge($model, $changes); + $string = static fn (string $key): ?string => isset($data[$key]) && is_string($data[$key]) ? $data[$key] : null; + $rows = static function (string $key) use ($data): array { + $rows = $data[$key] ?? []; + self::assertIsArray($rows); + + return array_values(array_map(static function ($row): array { + self::assertIsArray($row); + + return $row; + }, $rows)); + }; + + return new CardInput( + firstName: $string('firstName'), + lastName: $string('lastName'), + org: $string('org'), + title: $string('title'), + emails: array_map(static fn (array $r) => new EmailInput((string) $r['value'], (string) ($r['type'] ?? 'other'), self::key($r)), $rows('emails')), + phones: array_map(static fn (array $r) => new PhoneInput((string) $r['value'], (string) ($r['type'] ?? 'other'), self::key($r)), $rows('phones')), + addresses: array_map(static fn (array $r) => new AddressInput( + (string) ($r['type'] ?? 'other'), + (string) ($r['street'] ?? ''), + (string) ($r['city'] ?? ''), + (string) ($r['region'] ?? ''), + (string) ($r['postalCode'] ?? ''), + (string) ($r['country'] ?? ''), + self::key($r), + ), $rows('addresses')), + urls: array_map(static fn (array $r) => new UrlInput((string) $r['value'], self::key($r)), $rows('urls')), + birthday: $string('birthday'), + note: $string('note'), + ); + } + + /** + * @param array $row + */ + private static function key(array $row): ?int + { + return isset($row['key']) && is_int($row['key']) ? $row['key'] : null; + } + + /** + * @return list + */ + private static function lines(string $card): array + { + return explode("\r\n", rtrim(self::unfold($card))); + } + + private static function unfold(string $card): string + { + return str_replace("\r\n ", '', $card); + } + + /** + * @param list $lines + * + * @return list + */ + private static function without(array $lines, string ...$names): array + { + return array_values(array_filter($lines, static function (string $line) use ($names): bool { + foreach ($names as $name) { + if (1 === preg_match(sprintf('/^(?:[^.:;]+\.)?%s[;:]/i', preg_quote($name, '/')), $line)) { + return false; + } + } + + return true; + })); + } +} diff --git a/backend/tests/Contacts/ContactCardsApiTest.php b/backend/tests/Contacts/ContactCardsApiTest.php new file mode 100644 index 0000000..27e7c15 --- /dev/null +++ b/backend/tests/Contacts/ContactCardsApiTest.php @@ -0,0 +1,233 @@ +client = self::createClient(); + // One container for the whole test, so the fake server keeps its cards between requests + $this->client->disableReboot(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + foreach (['Contact', 'Card', 'ContactSync'] as $entity) { + $entityManager->createQuery('DELETE FROM App\Entity\\'.$entity)->execute(); + } + + /** @psalm-suppress ServiceNotFound it is only a service in the test environment */ + $dav = self::getContainer()->get(FakeCardDav::class); + self::assertInstanceOf(FakeCardDav::class, $dav); + $this->dav = $dav; + $this->dav->books = [self::BOOK => ['token' => 't1', 'name' => 'Contacts'], self::WORK => ['token' => 'w1', 'name' => 'Work']]; + $this->dav->cards = [ + self::BOOK => [ + self::BOOK.'emile.vcf' => (string) file_get_contents(__DIR__.'/../Fixtures/vcard/plain-3.0.vcf'), + self::BOOK.'pic.vcf' => (string) file_get_contents(__DIR__.'/../Fixtures/vcard/photo-3.0.vcf'), + ], + self::WORK => [self::WORK.'carol.vcf' => FakeCardDav::card('Carol Petit', 'carol@work.example')], + ]; + } + + public function testMeSaysTheAddressBookIsThere(): void + { + self::assertTrue($this->get('/api/me')['contacts']); + } + + public function testTheListHasTheBooksAndEveryCardInShort(): void + { + $list = $this->get('/api/contacts/cards'); + + self::assertTrue($list['available']); + self::assertSame([['id' => self::id(self::BOOK), 'name' => 'Contacts'], ['id' => self::id(self::WORK), 'name' => 'Work']], $list['books']); + self::assertSame([ + [ + 'id' => self::id(self::WORK.'carol.vcf'), + 'book' => self::id(self::WORK), + 'etag' => FakeCardDav::etag($this->dav->cards[self::WORK][self::WORK.'carol.vcf']), + 'name' => 'Carol Petit', + 'org' => null, + 'emails' => ['carol@work.example'], + 'phones' => [], + 'photo' => false, + ], + [ + 'id' => self::id(self::BOOK.'emile.vcf'), + 'book' => self::id(self::BOOK), + 'etag' => FakeCardDav::etag($this->dav->cards[self::BOOK][self::BOOK.'emile.vcf']), + 'name' => 'Émile Durand', + 'org' => 'Example', + 'emails' => ['emile@example.test', 'emile@home.example'], + 'phones' => ['+33 6 00 00 00 01', '+33 1 00 00 00 01'], + 'photo' => false, + ], + ], array_values(array_filter($list['cards'], static fn (array $c) => !$c['photo']))); + self::assertCount(3, $list['cards']); + } + + public function testACardInFull(): void + { + $this->get('/api/contacts/cards'); + + $card = $this->get('/api/contacts/cards/'.self::id(self::BOOK.'emile.vcf')); + + self::assertSame(self::id(self::BOOK.'emile.vcf'), $card['id']); + self::assertSame(self::id(self::BOOK), $card['book']); + self::assertSame('Émile', $card['firstName']); + self::assertSame('Engineer', $card['title']); + self::assertSame(['key' => 0, 'value' => 'emile@example.test', 'type' => 'work', 'label' => null], $card['emails'][0]); + self::assertSame('1984-03-12', $card['birthday']); + self::assertFalse($card['photo']); + } + + public function testAnotherUsersCardIsNotFound(): void + { + $this->get('/api/contacts/cards'); + $id = self::id(self::BOOK.'emile.vcf'); + + $this->client->request('GET', '/api/contacts/cards/'.$id, server: self::server('bob')); + self::assertResponseStatusCodeSame(404); + self::assertSame('contact_not_found', $this->json()['error']); + + // Not even under bob's own key for the same href + self::assertSame([], $this->get('/api/contacts/cards', 'bob')['cards']); + $this->client->request('GET', '/api/contacts/cards/'.$id.'/vcf', server: self::server('bob')); + self::assertResponseStatusCodeSame(404); + } + + public function testThePictureIsServedForAYear(): void + { + $this->get('/api/contacts/cards'); + + $this->client->request('GET', '/api/contacts/cards/'.self::id(self::BOOK.'pic.vcf').'/photo', server: self::server()); + + self::assertResponseIsSuccessful(); + $response = $this->client->getResponse(); + self::assertSame('image/png', $response->headers->get('Content-Type')); + self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options')); + self::assertStringContainsString('immutable', (string) $response->headers->get('Cache-Control')); + self::assertStringStartsWith("\x89PNG", (string) $response->getContent()); + + $this->client->request('GET', '/api/contacts/cards/'.self::id(self::BOOK.'emile.vcf').'/photo', server: self::server()); + self::assertResponseStatusCodeSame(404); + } + + public function testDownloadsAreAttachmentsNamedAfterWhatTheyHold(): void + { + $this->get('/api/contacts/cards'); + + $this->client->request('GET', '/api/contacts/cards/'.self::id(self::BOOK.'emile.vcf').'/vcf', server: self::server()); + $response = $this->client->getResponse(); + self::assertSame('text/vcard; charset=utf-8', $response->headers->get('Content-Type')); + self::assertSame('attachment; filename="Emile Durand.vcf"; filename*=utf-8\'\'%C3%89mile%20Durand.vcf', $response->headers->get('Content-Disposition')); + self::assertStringContainsString('item1.X-ABLabel:_$!!$_'."\r\n", $this->downloadOf('apple-grouped.vcf')); + + $this->client->request('GET', '/api/contacts/books/'.self::id(self::WORK).'/vcf', server: self::server()); + self::assertStringContainsString('filename=Work.vcf', (string) $this->client->getResponse()->headers->get('Content-Disposition')); + self::assertSame(1, substr_count((string) $this->client->getResponse()->getContent(), 'BEGIN:VCARD')); + + $this->client->request('GET', '/api/contacts/vcf', server: self::server()); + self::assertSame(4, substr_count((string) $this->client->getResponse()->getContent(), 'BEGIN:VCARD'), 'the Apple card came with the refresh'); + } + + public function testARefreshSaysWhetherAnythingChanged(): void + { + $this->get('/api/contacts/cards'); + + $this->client->request('POST', '/api/contacts/refresh', server: self::server()); + self::assertSame(['available' => true, 'changed' => false], $this->json()); + + $this->dav->books[self::WORK] = ['token' => 'w2', 'name' => 'Work']; + $this->dav->cards[self::WORK][self::WORK.'trent.vcf'] = FakeCardDav::card('Trent Simon', 'trent@work.example'); + $this->client->request('POST', '/api/contacts/refresh', server: self::server()); + self::assertSame(['available' => true, 'changed' => true], $this->json()); + + $this->dav->status = 503; + $this->client->request('POST', '/api/contacts/refresh', server: self::server()); + self::assertSame(['available' => false, 'changed' => false], $this->json()); + self::assertFalse($this->get('/api/contacts/cards')['available']); + self::assertCount(4, $this->get('/api/contacts/cards')['cards'], 'the cache is still there'); + } + + public function testNothingIsThereWhenTheAddressBookIsNotSetUp(): void + { + $saved = [$_ENV['DAVIS_URL'] ?? null, $_SERVER['DAVIS_URL'] ?? null]; + $_ENV['DAVIS_URL'] = $_SERVER['DAVIS_URL'] = ''; + try { + self::ensureKernelShutdown(); + $client = self::createClient(); + $client->request('GET', '/api/me', server: self::server()); + self::assertFalse(json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR)['contacts']); + + foreach (['/api/contacts/cards', '/api/contacts/cards/'.self::id(self::BOOK.'emile.vcf'), '/api/contacts/vcf'] as $url) { + $client->request('GET', $url, server: self::server()); + self::assertResponseStatusCodeSame(404); + self::assertStringStartsWith('{"error":"contacts_disabled"', (string) $client->getResponse()->getContent()); + } + } finally { + [$_ENV['DAVIS_URL'], $_SERVER['DAVIS_URL']] = $saved; + } + } + + private function downloadOf(string $fixture): string + { + $this->dav->books[self::BOOK] = ['token' => 't2', 'name' => 'Contacts']; + $this->dav->cards[self::BOOK][self::BOOK.$fixture] = (string) file_get_contents(__DIR__.'/../Fixtures/vcard/'.$fixture); + $this->client->request('POST', '/api/contacts/refresh', server: self::server()); + $this->client->request('GET', '/api/contacts/cards/'.self::id(self::BOOK.$fixture).'/vcf', server: self::server()); + + return (string) $this->client->getResponse()->getContent(); + } + + private static function id(string $href, string $owner = 'alice'): string + { + return Card::idFor($owner, $href); + } + + /** + * @return array + */ + private function get(string $url, string $user = 'alice'): array + { + $this->client->request('GET', $url, server: self::server($user)); + self::assertResponseIsSuccessful(); + + return $this->json(); + } + + /** + * @return array + */ + private function json(): array + { + $data = json_decode((string) $this->client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($data); + + return $data; + } + + /** + * @return array + */ + private static function server(string $user = 'alice'): array + { + return ['REMOTE_ADDR' => '127.0.0.1', 'HTTP_REMOTE_USER' => $user, 'HTTP_REMOTE_EMAIL' => $user.'@example.test']; + } +} diff --git a/backend/tests/Contacts/ContactCardsWriteTest.php b/backend/tests/Contacts/ContactCardsWriteTest.php new file mode 100644 index 0000000..3661fb6 --- /dev/null +++ b/backend/tests/Contacts/ContactCardsWriteTest.php @@ -0,0 +1,370 @@ +client = self::createClient(); + $this->client->disableReboot(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + foreach (['Contact', 'Card', 'ContactSync'] as $entity) { + $entityManager->createQuery('DELETE FROM App\Entity\\'.$entity)->execute(); + } + + /** @psalm-suppress ServiceNotFound it is only a service in the test environment */ + $dav = self::getContainer()->get(FakeCardDav::class); + self::assertInstanceOf(FakeCardDav::class, $dav); + $this->dav = $dav; + $this->dav->books = [self::BOOK => ['token' => 't1', 'name' => 'Contacts']]; + $this->dav->cards = [self::BOOK => [self::EMILE => (string) file_get_contents(__DIR__.'/../Fixtures/vcard/plain-3.0.vcf')]]; + $this->request('GET', '/api/contacts/cards'); + $this->dav->take(); + } + + public function testANewCardGoesToTheBookOnlyIfNothingIsThere(): void + { + $this->request('POST', '/api/contacts/cards', ['book' => self::id(self::BOOK), 'firstName' => 'ZoĂ©', 'lastName' => 'Lambert', 'emails' => [['value' => 'zoe@example.test', 'type' => 'home']]]); + + self::assertResponseStatusCodeSame(201); + $card = $this->json(); + self::assertSame('ZoĂ© Lambert', $card['name']); + self::assertSame(['If-None-Match' => '*'], $this->dav->conditions()); + $href = array_keys($this->dav->cards[self::BOOK])[1]; + self::assertMatchesRegularExpression('#^'.self::BOOK.'[0-9a-f-]{36}\.vcf$#', $href); + self::assertSame(self::id($href), $card['id']); + self::assertSame(FakeCardDav::etag($this->dav->cards[self::BOOK][$href]), $card['etag']); + self::assertStringContainsString("EMAIL;TYPE=HOME:zoe@example.test\r\n", $this->dav->cards[self::BOOK][$href]); + self::assertSame(['Émile Durand', 'ZoĂ© Lambert'], array_column($this->list(), 'name')); + } + + public function testAnEditIsWrittenOverTheVersionTheClientSaw(): void + { + $card = $this->card(self::EMILE); + + $this->request('PUT', '/api/contacts/cards/'.$card['id'], ['title' => 'Chief'] + $card); + + self::assertResponseIsSuccessful(); + self::assertSame(['If-Match' => self::etagOf($card)], $this->dav->conditions()); + self::assertSame('Chief', $this->json()['title']); + self::assertSame(FakeCardDav::etag($this->dav->cards[self::BOOK][self::EMILE]), $this->json()['etag']); + self::assertSame(['PUT '.self::EMILE], $this->dav->take(), 'the answer had an ETag, nothing to read back'); + } + + public function testACardChangedElsewhereIsAConflictAndIsReadAgain(): void + { + $card = $this->card(self::EMILE); + $this->dav->cards[self::BOOK][self::EMILE] = str_replace('TITLE:Engineer', 'TITLE:Changed elsewhere', $this->dav->cards[self::BOOK][self::EMILE]); + + $this->request('PUT', '/api/contacts/cards/'.$card['id'], ['title' => 'Chief'] + $card); + + self::assertResponseStatusCodeSame(409); + self::assertSame('contact_changed', $this->json()['error']); + self::assertStringContainsString('Changed elsewhere', $this->dav->cards[self::BOOK][self::EMILE], 'the other change was not written over'); + self::assertSame(['PUT '.self::EMILE, 'GET '.self::EMILE], $this->dav->take()); + + // The user now sees the card as it is, and a change made on that version goes through + $now = $this->card(self::EMILE); + self::assertSame('Changed elsewhere', $now['title']); + $this->request('PUT', '/api/contacts/cards/'.$card['id'], ['title' => 'Chief'] + $now); + self::assertResponseIsSuccessful(); + self::assertStringContainsString('TITLE:Chief', $this->dav->cards[self::BOOK][self::EMILE]); + } + + public function testAChangeMadeOnAnOlderVersionThanTheCacheIsNotSent(): void + { + $card = $this->card(self::EMILE); + + $this->request('PUT', '/api/contacts/cards/'.$card['id'], ['title' => 'Chief', 'etag' => '"an older version"'] + $card); + + self::assertResponseStatusCodeSame(409); + self::assertSame(['GET '.self::EMILE], $this->dav->take(), 'nothing was written'); + self::assertStringContainsString('TITLE:Engineer', $this->dav->cards[self::BOOK][self::EMILE]); + } + + public function testACardDeletedElsewhereIsGoneForTheUserToo(): void + { + $card = $this->card(self::EMILE); + unset($this->dav->cards[self::BOOK][self::EMILE]); + + $this->request('DELETE', '/api/contacts/cards/'.$card['id'].'?etag='.urlencode(self::etagOf($card))); + + self::assertResponseStatusCodeSame(404); + self::assertSame('contact_not_found', $this->json()['error']); + self::assertSame([], $this->list()); + } + + public function testTwoCardsOfTheSamePersonBecomeOne(): void + { + $twin = self::BOOK.'twin.vcf'; + $this->dav->cards[self::BOOK][$twin] = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:twin\r\nFN:E. Durand\r\nN:Durand;E.;;;\r\nEMAIL;TYPE=HOME:EMILE@example.test\r\nEMAIL:emile@other.example\r\nNOTE:Met in Lyon\r\nEND:VCARD\r\n"; + $this->dav->books[self::BOOK] = ['token' => 't2', 'name' => 'Contacts']; + $this->request('POST', '/api/contacts/refresh'); + $this->dav->take(); + $card = $this->card(self::EMILE); + $other = $this->card($twin); + + $this->request('POST', '/api/contacts/cards/'.$card['id'].'/merge', ['etag' => $card['etag'], 'other' => $other['id'], 'otherEtag' => $other['etag']]); + + self::assertResponseIsSuccessful(); + self::assertSame(['PUT '.self::EMILE, 'DELETE '.$twin], $this->dav->take(), 'kept first, then the other deleted'); + $merged = $this->json(); + self::assertSame('Émile Durand', $merged['name']); + self::assertSame('Met in Lyon', $merged['note']); + self::assertIsArray($merged['emails']); + self::assertSame(['emile@example.test', 'emile@home.example', 'emile@other.example'], array_column($merged['emails'], 'value')); + self::assertSame(['Émile Durand'], array_column($this->list(), 'name')); + self::assertArrayNotHasKey($twin, $this->dav->cards[self::BOOK]); + } + + public function testAMergeOnACardThatChangedTouchesNeither(): void + { + $twin = self::BOOK.'twin.vcf'; + $this->dav->cards[self::BOOK][$twin] = FakeCardDav::card('E. Durand', 'emile@other.example'); + $this->dav->books[self::BOOK] = ['token' => 't2', 'name' => 'Contacts']; + $this->request('POST', '/api/contacts/refresh'); + $card = $this->card(self::EMILE); + $other = $this->card($twin); + $before = $this->dav->cards[self::BOOK]; + $this->dav->take(); + + $this->request('POST', '/api/contacts/cards/'.$card['id'].'/merge', ['etag' => $card['etag'], 'other' => $other['id'], 'otherEtag' => '"an older version"']); + + self::assertResponseStatusCodeSame(409); + self::assertSame($before, $this->dav->cards[self::BOOK]); + self::assertNotContains('PUT '.self::EMILE, $this->dav->take()); + } + + public function testACardIsNotMergedWithItself(): void + { + $card = $this->card(self::EMILE); + + $this->request('POST', '/api/contacts/cards/'.$card['id'].'/merge', ['etag' => $card['etag'], 'other' => $card['id'], 'otherEtag' => $card['etag']]); + + self::assertResponseStatusCodeSame(422); + } + + public function testWithoutAnEtagInTheAnswerTheCardIsReadBack(): void + { + $card = $this->card(self::EMILE); + $this->dav->rewrites = true; + + $this->request('PUT', '/api/contacts/cards/'.$card['id'], ['title' => 'Chief'] + $card); + + self::assertResponseIsSuccessful(); + self::assertSame(['PUT '.self::EMILE, 'GET '.self::EMILE], $this->dav->take()); + self::assertSame(FakeCardDav::etag($this->dav->cards[self::BOOK][self::EMILE]), $this->json()['etag']); + $this->request('GET', '/api/contacts/cards/'.$card['id'].'/vcf'); + self::assertStringContainsString('X-SERVER:was here', (string) $this->client->getResponse()->getContent()); + } + + public function testAFailedWriteLeavesTheCache(): void + { + $card = $this->card(self::EMILE); + $this->dav->failing = ['PUT' => 503]; + + $this->request('PUT', '/api/contacts/cards/'.$card['id'], ['title' => 'Chief'] + $card); + + self::assertResponseStatusCodeSame(502); + self::assertSame('contacts_unavailable', $this->json()['error']); + self::assertSame($card, $this->card(self::EMILE)); + } + + public function testAnEditWithoutItsEtagOrOnSomeoneElsesCardIsRefused(): void + { + $card = $this->card(self::EMILE); + + $this->request('PUT', '/api/contacts/cards/'.$card['id'], ['etag' => null] + $card); + self::assertResponseStatusCodeSame(422); + + $this->request('PUT', '/api/contacts/cards/'.$card['id'], $card, 'bob'); + self::assertResponseStatusCodeSame(404); + $this->request('DELETE', '/api/contacts/cards/'.$card['id'].'?etag='.urlencode($card['etag']), user: 'bob'); + self::assertResponseStatusCodeSame(404); + self::assertSame([], array_values(array_filter($this->dav->take(), static fn (string $r) => !str_starts_with($r, 'PROPFIND'))), 'nothing was written'); + } + + public function testAutocompleteFollowsAnEditedAddress(): void + { + $card = $this->card(self::EMILE); + $card['emails'][0]['value'] = 'emile@new.example'; + + $this->request('PUT', '/api/contacts/cards/'.$card['id'], $card); + self::assertResponseIsSuccessful(); + + $this->request('GET', '/api/contacts?q=emile'); + self::assertSame(['emile@home.example', 'emile@new.example'], array_column($this->json(), 'email')); + } + + public function testThePictureIsSavedAsASmallJpeg(): void + { + $card = $this->card(self::EMILE); + $image = imagecreatetruecolor(1600, 900); + self::assertNotFalse($image); + $file = (string) tempnam(sys_get_temp_dir(), 'pic'); + imagepng($image, $file); + + $this->client->request('PUT', '/api/contacts/cards/'.$card['id'].'/photo', ['etag' => $card['etag']], ['file' => new UploadedFile($file, 'me.png', 'image/png', null, true)], self::server()); + + self::assertResponseIsSuccessful(); + self::assertTrue($this->json()['photo']); + $this->request('GET', '/api/contacts/cards/'.$card['id'].'/photo'); + $size = getimagesizefromstring((string) $this->client->getResponse()->getContent()); + self::assertNotFalse($size); + self::assertSame([512, 288, IMAGETYPE_JPEG], [$size[0], $size[1], $size[2]]); + self::assertStringContainsString('PHOTO;ENCODING=b;TYPE=JPEG:/9j/', $this->dav->cards[self::BOOK][self::EMILE]); + + $etag = $this->card(self::EMILE)['etag']; + $this->request('DELETE', '/api/contacts/cards/'.$card['id'].'/photo?etag='.urlencode($etag)); + self::assertFalse($this->json()['photo']); + self::assertStringNotContainsString('PHOTO', $this->dav->cards[self::BOOK][self::EMILE]); + } + + public function testSomethingElseThanAnImageIsRefused(): void + { + $card = $this->card(self::EMILE); + $file = (string) tempnam(sys_get_temp_dir(), 'pic'); + file_put_contents($file, ''); + + $this->client->request('PUT', '/api/contacts/cards/'.$card['id'].'/photo', ['etag' => $card['etag']], ['file' => new UploadedFile($file, 'me.svg', 'image/svg+xml', null, true)], self::server()); + + self::assertResponseStatusCodeSame(422); + self::assertSame([], $this->dav->take()); + } + + public function testADeletedCardComesBackWithItsTextAndCanBeImportedAgain(): void + { + $card = $this->card(self::EMILE); + + $this->request('DELETE', '/api/contacts/cards/'.$card['id'].'?etag='.urlencode($card['etag'])); + + self::assertResponseIsSuccessful(); + self::assertSame(['If-Match' => self::etagOf($card)], $this->dav->conditions()); + $vcard = $this->json()['vcard']; + self::assertIsString($vcard); + self::assertStringContainsString('UID:plain-30', $vcard); + self::assertSame([], $this->list()); + + self::assertSame(['imported' => 1, 'failed' => 0], $this->import($vcard)); + self::assertSame(['Émile Durand'], array_column($this->list(), 'name')); + self::assertStringContainsString('UID:plain-30', (string) current($this->dav->cards[self::BOOK]), 'the UID was free again'); + } + + public function testAnImportCountsTheCardsThatFailed(): void + { + $file = (string) file_get_contents(__DIR__.'/../Fixtures/vcard/plain-3.0.vcf') // UID already in the book + .(string) file_get_contents(__DIR__.'/../Fixtures/vcard/no-email.vcf') + ."BEGIN:VCARD\r\nVERSION:2.1\r\nFN:Refused by the server\r\nEND:VCARD\r\n" + ."BEGIN:VCARD\r\nthis is not a vCard\r\nEND:VCARD\r\n"; + + self::assertSame(['imported' => 2, 'failed' => 2], $this->import($file)); + + $cards = $this->dav->cards[self::BOOK]; + self::assertCount(3, $cards); + self::assertSame(1, substr_count(implode('', $cards), 'UID:plain-30'), 'the copy got a UID of its own'); + self::assertSame(['Émile Durand', 'Émile Durand', 'Plumber'], array_column($this->list(), 'name')); + } + + /** + * @return array{imported: int, failed: int} + */ + private function import(string $vcf): array + { + $file = (string) tempnam(sys_get_temp_dir(), 'vcf'); + file_put_contents($file, $vcf); + $this->client->request('POST', '/api/contacts/books/'.self::id(self::BOOK).'/import', files: ['file' => new UploadedFile($file, 'cards.vcf', 'text/vcard', null, true)], server: self::server()); + self::assertResponseIsSuccessful(); + $result = $this->json(); + self::assertIsInt($result['imported']); + self::assertIsInt($result['failed']); + + return ['imported' => $result['imported'], 'failed' => $result['failed']]; + } + + /** + * @return array + */ + private function card(string $href): array + { + $this->request('GET', '/api/contacts/cards/'.self::id($href)); + self::assertResponseIsSuccessful(); + + return $this->json(); + } + + /** + * @return list> + */ + private function list(): array + { + $this->request('GET', '/api/contacts/cards'); + $cards = $this->json()['cards']; + self::assertIsArray($cards); + + return array_values(array_filter($cards, is_array(...))); + } + + /** + * @param array $card + */ + private static function etagOf(array $card): string + { + self::assertIsString($card['etag']); + + return $card['etag']; + } + + private static function id(string $href): string + { + return Card::idFor('alice', $href); + } + + /** + * @param array $payload + */ + private function request(string $method, string $uri, array $payload = [], string $user = 'alice'): void + { + $this->client->request($method, $uri, server: self::server($user) + ['CONTENT_TYPE' => 'application/json'], content: [] === $payload ? null : json_encode($payload, JSON_THROW_ON_ERROR)); + } + + /** + * @return array + */ + private function json(): array + { + $data = json_decode((string) $this->client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($data); + + return $data; + } + + /** + * @return array + */ + private static function server(string $user = 'alice'): array + { + return ['REMOTE_ADDR' => '127.0.0.1', 'HTTP_REMOTE_USER' => $user, 'HTTP_REMOTE_EMAIL' => $user.'@example.test']; + } +} diff --git a/backend/tests/Contacts/ContactsServiceTest.php b/backend/tests/Contacts/ContactsServiceTest.php index 2515800..b076d9c 100644 --- a/backend/tests/Contacts/ContactsServiceTest.php +++ b/backend/tests/Contacts/ContactsServiceTest.php @@ -7,7 +7,10 @@ use App\Contacts\CardDavClient; use App\Contacts\ContactsService; use App\Contacts\DavisConfig; +use App\Entity\Card; use App\Entity\Contact; +use App\Entity\ContactSync; +use App\Repository\CardRepository; use App\Repository\ContactRepository; use App\Repository\ContactSyncRepository; use App\Security\RemoteUser; @@ -15,33 +18,19 @@ use Psr\Log\NullLogger; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpClient\MockHttpClient; -use Symfony\Component\HttpClient\Response\MockResponse; - -use function sprintf; - -use const PHP_URL_PATH; /** - * Against a fake Davis: the requests it gets tell what the cache asked for. + * Against a fake address book server: the requests it gets tell what the cache asked for. */ final class ContactsServiceTest extends KernelTestCase { private const string URL = 'https://dav.example.test/dav/'; private const string HOME = '/dav/addressbooks/alice/'; + private const string BOOK = self::HOME.'contacts/'; private RemoteUser $user; private EntityManagerInterface $entityManager; - - /** @var array sync token by address book href */ - private array $books = []; - - /** @var array> cards by address book href */ - private array $cards = []; - - private int $status = 207; - - /** @var list */ - private array $requests = []; + private FakeCardDav $dav; protected function setUp(): void { @@ -49,10 +38,15 @@ protected function setUp(): void $this->user = new RemoteUser('alice', 'alice@example.test'); $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); $this->entityManager->createQuery('DELETE FROM App\Entity\Contact')->execute(); + $this->entityManager->createQuery('DELETE FROM App\Entity\Card')->execute(); $this->entityManager->createQuery('DELETE FROM App\Entity\ContactSync')->execute(); - $this->books = [self::HOME.'contacts/' => 'token-1']; - $this->cards = [self::HOME.'contacts/' => [self::card('Émile Durand', 'emile@example.test'), self::card('Bob Martin', 'bob@example.test')]]; + $this->dav = new FakeCardDav(); + $this->dav->books[self::BOOK] = ['token' => 'token-1', 'name' => 'Contacts']; + $this->dav->cards[self::BOOK] = [ + self::BOOK.'emile.vcf' => FakeCardDav::card('Émile Durand', 'emile@example.test'), + self::BOOK.'bob.vcf' => FakeCardDav::card('Bob Martin', 'bob@example.test'), + ]; } public function testTheFirstSearchFillsTheCache(): void @@ -60,67 +54,133 @@ public function testTheFirstSearchFillsTheCache(): void $found = $this->service()->search($this->user, 'emile'); self::assertSame(['emile@example.test'], array_map(static fn (Contact $c) => $c->getEmail(), $found)); - self::assertSame(['PROPFIND /dav/', 'PROPFIND /dav/principals/alice/', 'PROPFIND '.self::HOME, 'REPORT '.self::HOME.'contacts/'], $this->requests); + self::assertSame(['PROPFIND /dav/', 'PROPFIND /dav/principals/alice/', 'PROPFIND '.self::HOME, 'REPORT '.self::BOOK.' query with cards'], $this->dav->take()); } public function testAnUnchangedBookIsNotFetchedAgain(): void { $service = $this->service(); $service->refresh($this->user); - $this->requests = []; + $this->dav->take(); + + self::assertFalse($service->refresh($this->user)); + self::assertSame(['PROPFIND '.self::HOME], $this->dav->take(), 'the home is remembered, the cards are not fetched'); + } + + public function testAChangedCardIsFetchedAlone(): void + { + $service = $this->service(); $service->refresh($this->user); + $this->dav->take(); - /** @psalm-suppress TypeDoesNotContainType the fake client fills $this->requests during refresh() */ - self::assertSame(['PROPFIND '.self::HOME], $this->requests, 'the home is remembered, the cards are not fetched'); + $this->dav->books[self::BOOK] = ['token' => 'token-2', 'name' => 'Contacts']; + $this->dav->cards[self::BOOK][self::BOOK.'bob.vcf'] = FakeCardDav::card('Bob Martin', 'bob@new.example'); + self::assertTrue($service->refresh($this->user)); + + self::assertSame(['PROPFIND '.self::HOME, 'REPORT '.self::BOOK.' query with ETags', 'REPORT '.self::BOOK.' multiget '.self::BOOK.'bob.vcf'], $this->dav->take()); + self::assertSame(['bob@new.example', 'emile@example.test'], $this->cached()); + self::assertSame(['Bob Martin', 'Émile Durand'], array_map(static fn (Card $c) => $c->getName(), $this->cards())); } - public function testAChangedBookIsReplacedAndAGoneOneDropped(): void + public function testADeletedCardDisappears(): void { - $this->books[self::HOME.'work/'] = 'w-1'; - $this->cards[self::HOME.'work/'] = [self::card('Carol Petit', 'carol@work.example')]; $service = $this->service(); $service->refresh($this->user); - $this->books = [self::HOME.'contacts/' => 'token-2']; - $this->cards[self::HOME.'contacts/'] = [self::card('Bob Martin', 'bob@new.example')]; + $this->dav->books[self::BOOK] = ['token' => 'token-2', 'name' => 'Contacts']; + unset($this->dav->cards[self::BOOK][self::BOOK.'bob.vcf']); + $this->dav->take(); $service->refresh($this->user); - self::assertSame(['bob@new.example'], $this->cached()); + self::assertSame(['PROPFIND '.self::HOME, 'REPORT '.self::BOOK.' query with ETags'], $this->dav->take(), 'nothing to fetch'); + self::assertSame(['emile@example.test'], $this->cached()); + self::assertCount(1, $this->cards()); } - public function testDavisAwayKeepsWhatWeHave(): void + public function testACardWithoutEmailIsCached(): void { + $this->dav->cards[self::BOOK][self::BOOK.'plumber.vcf'] = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:plumber\r\nFN:Plumber\r\nORG:Fix-It\r\nTEL;TYPE=WORK:+33 4 00 00 00 02\r\nEND:VCARD\r\n"; + + $this->service()->refresh($this->user); + + $card = self::getContainer()->get(CardRepository::class)->findOwned('alice', Card::idFor('alice', self::BOOK.'plumber.vcf')); + self::assertNotNull($card); + self::assertSame(['Plumber', 'Fix-It', [], ['+33 4 00 00 00 02']], [$card->getName(), $card->getOrg(), $card->getEmails(), $card->getPhones()]); + self::assertSame(['bob@example.test', 'emile@example.test'], $this->cached(), 'no autocomplete row without an address'); + } + + public function testABookWithoutSyncTokenIsComparedEveryTime(): void + { + $this->dav->books[self::BOOK] = ['token' => '', 'name' => 'Contacts']; $service = $this->service(); $service->refresh($this->user); + $this->dav->take(); + + self::assertFalse($service->refresh($this->user)); + self::assertSame(['PROPFIND '.self::HOME, 'REPORT '.self::BOOK.' query with ETags'], $this->dav->take()); - $this->status = 401; + $this->dav->cards[self::BOOK][self::BOOK.'carol.vcf'] = FakeCardDav::card('Carol Petit', 'carol@example.test'); + self::assertTrue($service->refresh($this->user)); + self::assertSame(['bob@example.test', 'carol@example.test', 'emile@example.test'], $this->cached()); + } + + public function testAGoneBookIsDroppedAndBooksHaveTheirName(): void + { + $this->dav->books[self::HOME.'work/'] = ['token' => 'w-1', 'name' => 'Work']; + $this->dav->cards[self::HOME.'work/'] = [self::HOME.'work/carol.vcf' => FakeCardDav::card('Carol Petit', 'carol@work.example')]; + $service = $this->service(); + $service->refresh($this->user); + self::assertSame(['Contacts', 'Work'], array_column($this->sync()->getBooks(), 'name')); + + unset($this->dav->books[self::HOME.'work/']); $service->refresh($this->user); self::assertSame(['bob@example.test', 'emile@example.test'], $this->cached()); + self::assertSame([self::BOOK, self::BOOK], array_map(static fn (Card $c) => $c->getBook(), $this->cards())); + self::assertSame([self::BOOK], array_keys($this->sync()->getBooks())); + } + + public function testAServerAwayKeepsTheCacheAndSaysSo(): void + { + $service = $this->service(); + $service->refresh($this->user); + self::assertTrue($this->sync()->isAvailable()); + + $this->dav->status = 401; + $service->refresh($this->user); + + self::assertSame(['bob@example.test', 'emile@example.test'], $this->cached()); + self::assertCount(2, $this->cards()); + self::assertFalse($this->sync()->isAvailable()); + + $this->dav->status = 207; + $service->refresh($this->user); + self::assertTrue($this->sync()->isAvailable()); } public function testTheLookupNamesTheAddressesOnFile(): void { - $this->cards[self::HOME.'contacts/'][] = self::card('', 'nameless@example.test'); + $this->dav->cards[self::BOOK][self::BOOK.'nameless.vcf'] = FakeCardDav::card('', 'nameless@example.test'); $names = $this->service()->lookup($this->user, ['Bob@Example.test', 'nameless@example.test', 'stranger@example.test']); ksort($names); self::assertSame([ - 'bob@example.test' => ['name' => 'Bob Martin', 'photo' => false], - 'nameless@example.test' => ['name' => null, 'photo' => false], + 'bob@example.test' => ['name' => 'Bob Martin', 'photo' => false, 'id' => Card::idFor('alice', self::BOOK.'bob.vcf')], + 'nameless@example.test' => ['name' => null, 'photo' => false, 'id' => Card::idFor('alice', self::BOOK.'nameless.vcf')], ], $names); } public function testThePictureOnACardIsKeptAndServed(): void { $png = "\x89PNG\r\n\x1a\n"; - $this->cards[self::HOME.'contacts/'][] = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Pic\r\nPHOTO;ENCODING=b;TYPE=PNG:".base64_encode($png)."\r\nEMAIL:pic@example.test\r\nEND:VCARD\r\n"; + $this->dav->cards[self::BOOK][self::BOOK.'pic.vcf'] = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Pic\r\nPHOTO;ENCODING=b;TYPE=PNG:".base64_encode($png)."\r\nEMAIL:pic@example.test\r\nEND:VCARD\r\n"; $service = $this->service(); - self::assertSame(['pic@example.test' => ['name' => 'Pic', 'photo' => true]], $service->lookup($this->user, ['pic@example.test'])); + self::assertSame(['pic@example.test' => ['name' => 'Pic', 'photo' => true, 'id' => Card::idFor('alice', self::BOOK.'pic.vcf')]], $service->lookup($this->user, ['pic@example.test'])); self::assertSame(['mime' => 'image/png', 'data' => $png], $service->photo($this->user, 'PIC@example.test')); + self::assertSame([false, false, true], array_map(static fn (Card $c) => $c->hasPhoto(), $this->cards())); self::assertNull($service->photo($this->user, 'bob@example.test')); } @@ -129,12 +189,12 @@ public function testNothingIsAskedWhenDavisIsNotConfigured(): void $service = $this->service(new DavisConfig('', '', '')); self::assertSame([], $service->search($this->user, 'bob')); - self::assertSame([], $this->requests); + self::assertSame([], $this->dav->take()); } public function testAWordStartingWithTheQueryComesFirst(): void { - $this->cards[self::HOME.'contacts/'][] = self::card('Rob Ober', 'rob@example.test'); + $this->dav->cards[self::BOOK][self::BOOK.'rob.vcf'] = FakeCardDav::card('Rob Ober', 'rob@example.test'); $found = $this->service()->search($this->user, 'ob'); @@ -147,56 +207,42 @@ public function testAWordStartingWithTheQueryComesFirst(): void */ private function cached(): array { + $this->entityManager->clear(); $all = self::getContainer()->get(ContactRepository::class)->findBy(['owner' => 'alice'], ['email' => 'ASC']); return array_map(static fn (Contact $c) => $c->getEmail(), $all); } + /** + * @return list + */ + private function cards(): array + { + $this->entityManager->clear(); + + return self::getContainer()->get(CardRepository::class)->findAllOwned('alice'); + } + + private function sync(): ContactSync + { + $sync = self::getContainer()->get(ContactSyncRepository::class)->find('alice'); + self::assertNotNull($sync); + + return $sync; + } + private function service(?DavisConfig $config = null): ContactsService { $config ??= new DavisConfig(self::URL, 'app_test', 'secret'); - $http = new MockHttpClient(function (string $method, string $url): MockResponse { - $path = (string) parse_url($url, PHP_URL_PATH); - $this->requests[] = $method.' '.$path; - if (207 !== $this->status) { - return new MockResponse('', ['http_code' => $this->status]); - } - - return new MockResponse($this->answer($method, $path), ['http_code' => 207]); - }); return new ContactsService( $config, - new CardDavClient($http, $config), + new CardDavClient(new MockHttpClient($this->dav), $config), self::getContainer()->get(ContactRepository::class), + self::getContainer()->get(CardRepository::class), self::getContainer()->get(ContactSyncRepository::class), $this->entityManager, new NullLogger(), ); } - - private function answer(string $method, string $path): string - { - $prop = match (true) { - 'REPORT' === $method => implode('', array_map( - static fn (string $card) => sprintf('%s1.vcf%s', $path, htmlspecialchars($card)), - $this->cards[$path] ?? [], - )), - '/dav/' === $path => '/dav//dav/principals/alice/', - '/dav/principals/alice/' === $path => ''.$path.''.self::HOME.'', - default => ''.self::HOME.'' - .implode('', array_map( - static fn (string $href, string $token) => sprintf('%s%s', $href, $token), - array_keys($this->books), - $this->books, - )), - }; - - return ''.$prop.''; - } - - private static function card(string $name, string $email): string - { - return "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:{$name}\r\nEMAIL:{$email}\r\nEND:VCARD\r\n"; - } } diff --git a/backend/tests/Contacts/FakeCardDav.php b/backend/tests/Contacts/FakeCardDav.php new file mode 100644 index 0000000..f1e441a --- /dev/null +++ b/backend/tests/Contacts/FakeCardDav.php @@ -0,0 +1,220 @@ + by book href */ + public array $books = []; + + /** @var array> vCard by card href, by book href */ + public array $cards = []; + + /** Anything else than 207 makes every request fail with it */ + public int $status = 207; + + /** @var array a status that requests of this method get instead */ + public array $failing = []; + + /** When true, PUT stores the card with a line of its own and answers without an ETag, as servers that change cards do */ + public bool $rewrites = false; + + /** @var array the conditional headers of the last PUT or DELETE */ + private array $conditions = []; + + /** @var list */ + private array $requests = []; + + /** + * The requests received since the last call. + * + * @return list + */ + public function take(): array + { + $requests = $this->requests; + $this->requests = []; + + return $requests; + } + + /** + * The If-Match or If-None-Match of the last PUT or DELETE. + * + * @return array + */ + public function conditions(): array + { + return $this->conditions; + } + + public static function card(string $name, string $email): string + { + return "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:{$name}\r\nEMAIL:{$email}\r\nEND:VCARD\r\n"; + } + + public static function etag(string $vcard): string + { + return '"'.md5($vcard).'"'; + } + + /** + * @param array $options + */ + public function __invoke(string $method, string $url, array $options): MockResponse + { + $path = (string) parse_url($url, PHP_URL_PATH); + $body = is_string($options['body'] ?? null) ? $options['body'] : ''; + $this->requests[] = $method.' '.$path.('REPORT' === $method ? ' '.$this->reportKind($body) : ''); + if (207 !== $this->status || isset($this->failing[$method])) { + return new MockResponse('', ['http_code' => $this->failing[$method] ?? $this->status]); + } + if (in_array($method, ['GET', 'PUT', 'DELETE'], true)) { + return $this->resource($method, $path, $body, $options); + } + + return new MockResponse($this->multistatus($method, $path, $body, self::login($options)), ['http_code' => 207]); + } + + /** + * One card: read, written or deleted, with If-Match and If-None-Match as RFC 7232 says. + * + * @param array $options + */ + private function resource(string $method, string $href, string $body, array $options): MockResponse + { + $book = substr($href, 0, (int) strrpos($href, '/') + 1); + $current = $this->cards[$book][$href] ?? null; + $ifMatch = self::header($options, 'if-match'); + $ifNoneMatch = self::header($options, 'if-none-match'); + if ('GET' !== $method) { + $this->conditions = array_filter(['If-Match' => $ifMatch, 'If-None-Match' => $ifNoneMatch], static fn (?string $v) => null !== $v); + } + if (null !== $ifMatch && (null === $current || $ifMatch !== self::etag($current)) || '*' === $ifNoneMatch && null !== $current) { + return new MockResponse('', ['http_code' => 412]); + } + if ('GET' === $method || 'DELETE' === $method) { + if (null === $current || !isset($this->books[$book])) { + return new MockResponse('', ['http_code' => 404]); + } + if ('DELETE' === $method) { + unset($this->cards[$book][$href]); + + return new MockResponse('', ['http_code' => 204]); + } + + return new MockResponse($current, ['http_code' => 200, 'response_headers' => ['ETag' => self::etag($current)]]); + } + + if (!isset($this->books[$book])) { + return new MockResponse('', ['http_code' => 404]); + } + if (!str_starts_with($body, 'BEGIN:VCARD') || str_contains($body, 'VERSION:2.1')) { + return new MockResponse('', ['http_code' => 415]); + } + $stored = $this->rewrites ? str_replace("END:VCARD\r\n", "X-SERVER:was here\r\nEND:VCARD\r\n", $body) : $body; + $this->cards[$book][$href] = $stored; + + return new MockResponse('', ['http_code' => null === $current ? 201 : 204, 'response_headers' => $this->rewrites ? [] : ['ETag' => self::etag($stored)]]); + } + + /** + * @param array $options + */ + private static function header(array $options, string $name): ?string + { + $headers = $options['normalized_headers'] ?? []; + $line = is_array($headers) && is_array($headers[$name] ?? null) ? ($headers[$name][0] ?? null) : null; + + return is_string($line) ? substr($line, (int) strpos($line, ':') + 2) : null; + } + + /** + * Who is asking: "alice" for "alice*app_test", as Davis reads an app key login. + * + * @param array $options + */ + private static function login(array $options): string + { + $headers = $options['normalized_headers'] ?? []; + $header = is_array($headers) && is_array($headers['authorization'] ?? null) ? (string) ($headers['authorization'][0] ?? '') : ''; + $decoded = (string) base64_decode(substr($header, strlen('Authorization: Basic ')), true); + + return explode('*', explode(':', $decoded)[0])[0]; + } + + private function reportKind(string $body): string + { + if (str_contains($body, 'addressbook-multiget')) { + preg_match_all('#([^<]*)#', $body, $m); + + return 'multiget '.implode(' ', $m[1]); + } + + return str_contains($body, 'address-data') ? 'query with cards' : 'query with ETags'; + } + + private function multistatus(string $method, string $path, string $body, string $login): string + { + $home = '/dav/addressbooks/'.$login.'/'; + $books = array_filter($this->books, static fn (string $href) => str_starts_with($href, $home), ARRAY_FILTER_USE_KEY); + $responses = match (true) { + 'REPORT' === $method => str_starts_with($path, $home) ? $this->report($path, $body) : '', + '/dav/' === $path => '/dav//dav/principals/'.$login.'/HTTP/1.1 200 OK', + '/dav/principals/'.$login.'/' === $path => ''.$path.''.$home.'HTTP/1.1 200 OK', + default => ''.$home.'HTTP/1.1 200 OK' + .implode('', array_map( + static fn (string $href, array $book) => sprintf( + '%s%s%sHTTP/1.1 200 OK', + $href, + '' === $book['token'] ? '' : ''.$book['token'].'', + htmlspecialchars($book['name']), + ), + array_keys($books), + $books, + )), + }; + + return ''.$responses.''; + } + + private function report(string $book, string $body): string + { + $cards = $this->cards[$book] ?? []; + if (str_contains($body, 'addressbook-multiget')) { + preg_match_all('#([^<]*)#', $body, $m); + $cards = array_intersect_key($cards, array_flip(array_map(htmlspecialchars_decode(...), $m[1]))); + } + $withData = str_contains($body, 'address-data'); + + $out = ''; + foreach ($cards as $href => $vcard) { + $out .= sprintf( + '%s%s%sHTTP/1.1 200 OK', + htmlspecialchars($href), + htmlspecialchars(self::etag($vcard)), + // Like Davis, carriage returns are sent as so XML does not turn them into plain line feeds + $withData ? ''.str_replace("\r", ' ', htmlspecialchars($vcard)).'' : '', + ); + } + + return $out; + } +} diff --git a/backend/tests/Fixtures/vcard/apple-grouped.vcf b/backend/tests/Fixtures/vcard/apple-grouped.vcf new file mode 100644 index 0000000..af862e2 --- /dev/null +++ b/backend/tests/Fixtures/vcard/apple-grouped.vcf @@ -0,0 +1,18 @@ +BEGIN:VCARD +VERSION:3.0 +PRODID:-//Apple Inc.//iPhone OS 17.0//EN +N:Moreau;Grace;;; +FN:Grace Moreau +item1.EMAIL;type=INTERNET;type=pref:grace@example.test +item1.X-ABLabel:_$!!$_ +item2.TEL:+33 6 00 00 00 04 +item2.X-ABLabel:Boat +item3.ADR;type=HOME;type=pref:;;1 quai du Port;Marseille;;13002;France +item3.X-ABADR:fr +item4.URL;type=pref:https://grace.example +item4.X-ABLabel:_$!!$_ +BDAY;X-APPLE-OMIT-YEAR=1604:1604-11-02 +X-SOCIALPROFILE;type=mastodon:https://social.example/@grace +IMPP;X-SERVICE-TYPE=Jabber;type=HOME:xmpp:grace@chat.example +UID:apple-grouped +END:VCARD diff --git a/backend/tests/Fixtures/vcard/folded-note.vcf b/backend/tests/Fixtures/vcard/folded-note.vcf new file mode 100644 index 0000000..ab4490b --- /dev/null +++ b/backend/tests/Fixtures/vcard/folded-note.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:3.0 +UID:note +FN:Note Taker +NOTE:First line\nsecond line\, with a comma\; and a semicolon. This note is long enou + gh to be folded over two lines. +END:VCARD diff --git a/backend/tests/Fixtures/vcard/no-email.vcf b/backend/tests/Fixtures/vcard/no-email.vcf new file mode 100644 index 0000000..5ebceae --- /dev/null +++ b/backend/tests/Fixtures/vcard/no-email.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:3.0 +UID:no-email +FN:Plumber +ORG:Fix-It +TEL;TYPE=WORK:+33 4 00 00 00 02 +END:VCARD diff --git a/backend/tests/Fixtures/vcard/photo-2.1.vcf b/backend/tests/Fixtures/vcard/photo-2.1.vcf new file mode 100644 index 0000000..910f9c8 --- /dev/null +++ b/backend/tests/Fixtures/vcard/photo-2.1.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:2.1 +FN:Pic Two +PHOTO;PNG;ENCODING=BASE64:iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAC0lEQVQImWNgQAYAAA4AAbGa6gYAAAAASUVORK5CYII= +EMAIL:pic2@example.test +END:VCARD diff --git a/backend/tests/Fixtures/vcard/photo-3.0.vcf b/backend/tests/Fixtures/vcard/photo-3.0.vcf new file mode 100644 index 0000000..1c4f658 --- /dev/null +++ b/backend/tests/Fixtures/vcard/photo-3.0.vcf @@ -0,0 +1,8 @@ +BEGIN:VCARD +VERSION:3.0 +UID:photo-30 +FN:Pic Three +PHOTO;ENCODING=b;TYPE=PNG:iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD9 + 1JpzAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAC0lEQVQImWNgQAYAAA4AAbGa6gYAAAAASUVORK5CYII= +EMAIL:pic3@example.test +END:VCARD diff --git a/backend/tests/Fixtures/vcard/photo-4.0.vcf b/backend/tests/Fixtures/vcard/photo-4.0.vcf new file mode 100644 index 0000000..8066c48 --- /dev/null +++ b/backend/tests/Fixtures/vcard/photo-4.0.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:4.0 +UID:photo-40 +FN:Pic Four +PHOTO:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAC0lEQVQImWNgQAYAAA4AAbGa6gYAAAAASUVORK5CYII= +EMAIL:pic4@example.test +END:VCARD diff --git a/backend/tests/Fixtures/vcard/plain-3.0.vcf b/backend/tests/Fixtures/vcard/plain-3.0.vcf new file mode 100644 index 0000000..e4cc1d0 --- /dev/null +++ b/backend/tests/Fixtures/vcard/plain-3.0.vcf @@ -0,0 +1,17 @@ +BEGIN:VCARD +VERSION:3.0 +PRODID:-//Example//Example//EN +UID:plain-30 +FN:Émile Durand +N:Durand;Émile;Jean;Dr;Jr +ORG:Example;Research +TITLE:Engineer +EMAIL;TYPE=INTERNET;TYPE=WORK:emile@example.test +EMAIL;TYPE=INTERNET;TYPE=HOME;TYPE=PREF:emile@home.example +TEL;TYPE=CELL;TYPE=VOICE:+33 6 00 00 00 01 +TEL;TYPE=WORK:+33 1 00 00 00 01 +ADR;TYPE=HOME:BP 12;BĂąt. B;12 rue des Lilas;Lyon;;69001;France +URL:https://example.test/emile +BDAY:1984-03-12 +REV:2020-01-01T00:00:00Z +END:VCARD diff --git a/backend/tests/Fixtures/vcard/unknown-properties.vcf b/backend/tests/Fixtures/vcard/unknown-properties.vcf new file mode 100644 index 0000000..f7e2d00 --- /dev/null +++ b/backend/tests/Fixtures/vcard/unknown-properties.vcf @@ -0,0 +1,13 @@ +BEGIN:VCARD +VERSION:3.0 +UID:unknown +FN:Odd Fields +EMAIL:odd@example.test +X-FOO;X-BAR="a,b";X-BAZ=c:weird\,value;with parts +X-EMPTY: +X-MS-OL-DESIGN;CHARSET=utf-8: +NICKNAME:Oddy,Od +CATEGORIES:friends,work +GEO:48.85;2.35 +PHOTO;VALUE=URI:https://example.test/pic.jpg +END:VCARD diff --git a/backend/tests/Fixtures/vcard/version-4.0.vcf b/backend/tests/Fixtures/vcard/version-4.0.vcf new file mode 100644 index 0000000..1f8987b --- /dev/null +++ b/backend/tests/Fixtures/vcard/version-4.0.vcf @@ -0,0 +1,12 @@ +BEGIN:VCARD +VERSION:4.0 +UID:urn:uuid:4f0e2c8a-0000-4000-8000-000000000040 +FN:NoĂ©mie Roux +N:Roux;NoĂ©mie;;; +KIND:individual +EMAIL;TYPE=work;PREF=1:noemie@work.example +TEL;VALUE=uri;TYPE="cell,voice":tel:+33-6-00-00-00-03 +BDAY:--0704 +URL:https://example.test/a,b +IMPP;PREF=1:xmpp:noemie@chat.example +END:VCARD diff --git a/backend/tests/Security/RemoteUserAuthenticatorTest.php b/backend/tests/Security/RemoteUserAuthenticatorTest.php index a42d65d..ef6afa7 100644 --- a/backend/tests/Security/RemoteUserAuthenticatorTest.php +++ b/backend/tests/Security/RemoteUserAuthenticatorTest.php @@ -49,7 +49,7 @@ public function testHeadersFromTheTrustedProxyAuthenticate(): void self::assertResponseIsSuccessful(); self::assertJsonStringEqualsJsonString( - '{"username":"alice","email":"alice@example.com","displayName":"Alice","logoutUrl":null}', + '{"username":"alice","email":"alice@example.com","displayName":"Alice","logoutUrl":null,"contacts":true}', (string) $client->getResponse()->getContent(), ); } diff --git a/dev/davis/seed.sh b/dev/davis/seed.sh index 12e713e..a362389 100755 --- a/dev/davis/seed.sh +++ b/dev/davis/seed.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Creates the CardDAV user "alice" with an address book and fills it with -# synthetic contacts. Idempotent enough: rerunning overwrites the same cards. +# Creates the CardDAV user "alice" with two address books and fills them with +# synthetic contacts, plus a few cards with every kind of field. Idempotent enough: rerunning overwrites the same cards. set -euo pipefail DAVIS=http://127.0.0.1:9000 @@ -16,8 +16,17 @@ docker exec -u www-data postel-dev-davis sh -c "cd /var/www/davis && APP_ENV=pro UPDATE users SET digesta1 = '$hash' WHERE username = '$USER'; INSERT INTO principals (uri, email, displayname, is_main, is_admin) SELECT 'principals/$USER', '$USER@example.test', 'Alice', 1, 0 WHERE NOT EXISTS (SELECT 1 FROM principals WHERE uri = 'principals/$USER'); INSERT INTO addressbooks (principaluri, uri, displayname, synctoken) SELECT 'principals/$USER', 'contacts', 'Contacts', 1 WHERE NOT EXISTS (SELECT 1 FROM addressbooks WHERE principaluri = 'principals/$USER' AND uri = 'contacts'); + INSERT INTO addressbooks (principaluri, uri, displayname, synctoken) SELECT 'principals/$USER', 'work', 'Work', 1 WHERE NOT EXISTS (SELECT 1 FROM addressbooks WHERE principaluri = 'principals/$USER' AND uri = 'work'); \"" +# put : the lines of the card are joined with CRLF +put() { + local card + card=$(printf '%s\r\n' "${@:3}") + code=$(curl -s -o /dev/null -w '%{http_code}' -u "$USER:$PASSWORD" -X PUT -H 'Content-Type: text/vcard; charset=utf-8' --data-binary "$card" "$DAVIS/dav/addressbooks/$USER/$1/$2.vcf") + case $code in 201|204) ;; *) echo "PUT $1/$2 failed: $code" >&2; exit 1 ;; esac +} + firsts=(Alice Bob Carol Dave Erin Frank Grace Heidi Ivan Judy Mallory Niaj Olivia Peggy Rupert Sybil Trent Victor Walter ZoĂ« François Émile NoĂ©mie) lasts=(Martin Bernard Dubois Thomas Robert Richard Petit Durand Leroy Moreau Simon Laurent Lefebvre Michel Garcia David Bertrand Roux Vincent Fournier DĂŒrr "O'Brien" "Smith-Jones") domains=(example.test mail.example work.example) @@ -27,8 +36,29 @@ for i in $(seq 1 "$COUNT"); do last=${lasts[$((RANDOM % ${#lasts[@]}))]} slug=$(printf '%s.%s' "$first" "$last" | LC_ALL=C tr -cd '[:alnum:].' | tr '[:upper:]' '[:lower:]') email="$slug$i@${domains[$((RANDOM % ${#domains[@]}))]}" - card=$(printf 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:contact-%d\r\nFN:%s %s\r\nN:%s;%s;;;\r\nEMAIL;TYPE=INTERNET:%s\r\nEND:VCARD\r\n' "$i" "$first" "$last" "$last" "$first" "$email") - code=$(curl -s -o /dev/null -w '%{http_code}' -u "$USER:$PASSWORD" -X PUT -H 'Content-Type: text/vcard; charset=utf-8' --data-binary "$card" "$DAVIS/dav/addressbooks/$USER/contacts/contact-$i.vcf") - case $code in 201|204) ;; *) echo "PUT contact-$i failed: $code" >&2; exit 1 ;; esac + put contacts "contact-$i" BEGIN:VCARD VERSION:3.0 "UID:contact-$i" "FN:$first $last" "N:$last;$first;;;" "EMAIL;TYPE=INTERNET:$email" END:VCARD done -echo "$COUNT contacts in $DAVIS/dav/addressbooks/$USER/contacts/" + +# A few cards with everything the address book shows, and the odd shapes it must keep intact +PHOTO=/9j/4AAQSkZJRgABAQEAYABgAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2ODApLCBxdWFsaXR5ID0gNzAK/9sAQwAKBwcIBwYKCAgICwoKCw4YEA4NDQ4dFRYRGCMfJSQiHyIhJis3LyYpNCkhIjBBMTQ5Oz4+PiUuRElDPEg3PT47/9sAQwEKCwsODQ4cEBAcOygiKDs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7/8AAEQgAQABAAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8Ar0UUV7h4gUUVZtNNvb8gWttJIMkbgMKDjOCTwKTaSuxpNuyK1FXLvSdQsQWubWRFABLgblHOOSOKp0KSkrpg4uLs0FFFFMQUUUUAaGiaX/a2oC3MmxFXe5HXAIGB7816DBBFawJBAgSNBhVHauU8FTKtzdQEHc6K4PbAJB/9CFdfXjY2cnU5Xsj2MFCKp8y3Y10WRGR1DKwwVIyCPSuE8RaOmlXSNC2YZ8lFPVMYyPccjH+Se9rm/GkyrY28BB3PKXB7YAwf/QhU4Oco1Ul1KxcIyptvocdRRRXtnihRRRQBJBPLazpPA5SRDlWHau20zxPY3cKLcyrb3B4ZWyFOO4PQD6n/AOvxtnYXV/IY7WBpSOpHQfU9B0rdg8FXLbvtF5FH/d8tS+fzxiuPEqjLSbszrwzrR1gro277xJptnG22dZ5QuVSI53f8CHArh76+n1G6a4uH3O3AA6KPQe1b03gqZUBgvY3bPIdCox9RmsW/0q9018XMJC5wJByrde/4dOtThlQj8DuysS68vjVkU6KKK7jiCtHRNJbV73yyxSJBukcDt6D3P+PpWdXeeFIVi0KN1JJmdnbPY528fgormxNV06d1udGGpKpUs9jVggitYEggQJGgwqjtUlFFeE3fVnuJWCmuiyIyOoZWGCpGQR6U6igDg/EWiDSp1lgybaUnaDk7D6E/y78H0zWNXo2uQrPol2jkgCIvx6r8w/UV5zXt4Sq6kPe3R4uKpKnPTZn/2Q== +put contacts full BEGIN:VCARD VERSION:3.0 UID:seed-full "FN:Émile Durand" "N:Durand;Émile;;;" ORG:Example "TITLE:Engineer" \ + "EMAIL;TYPE=INTERNET;TYPE=WORK:emile@example.test" "EMAIL;TYPE=INTERNET;TYPE=HOME:emile.durand@mail.example" \ + "TEL;TYPE=CELL:+33 6 00 00 00 01" "TEL;TYPE=WORK:+33 1 00 00 00 01" \ + "ADR;TYPE=HOME:;;12 rue des Lilas;Lyon;;69001;France" "URL:https://example.test/emile" \ + BDAY:1984-03-12 "NOTE:Met at the conference.\\nLikes tea\\, not coffee." "PHOTO;ENCODING=b;TYPE=JPEG:$PHOTO" END:VCARD +put contacts no-email BEGIN:VCARD VERSION:3.0 UID:seed-no-email "FN:Plumber" ORG:Fix-It "TEL;TYPE=WORK:+33 4 00 00 00 02" END:VCARD +put contacts version-4 BEGIN:VCARD VERSION:4.0 UID:seed-version-4 "FN:NoĂ©mie Roux" "N:Roux;NoĂ©mie;;;" \ + "EMAIL;TYPE=work:noemie@work.example" "TEL;VALUE=uri;TYPE=cell:tel:+33-6-00-00-00-03" "BDAY:--0704" END:VCARD +put contacts apple BEGIN:VCARD VERSION:3.0 "PRODID:-//Apple Inc.//iPhone OS 17.0//EN" UID:seed-apple "FN:Grace Moreau" "N:Moreau;Grace;;;" \ + "item1.EMAIL;type=INTERNET;type=pref:grace@example.test" "item1.X-ABLabel:_\$!!\$_" \ + "item2.TEL:+33 6 00 00 00 04" "item2.X-ABLabel:Boat" \ + "item3.ADR;type=HOME;type=pref:;;1 quai du Port;Marseille;;13002;France" "item3.X-ABADR:fr" \ + "BDAY;X-APPLE-OMIT-YEAR=1604:1604-11-02" "X-SOCIALPROFILE;type=mastodon:https://social.example/@grace" "IMPP;X-SERVICE-TYPE=Jabber:xmpp:grace@chat.example" END:VCARD +put contacts duplicate-1 BEGIN:VCARD VERSION:3.0 UID:seed-duplicate-1 "FN:Victor Laurent" "N:Laurent;Victor;;;" "EMAIL;TYPE=INTERNET:victor.laurent@example.test" END:VCARD +put contacts duplicate-2 BEGIN:VCARD VERSION:3.0 UID:seed-duplicate-2 "FN:Victor Laurent" "N:Laurent;Victor;;;" "EMAIL;TYPE=INTERNET:victor@work.example" "TEL;TYPE=CELL:+33 6 00 00 00 05" "ORG:Laurent & Fils" END:VCARD + +# A second address book +put work colleague-1 BEGIN:VCARD VERSION:3.0 UID:seed-work-1 "FN:Carol Petit" "N:Petit;Carol;;;" ORG:Work "EMAIL;TYPE=INTERNET;TYPE=WORK:carol@work.example" END:VCARD +put work colleague-2 BEGIN:VCARD VERSION:3.0 UID:seed-work-2 "FN:Trent Simon" "N:Simon;Trent;;;" ORG:Work "EMAIL;TYPE=INTERNET;TYPE=WORK:trent@work.example" "TEL;TYPE=WORK:+33 1 00 00 00 06" END:VCARD + +echo "$COUNT contacts and a few special ones in $DAVIS/dav/addressbooks/$USER/contacts/, 2 in $DAVIS/dav/addressbooks/$USER/work/" diff --git a/docs/architecture.md b/docs/architecture.md index 5b2e3c8..75c624f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,7 +21,7 @@ Dovecot does the heavy work: `SORT`, `SEARCH`, `THREAD`, `STATUS` and `PREVIEW` Messages are parsed with `zbateson/mail-mime-parser`, which decodes encoded headers properly and lets the app guess the charset when a message doesn't declare one. The test messages in `backend/tests/Fixtures/mail` cover the difficult cases: inline images, `format=flowed`, calendar invites, Latin-1 and undeclared UTF-8, Outlook HTML, nested multiparts, and a message trying to inject scripts. -Drafts and sent mail are stored in IMAP. The only local state is a SQLite file with the user settings and the contacts cache. +Drafts and sent mail are stored in IMAP. The only local state is a SQLite file with the user settings and the copy of the address books. ## Authentication @@ -41,11 +41,35 @@ Dovecot limits the number of connections per user and IP address (`mail_max_user Contacts come from [Davis](https://github.com/tchapi/davis) over CardDAV, with an app key: the backend logs in as `*` with the key's secret, and Davis acts as ``. -Each CardDAV request costs about 170 ms, because Davis checks a bcrypt hash every time. So autocomplete never asks Davis directly. It searches a copy of the address books kept in SQLite: +Everything after the login is plain CardDAV (RFC 6352): discovery from the principal, `REPORT`s, `GET`, `PUT` and `DELETE` with ETags. Nothing depends on how Davis lays out its URLs, and the app key is the only part another server would not have. -- The first search fills the copy. -- After that, at most every five minutes and once the response has been sent, one `PROPFIND` reads each address book's sync token. An address book whose token changed is fetched again in full with `addressbook-query`. -- If Davis is down or refuses the key, the copy stays as it was. +### The copy + +Each CardDAV request costs about 170 ms, because Davis checks a bcrypt hash every time. So neither autocomplete nor the address book asks Davis to show something. They read a copy kept in SQLite: one row per card, with the whole vCard, and one row per email address for autocomplete. + +- The first use fills the copy, with one `addressbook-query` per address book. +- After that, when the address book is opened, and otherwise at most every five minutes once the response has been sent, one `PROPFIND` reads each address book's sync token. For a book whose token changed, one `addressbook-query` reads the ETag of every card, and one `addressbook-multiget` fetches only the cards whose ETag differs. Cards that are gone are dropped. +- If Davis is down or refuses the key, the copy stays as it was, and the address book says it shows the last copy. + +Cards and books have ids made from the user and the href (a hash), stable across a rebuild of the copy. The browser never sends an href: every URL asked of Davis comes from the copy, looked up by id and user. + +### Changes + +A change goes to Davis first, then to the copy: + +- The browser sends the ETag of the card the user was looking at. The change is made on the copy's vCard, only if the copy is at that version, and sent with `If-Match`. Davis refuses it (`412`) if the card moved on in the meantime. +- A refused change reads that one card again, so what the user sees next is the card as it is. The editor then moves the user's edits onto it (`lib/rebase.ts`) rather than dropping them or writing over the other change. +- When Davis answers without an ETag, it stored something else than what was sent: the card is read back once. +- A merge writes the card that stays, then deletes the other. If it stops half way there is a card too many, never one too few. +- A `.vcf` import is cut into cards in the browser and sent a few at a time, since each card is one request to Davis. + +### vCards + +Cards are read and edited with `sabre/vobject` (`CardDocument`), in vCard 3.0 or 4.0. Only what the address book shows is touched. Every line nobody edited is written back exactly as it was read, in its place: vobject alone would change the escaping of some values it does not know, and upper-case Apple's `item1.` groups. + +Emails, phones, addresses and links carry a `key`, their position among the lines of the same name, so an edit lands on the line it came from and keeps its other parameters, its group and its label. + +Pictures are cropped in the browser, then decoded and written again as a 512 px JPEG by the backend, which never stores the bytes it was sent. ## Displaying mail @@ -53,7 +77,7 @@ Message HTML goes through `symfony/html-sanitizer` on the backend, then into a s ## Frontend -The interface lives in `frontend/src/components`, one component per part of the screen. None of them fetches anything: they take props and call back. `MailApp.tsx` is the only container. It reads data through the `MailHooks` contract (`hooks.ts`), implemented on top of the API in `api/mailHooks.ts`, which also holds the optimistic updates and undo. +The interface lives in `frontend/src/components`, one component per part of the screen. None of them fetches anything: they take props and call back. `MailApp.tsx` is the container for mail, and `ContactsApp.tsx`, loaded when first opened, the one for the address book. They read data through the `MailHooks` contract (`hooks.ts`), implemented on top of the API in `api/mailHooks.ts` and `api/contactsHooks.ts`, which also hold the optimistic updates and undo. The hooks hide two things from the components: diff --git a/frontend/src/ContactsApp.tsx b/frontend/src/ContactsApp.tsx new file mode 100644 index 0000000..575968f --- /dev/null +++ b/frontend/src/ContactsApp.tsx @@ -0,0 +1,525 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Button, Group, Modal, Select, Stack, Text } from '@mantine/core' +import { useLocalStorage } from '@mantine/hooks' +import { notifications } from '@mantine/notifications' +import { CheckIcon, CircleAlertIcon, UploadIcon } from 'lucide-react' +import { ContactError, useMailHooks, type ContactUrls } from './hooks' +import type { Addr, ContactCard, ContactInput, ContactSummary, Density, Undo } from './types' +import { matchesFilter, secondLine } from './lib/addressBook' +import { findDuplicates } from './lib/duplicates' +import { useI18n } from './lib/i18n' +import { useMailShortcuts } from './lib/shortcuts' +import type { Layout } from './components/AppShell' +import { SplitPanes } from './components/SplitPanes' +import { ContactList } from './components/contacts/ContactList' +import { ContactDetail } from './components/contacts/ContactDetail' +import { MergeDialog } from './components/contacts/MergeDialog' +import { + ContactEditor, + type ContactSave, + type SaveOutcome, +} from './components/contacts/ContactEditor' + +export type ContactsAppProps = { + layout: Layout + density: Density + urls: ContactUrls + /** The open contact lives in the address bar, so MailApp owns it. */ + contactId: string | null + onContactChange: (id: string | null) => void + /** Back to mail */ + onClose: () => void + /** Undefined while nothing can be sent */ + onCompose?: (to?: Addr) => void + onShowMessages: (email: string) => void + onGoInbox: () => void + onShowShortcuts: () => void + /** False while a dialog or the composer has the keyboard */ + shortcutsEnabled: boolean + /** The backend answers */ + reachable: boolean + /** Someone from mail to add: the editor opens with their name and address. */ + newContact?: Addr | null + onNewContactTaken?: () => void +} + +type Editing = + { kind: 'edit'; id: string } | { kind: 'new'; key: number; prefill?: Partial } + +const problemOf = (error: unknown) => (error instanceof ContactError ? error.problem : 'failed') + +/** + * The address book: the list of cards on the left, the open card (or the editor) on the right. + * Lazy-loaded with its components; MailApp only knows the view and the open card's id. + */ +export function ContactsApp({ + layout, + density, + urls, + contactId, + onContactChange, + onClose, + onCompose, + onShowMessages, + onGoInbox, + onShowShortcuts, + shortcutsEnabled, + reachable, + newContact, + onNewContactTaken, +}: ContactsAppProps) { + const { t } = useI18n() + const hooks = useMailHooks() + const actions = hooks.useContactActions() + const bookQuery = hooks.useAddressBook(true) + const cardQuery = hooks.useContactCard(contactId) + const isMobile = layout === 'mobile' + + const [listWidth, setListWidth] = useLocalStorage({ + key: 'postel.contacts-list-width', + defaultValue: 340, + }) + // Which address book the user looks at, remembered by this browser + const [storedBook, setStoredBook] = useLocalStorage({ + key: 'postel.contacts-book', + defaultValue: 'all', + }) + const [filter, setFilter] = useState('') + const filterRef = useRef(null) + const [mobileDetail, setMobileDetail] = useState(contactId !== null) + const [editing, setEditing] = useState(null) + const newKey = useRef(0) + const [importBook, setImportBook] = useState(null) + const [askBook, setAskBook] = useState(false) + const fileRef = useRef(null) + + const data = bookQuery.data + const books = useMemo(() => data?.books ?? [], [data]) + // A book that is gone, or a single book: show everything + const bookId = books.length > 1 && books.some((b) => b.id === storedBook) ? storedBook : 'all' + const inBook = useMemo( + () => (data?.cards ?? []).filter((c) => bookId === 'all' || c.book === bookId), + [data, bookId], + ) + const cards = useMemo(() => inBook.filter((c) => matchesFilter(c, filter)), [inBook, filter]) + // New cards and imports go to the picked book, else the first one + const targetBook = bookId === 'all' ? (books[0]?.id ?? '') : bookId + + // On open: show the copy at once, and ask the backend to look for changes behind it + const refreshed = useRef(false) + useEffect(() => { + if (refreshed.current) return + refreshed.current = true + actions.refresh().catch(() => undefined) + }, [actions]) + + const card = cardQuery.data + + // The open card may be on file twice: the other cards that look like the same person + const [mergeWith, setMergeWith] = useState(null) + const [mergeProblem, setMergeProblem] = useState(null) + const otherQuery = hooks.useContactCard(mergeWith) + const duplicateIds = useMemo(() => findDuplicates(data?.cards ?? []), [data]) + const duplicates = useMemo(() => { + const ids = card ? (duplicateIds.get(card.id) ?? []) : [] + return (data?.cards ?? []) + .filter((c) => ids.includes(c.id)) + .map((c) => ({ id: c.id, name: c.name, detail: secondLine(c) })) + }, [card, duplicateIds, data]) + + const photoUrl = (c: Pick) => + c.photo ? urls.photo(c) : undefined + + /* ---------- feedback ---------- */ + const notifyDone = useCallback( + (message: string, undo?: Undo) => { + const id = notifications.show({ + message: ( + + {message} + {undo && ( + + )} + + ), + icon: , + color: 'gray', + autoClose: undo ? 6000 : 3000, + }) + }, + [t], + ) + const notifyProblem = (error: unknown) => + notifications.show({ + color: 'red', + icon: , + message: problemOf(error) === 'unavailable' ? t.contact.unreachableNothing : t.actionFailed, + }) + + /* ---------- navigation ---------- */ + const open = (id: string) => { + onContactChange(id) + if (isMobile) setMobileDetail(true) + } + + const step = (delta: 1 | -1) => { + if (!cards.length) return + const idx = contactId === null ? -1 : cards.findIndex((c) => c.id === contactId) + const next = cards[Math.min(cards.length - 1, Math.max(0, idx + delta))] + if (next) onContactChange(next.id) + } + + const write = (email?: string) => { + if (!onCompose) return + onCompose(email ? { email, name: card?.name } : undefined) + } + + /* ---------- writes ---------- */ + const canWrite = reachable && books.length > 0 + + const startNew = (prefill?: Partial) => { + newKey.current += 1 + setEditing({ kind: 'new', key: newKey.current, prefill }) + if (isMobile) setMobileDetail(true) + } + + const startEdit = () => { + if (card) setEditing({ kind: 'edit', id: card.id }) + } + + const save = async ({ + book, + input, + fieldsChanged, + photo, + etag, + }: ContactSave): Promise => { + let saved: ContactCard | undefined + try { + // Fields first, then the picture, each with the ETag the previous answer gave + if (editing?.kind === 'new') saved = await actions.create(book, input) + else if (card && fieldsChanged) + saved = await actions.update(card.id, etag ?? card.etag, input) + const target = saved ?? card + if (target && photo) { + const tag = saved?.etag ?? etag ?? target.etag + saved = + photo === 'remove' + ? await actions.removePhoto(target.id, tag) + : await actions.setPhoto(target.id, tag, photo) + } + } catch (error) { + const problem = problemOf(error) + if (editing?.kind === 'new' && saved) { + // The card exists, only its picture failed: open it rather than create it twice + setEditing(null) + open(saved.id) + notifications.show({ + color: 'red', + icon: , + message: t.contact.saveFailed, + }) + return { ok: true } + } + if (problem === 'changed' && card) { + // Someone else's version is the base from now on; the user's input stays in the form + const fresh = await cardQuery.refetch() + return { ok: false, problem, card: fresh.data } + } + // The fields may have gone through before the picture failed + return { ok: false, problem, card: saved } + } + setEditing(null) + if (saved) { + open(saved.id) + notifyDone(editing?.kind === 'new' ? t.contact.created(saved.name) : t.contact.saved) + } + return { ok: true } + } + + const remove = async () => { + if (!card) return + const idx = cards.findIndex((c) => c.id === card.id) + const next = cards[idx + 1] ?? cards[idx - 1] + try { + const undo = await actions.remove(card) + onContactChange(next && next.id !== card.id ? next.id : null) + if (isMobile) setMobileDetail(false) + notifyDone(t.contact.deleted(card.name), undo) + } catch (error) { + notifyProblem(error) + } + } + + const closeMerge = () => { + setMergeWith(null) + setMergeProblem(null) + } + + const merge = async (keep: ContactCard, other: ContactCard) => { + setMergeProblem(null) + try { + const merged = await actions.merge(keep, other) + closeMerge() + onContactChange(merged.id) + notifyDone(t.contact.merged(merged.name)) + } catch (error) { + const problem = problemOf(error) + // The dialog stays open on the cards as they are now + await Promise.all([cardQuery.refetch(), otherQuery.refetch()]) + setMergeProblem( + problem === 'changed' + ? t.contact.mergeChanged + : problem === 'unavailable' + ? t.contact.unreachable + : t.contact.mergeFailed, + ) + } + } + + const importInto = async (book: string, file: File) => { + const id = notifications.show({ + loading: true, + message: t.contact.importing, + autoClose: false, + withCloseButton: false, + }) + try { + const { imported, failed } = await actions.importFile(book, file, (done, total) => + notifications.update({ + id, + loading: true, + message: t.contact.importingProgress(done, total), + }), + ) + notifications.update({ + id, + loading: false, + color: failed ? 'yellow' : 'gray', + icon: failed ? : , + message: failed + ? `${t.contact.imported(imported)}. ${t.contact.importFailed(failed)}` + : t.contact.imported(imported), + autoClose: 6000, + withCloseButton: true, + }) + } catch (error) { + notifications.update({ + id, + loading: false, + color: 'red', + icon: , + message: problemOf(error) === 'unavailable' ? t.contact.unreachableNothing : t.actionFailed, + autoClose: 6000, + withCloseButton: true, + }) + } + } + + const chooseImport = () => { + // With several books and none picked, ask which one first + if (books.length > 1 && bookId === 'all') { + setImportBook(books[0]!.id) + setAskBook(true) + } else { + setImportBook(targetBook) + fileRef.current?.click() + } + } + + // A sender from mail: a new card with what the message said about them + useEffect(() => { + if (!newContact) return + const [first = '', ...rest] = (newContact.name ?? '').trim().split(/\s+/) + newKey.current += 1 + setEditing({ + kind: 'new', + key: newKey.current, + prefill: { + firstName: first, + lastName: rest.join(' '), + emails: [{ value: newContact.email, type: 'home' }], + }, + }) + if (isMobile) setMobileDetail(true) + onNewContactTaken?.() + }, [newContact, onNewContactTaken, isMobile]) + + useMailShortcuts( + { + next: () => step(1), + prev: () => step(-1), + open: () => contactId !== null && open(contactId), + close: () => { + if (isMobile && mobileDetail) setMobileDetail(false) + else onClose() + }, + search: () => filterRef.current?.focus(), + compose: () => write(card?.emails[0]?.value), + editContact: canWrite ? startEdit : undefined, + delete: canWrite ? () => void remove() : undefined, + goInbox: onGoInbox, + help: onShowShortcuts, + }, + // The editor and the merge dialog have their own keys + shortcutsEnabled && editing === null && mergeWith === null, + ) + + const downloadUrl = bookId === 'all' ? urls.allVcf() : urls.bookVcf(bookId) + const bookName = books.length > 1 ? books.find((b) => b.id === card?.book)?.name : undefined + const problem = cardQuery.error instanceof ContactError ? cardQuery.error.problem : undefined + + const list = ( + void bookQuery.refetch()} + unreachable={!reachable} + stale={data ? !data.available : false} + books={books} + bookId={bookId} + onBookChange={setStoredBook} + filter={filter} + onFilterChange={setFilter} + filterRef={filterRef} + activeId={contactId} + density={density} + isMobile={isMobile} + photoUrl={photoUrl} + downloadUrl={downloadUrl} + onNew={canWrite ? () => startNew() : undefined} + onImport={canWrite ? chooseImport : undefined} + onRefresh={() => actions.refresh().catch(() => undefined)} + onActivate={(id) => { + if (editing === null) onContactChange(id) + }} + onOpen={(id) => { + if (editing === null) open(id) + }} + onBack={isMobile ? onClose : undefined} + /> + ) + + let detail + if (editing && (editing.kind === 'new' || card?.id === editing.id)) + detail = ( + { + setEditing(null) + if (isMobile && editing.kind === 'new') setMobileDetail(false) + }} + /> + ) + else + detail = ( + void cardQuery.refetch()} + isMobile={isMobile} + photoUrl={card ? photoUrl(card) : undefined} + vcfUrl={contactId ? urls.cardVcf(contactId) : '#'} + bookName={bookName} + onBack={() => setMobileDetail(false)} + onWrite={onCompose ? write : undefined} + onShowMessages={onShowMessages} + onEdit={canWrite ? startEdit : undefined} + onDelete={canWrite ? () => void remove() : undefined} + duplicates={duplicates} + onMerge={canWrite ? setMergeWith : undefined} + /> + ) + + const showDetail = editing !== null || (mobileDetail && contactId !== null) + + return ( + <> + + + + + { + const file = e.currentTarget.files?.[0] + e.currentTarget.value = '' + if (file && importBook) void importInto(importBook, file) + }} + /> + + setAskBook(false)} + title={t.contact.importTitle} + centered + size="sm" + > + + type && update(i, { type: type as ContactType })} + /> + )} + set({ [kind]: list.filter((_, j) => j !== i) })} + /> + + ))} + set({ [kind]: [...list, { value: '', type: blankType }] })}> + {add} + + + ) + } + + const updateAddress = (i: number, patch: Partial) => + set({ addresses: draft.addresses.map((a, j) => (j === i ? { ...a, ...patch } : a)) }) + + const problemText = + problem === 'changed' + ? t.contact.changed + : problem === 'unavailable' + ? t.contact.unreachable + : problem === 'badImage' + ? t.contact.badImage + : problem + ? t.contact.saveFailed + : null + + const title = card ? t.contact.editTitle : t.contact.newContact + const avatarName = + [draft.firstName, draft.lastName].filter((v) => v.trim()).join(' ') || draft.org + + return ( + +
+ + {isMobile && ( + + + + )} + + {title} + + {!isMobile && ( + + )} + + {t.contact.save} + {keyLabel('mod')} + {keyLabel('Enter')} + + } + disabled={isMobile} + > + + + +
+ + + + {problemText && ( + } + role="alert" + radius="md" + > + {problemText} + + )} + + {!card && books.length > 1 && ( + type && updateAddress(i, { type: type as ContactType })} + /> + set({ addresses: draft.addresses.filter((_, j) => j !== i) })} + /> + +