/
DATKAI
/
ITCUP
Обзор
Документация
Войти
/
DATKAI
/
ITCUP
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
app/Http/Controllers/CardDavController.php
207 строк
8 KB
DATKAI
feat(comms): адресная книга, структура компании, CardDAV-синхронизация
03 июл 2026, 08:10
03 июл 2026, 08:10
db69ed1
Код
Авторство
О чём код?
<?php namespace App\Http\Controllers; use App\Models\User; use App\Services\VCardService; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Hash; /** * Минимальный read-only CardDAV сервер для синхронизации адресной книги * с Thunderbird / iOS / Android (DAVx5) и другими клиентами. * * Точка подключения: https://<host>/carddav/ * Авторизация: HTTP Basic (email + пароль ITCUP). */ class CardDavController extends Controller { public function __construct(protected VCardService $vcards) { } public function handle(Request $request, string $path = ''): Response { $user = $this->authenticate($request); if (!$user) { return response('Unauthorized', 401, [ 'WWW-Authenticate' => 'Basic realm="ITCUP Address Book"', ]); } $path = trim($path, '/'); $method = strtoupper($request->getMethod()); return match (true) { $method === 'OPTIONS' => $this->options(), $method === 'GET' && $path === 'export.vcf' => $this->exportAll(), $method === 'GET' && preg_match('/^books\/company\/([uc]\d+)\.vcf$/', $path, $m) => $this->getCard($m[1]), $method === 'PROPFIND' => $this->propfind($request, $path), $method === 'REPORT' && $path === 'books/company' => $this->report($request), default => response('Not Found', 404), }; } protected function authenticate(Request $request): ?User { $email = $request->getUser(); $password = $request->getPassword(); if (!$email || !$password) { return null; } $user = User::where('email', $email)->first(); if (!$user || $user->employment_status === 'dismissed' || !Hash::check($password, $user->password)) { return null; } return $user; } protected function options(): Response { return response('', 200, [ 'DAV' => '1, 3, addressbook', 'Allow' => 'OPTIONS, GET, PROPFIND, REPORT', ]); } /** Экспорт всей книги одним .vcf (ручной импорт в любой клиент) */ protected function exportAll(): Response { $body = implode('', array_column($this->vcards->all(), 'vcard')); return response($body, 200, [ 'Content-Type' => 'text/vcard; charset=utf-8', 'Content-Disposition' => 'attachment; filename="itcup-addressbook.vcf"', ]); } protected function getCard(string $key): Response { $items = $this->vcards->all(); if (!isset($items[$key])) { return response('Not Found', 404); } return response($items[$key]['vcard'], 200, [ 'Content-Type' => 'text/vcard; charset=utf-8', 'ETag' => '"' . $items[$key]['etag'] . '"', ]); } protected function propfind(Request $request, string $path): Response { $depth = $request->header('Depth', '0'); $base = '/carddav'; // Discovery: корень и principal if ($path === '' || $path === 'principal') { $href = $path === '' ? "{$base}/" : "{$base}/principal/"; return $this->multistatus([ $this->davResponse($href, [ '<d:resourcetype><d:collection/></d:resourcetype>', "<d:current-user-principal><d:href>{$base}/principal/</d:href></d:current-user-principal>", "<card:addressbook-home-set><d:href>{$base}/books/</d:href></card:addressbook-home-set>", '<d:displayname>ITCUP</d:displayname>', ]), ]); } // Домашняя коллекция книг if ($path === 'books') { $responses = [ $this->davResponse("{$base}/books/", [ '<d:resourcetype><d:collection/></d:resourcetype>', '<d:displayname>Адресные книги</d:displayname>', ]), ]; if ($depth !== '0') { $responses[] = $this->bookResponse($base); } return $this->multistatus($responses); } // Сама адресная книга if ($path === 'books/company') { $responses = [$this->bookResponse($base)]; if ($depth !== '0') { foreach ($this->vcards->all() as $key => $item) { $responses[] = $this->davResponse("{$base}/books/company/{$key}.vcf", [ '<d:resourcetype/>', '<d:getcontenttype>text/vcard; charset=utf-8</d:getcontenttype>', '<d:getetag>"' . $item['etag'] . '"</d:getetag>', ]); } } return $this->multistatus($responses); } return response('Not Found', 404); } protected function bookResponse(string $base): string { $ctag = $this->vcards->ctag(); return $this->davResponse("{$base}/books/company/", [ '<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>', '<d:displayname>ITCUP — сотрудники и контакты</d:displayname>', '<card:addressbook-description>Единая адресная книга компании</card:addressbook-description>', "<cs:getctag>{$ctag}</cs:getctag>", '<d:supported-report-set><d:supported-report><d:report><card:addressbook-multiget/></d:report></d:supported-report></d:supported-report-set>', ]); } protected function report(Request $request): Response { $items = $this->vcards->all(); $base = '/carddav'; $body = (string) $request->getContent(); $responses = []; // addressbook-multiget: вернуть запрошенные href'ы if (str_contains($body, 'addressbook-multiget')) { preg_match_all('/<[^>]*href[^>]*>([^<]+)<\//i', $body, $m); foreach ($m[1] ?? [] as $href) { if (preg_match('/([uc]\d+)\.vcf$/', $href, $mm) && isset($items[$mm[1]])) { $item = $items[$mm[1]]; $responses[] = $this->davResponse($href, [ '<d:getetag>"' . $item['etag'] . '"</d:getetag>', '<card:address-data>' . htmlspecialchars($item['vcard'], ENT_XML1) . '</card:address-data>', ]); } } } else { // addressbook-query без фильтра: вернуть всё foreach ($items as $key => $item) { $responses[] = $this->davResponse("{$base}/books/company/{$key}.vcf", [ '<d:getetag>"' . $item['etag'] . '"</d:getetag>', '<card:address-data>' . htmlspecialchars($item['vcard'], ENT_XML1) . '</card:address-data>', ]); } } return $this->multistatus($responses); } protected function davResponse(string $href, array $props): string { $propsXml = implode('', $props); return "<d:response><d:href>{$href}</d:href><d:propstat><d:prop>{$propsXml}</d:prop>" . '<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>'; } protected function multistatus(array $responses): Response { $xml = '<?xml version="1.0" encoding="utf-8"?>' . '<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">' . implode('', $responses) . '</d:multistatus>'; return response($xml, 207, ['Content-Type' => 'application/xml; charset=utf-8']); } }