2020-02-10 10:04:38 -05:00
< ? php
declare ( strict_types = 1 );
/**
* @ copyright Copyright ( c ) 2020 , Thomas Citharel < nextcloud @ tcit . fr >
2020-09-20 01:47:30 -04:00
* @ copyright Copyright ( c ) 2020 , leith abdulla ( < online - nextcloud @ eleith . com > )
2020-02-10 10:04:38 -05:00
*
2020-04-29 05:57:22 -04:00
* @ author Christoph Wurst < christoph @ winzerhof - wurst . at >
2020-12-16 08:54:15 -05:00
* @ author eleith < online + github @ eleith . com >
2020-02-10 10:04:38 -05:00
* @ author Georg Ehrke < oc . list @ georgehrke . com >
2020-04-29 05:57:22 -04:00
* @ author Joas Schilling < coding @ schilljs . com >
2020-02-10 10:04:38 -05:00
* @ author Thomas Citharel < nextcloud @ tcit . fr >
*
* @ license GNU AGPL version 3 or any later version
*
* This program is free software : you can redistribute it and / or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation , either version 3 of the
* License , or ( at your option ) any later version .
*
* This program is distributed in the hope that it will be useful ,
* but WITHOUT ANY WARRANTY ; without even the implied warranty of
2021-06-04 15:52:51 -04:00
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE . See the
2020-02-10 10:04:38 -05:00
* GNU Affero General Public License for more details .
*
* You should have received a copy of the GNU Affero General Public License
* along with this program . If not , see < http :// www . gnu . org / licenses />.
*
*/
namespace OCA\DAV\CalDAV\WebcalCaching ;
use Exception ;
use GuzzleHttp\HandlerStack ;
use GuzzleHttp\Middleware ;
use OCA\DAV\CalDAV\CalDavBackend ;
use OCP\Http\Client\IClientService ;
2020-03-24 09:19:57 -04:00
use OCP\Http\Client\LocalServerException ;
2020-02-10 10:04:38 -05:00
use OCP\IConfig ;
use Psr\Http\Message\RequestInterface ;
use Psr\Http\Message\ResponseInterface ;
2022-03-31 09:34:57 -04:00
use Psr\Log\LoggerInterface ;
2020-02-10 10:04:38 -05:00
use Sabre\DAV\Exception\BadRequest ;
use Sabre\DAV\PropPatch ;
use Sabre\DAV\Xml\Property\Href ;
use Sabre\VObject\Component ;
use Sabre\VObject\DateTimeParser ;
use Sabre\VObject\InvalidDataException ;
2020-09-20 01:47:30 -04:00
use Sabre\VObject\Recur\NoInstancesException ;
2020-02-10 10:04:38 -05:00
use Sabre\VObject\ParseException ;
use Sabre\VObject\Reader ;
use Sabre\VObject\Splitter\ICalendar ;
2020-03-16 10:04:21 -04:00
use Sabre\VObject\UUIDUtil ;
2020-02-10 10:04:38 -05:00
use function count ;
class RefreshWebcalService {
2022-03-18 13:07:24 -04:00
private CalDavBackend $calDavBackend ;
2020-02-10 10:04:38 -05:00
2022-03-18 13:07:24 -04:00
private IClientService $clientService ;
2020-02-10 10:04:38 -05:00
2022-03-18 13:07:24 -04:00
private IConfig $config ;
2020-02-10 10:04:38 -05:00
2022-03-18 13:07:24 -04:00
/** @var LoggerInterface */
2022-03-31 09:34:57 -04:00
private LoggerInterface $logger ;
2020-02-10 10:04:38 -05:00
public const REFRESH_RATE = '{http://apple.com/ns/ical/}refreshrate' ;
public const STRIP_ALARMS = '{http://calendarserver.org/ns/}subscribed-strip-alarms' ;
public const STRIP_ATTACHMENTS = '{http://calendarserver.org/ns/}subscribed-strip-attachments' ;
public const STRIP_TODOS = '{http://calendarserver.org/ns/}subscribed-strip-todos' ;
2022-03-18 13:07:24 -04:00
public function __construct ( CalDavBackend $calDavBackend , IClientService $clientService , IConfig $config , LoggerInterface $logger ) {
2020-02-10 10:04:38 -05:00
$this -> calDavBackend = $calDavBackend ;
$this -> clientService = $clientService ;
$this -> config = $config ;
$this -> logger = $logger ;
}
public function refreshSubscription ( string $principalUri , string $uri ) {
$subscription = $this -> getSubscription ( $principalUri , $uri );
$mutations = [];
if ( ! $subscription ) {
return ;
}
$webcalData = $this -> queryWebcalFeed ( $subscription , $mutations );
if ( ! $webcalData ) {
return ;
}
$stripTodos = ( $subscription [ self :: STRIP_TODOS ] ? ? 1 ) === 1 ;
$stripAlarms = ( $subscription [ self :: STRIP_ALARMS ] ? ? 1 ) === 1 ;
$stripAttachments = ( $subscription [ self :: STRIP_ATTACHMENTS ] ? ? 1 ) === 1 ;
try {
$splitter = new ICalendar ( $webcalData , Reader :: OPTION_FORGIVING );
// we wait with deleting all outdated events till we parsed the new ones
// in case the new calendar is broken and `new ICalendar` throws a ParseException
// the user will still see the old data
$this -> calDavBackend -> purgeAllCachedEventsForSubscription ( $subscription [ 'id' ]);
while ( $vObject = $splitter -> getNext ()) {
/** @var Component $vObject */
$compName = null ;
foreach ( $vObject -> getComponents () as $component ) {
if ( $component -> name === 'VTIMEZONE' ) {
continue ;
}
$compName = $component -> name ;
if ( $stripAlarms ) {
unset ( $component -> { 'VALARM' });
}
if ( $stripAttachments ) {
unset ( $component -> { 'ATTACH' });
}
}
if ( $stripTodos && $compName === 'VTODO' ) {
continue ;
}
2022-03-18 13:07:24 -04:00
$objectUri = $this -> getRandomCalendarObjectUri ();
2020-02-10 10:04:38 -05:00
$calendarData = $vObject -> serialize ();
try {
2022-03-18 13:07:24 -04:00
$this -> calDavBackend -> createCalendarObject ( $subscription [ 'id' ], $objectUri , $calendarData , CalDavBackend :: CALENDAR_TYPE_SUBSCRIPTION );
2020-09-20 01:47:30 -04:00
} catch ( NoInstancesException | BadRequest $ex ) {
2022-03-18 13:07:24 -04:00
$this -> logger -> error ( 'Unable to create calendar object from subscription {subscriptionId}' , [ 'exception' => $ex , 'subscriptionId' => $subscription [ 'id' ], 'source' => $subscription [ 'source' ]]);
2020-02-10 10:04:38 -05:00
}
}
$newRefreshRate = $this -> checkWebcalDataForRefreshRate ( $subscription , $webcalData );
if ( $newRefreshRate ) {
$mutations [ self :: REFRESH_RATE ] = $newRefreshRate ;
}
$this -> updateSubscription ( $subscription , $mutations );
2020-04-10 08:19:56 -04:00
} catch ( ParseException $ex ) {
2022-03-18 13:07:24 -04:00
$this -> logger -> error ( " Subscription { subscriptionId} could not be refreshed due to a parsing error " , [ 'exception' => $ex , 'subscriptionId' => $subscription [ 'id' ]]);
2020-02-10 10:04:38 -05:00
}
}
/**
* loads subscription from backend
*/
2022-03-18 13:07:24 -04:00
public function getSubscription ( string $principalUri , string $uri ) : ? array {
2020-02-10 10:04:38 -05:00
$subscriptions = array_values ( array_filter (
$this -> calDavBackend -> getSubscriptionsForUser ( $principalUri ),
2020-04-09 07:53:40 -04:00
function ( $sub ) use ( $uri ) {
2020-02-10 10:04:38 -05:00
return $sub [ 'uri' ] === $uri ;
}
));
if ( count ( $subscriptions ) === 0 ) {
return null ;
}
return $subscriptions [ 0 ];
}
/**
* gets webcal feed from remote server
*/
2022-03-18 13:07:24 -04:00
private function queryWebcalFeed ( array $subscription , array & $mutations ) : ? string {
2020-02-10 10:04:38 -05:00
$client = $this -> clientService -> newClient ();
$didBreak301Chain = false ;
$latestLocation = null ;
$handlerStack = HandlerStack :: create ();
$handlerStack -> push ( Middleware :: mapRequest ( function ( RequestInterface $request ) {
return $request
-> withHeader ( 'Accept' , 'text/calendar, application/calendar+json, application/calendar+xml' )
2022-05-20 10:23:40 -04:00
-> withHeader ( 'User-Agent' , 'Nextcloud Webcal Service' );
2020-02-10 10:04:38 -05:00
}));
2020-04-09 07:53:40 -04:00
$handlerStack -> push ( Middleware :: mapResponse ( function ( ResponseInterface $response ) use ( & $didBreak301Chain , & $latestLocation ) {
2020-02-10 10:04:38 -05:00
if ( ! $didBreak301Chain ) {
if ( $response -> getStatusCode () !== 301 ) {
$didBreak301Chain = true ;
} else {
$latestLocation = $response -> getHeader ( 'Location' );
}
}
return $response ;
}));
$allowLocalAccess = $this -> config -> getAppValue ( 'dav' , 'webcalAllowLocalAccess' , 'no' );
$subscriptionId = $subscription [ 'id' ];
$url = $this -> cleanURL ( $subscription [ 'source' ]);
if ( $url === null ) {
return null ;
}
try {
$params = [
'allow_redirects' => [
'redirects' => 10
],
'handler' => $handlerStack ,
2020-03-24 09:19:57 -04:00
'nextcloud' => [
'allow_local_address' => $allowLocalAccess === 'yes' ,
]
2020-02-10 10:04:38 -05:00
];
$user = parse_url ( $subscription [ 'source' ], PHP_URL_USER );
$pass = parse_url ( $subscription [ 'source' ], PHP_URL_PASS );
if ( $user !== null && $pass !== null ) {
$params [ 'auth' ] = [ $user , $pass ];
}
$response = $client -> get ( $url , $params );
$body = $response -> getBody ();
if ( $latestLocation ) {
$mutations [ '{http://calendarserver.org/ns/}source' ] = new Href ( $latestLocation );
}
$contentType = $response -> getHeader ( 'Content-Type' );
$contentType = explode ( ';' , $contentType , 2 )[ 0 ];
2020-04-10 08:19:56 -04:00
switch ( $contentType ) {
2020-02-10 10:04:38 -05:00
case 'application/calendar+json' :
try {
$jCalendar = Reader :: readJson ( $body , Reader :: OPTION_FORGIVING );
2020-04-10 08:19:56 -04:00
} catch ( Exception $ex ) {
2020-02-10 10:04:38 -05:00
// In case of a parsing error return null
2022-03-18 13:07:24 -04:00
$this -> logger -> warning ( " Subscription $subscriptionId could not be parsed " , [ 'exception' => $ex ]);
2020-02-10 10:04:38 -05:00
return null ;
}
return $jCalendar -> serialize ();
case 'application/calendar+xml' :
try {
$xCalendar = Reader :: readXML ( $body );
2020-04-10 08:19:56 -04:00
} catch ( Exception $ex ) {
2020-02-10 10:04:38 -05:00
// In case of a parsing error return null
2022-03-18 13:07:24 -04:00
$this -> logger -> warning ( " Subscription $subscriptionId could not be parsed " , [ 'exception' => $ex ]);
2020-02-10 10:04:38 -05:00
return null ;
}
return $xCalendar -> serialize ();
case 'text/calendar' :
default :
try {
$vCalendar = Reader :: read ( $body );
2020-04-10 08:19:56 -04:00
} catch ( Exception $ex ) {
2020-02-10 10:04:38 -05:00
// In case of a parsing error return null
2022-03-18 13:07:24 -04:00
$this -> logger -> warning ( " Subscription $subscriptionId could not be parsed " , [ 'exception' => $ex ]);
2020-02-10 10:04:38 -05:00
return null ;
}
return $vCalendar -> serialize ();
}
2020-03-24 09:19:57 -04:00
} catch ( LocalServerException $ex ) {
2022-03-31 09:34:57 -04:00
$this -> logger -> warning ( " Subscription $subscriptionId was not refreshed because it violates local access rules " , [
'exception' => $ex ,
2020-03-24 09:19:57 -04:00
]);
return null ;
2020-04-10 08:19:56 -04:00
} catch ( Exception $ex ) {
2022-03-31 09:34:57 -04:00
$this -> logger -> warning ( " Subscription $subscriptionId could not be refreshed due to a network error " , [
'exception' => $ex ,
2020-03-24 09:19:57 -04:00
]);
2020-02-10 10:04:38 -05:00
return null ;
}
}
/**
* check if :
* - current subscription stores a refreshrate
* - the webcal feed suggests a refreshrate
* - return suggested refreshrate if user didn ' t set a custom one
*
*/
2022-03-18 13:07:24 -04:00
private function checkWebcalDataForRefreshRate ( array $subscription , string $webcalData ) : ? string {
2020-02-10 10:04:38 -05:00
// if there is no refreshrate stored in the database, check the webcal feed
// whether it suggests any refresh rate and store that in the database
if ( isset ( $subscription [ self :: REFRESH_RATE ]) && $subscription [ self :: REFRESH_RATE ] !== null ) {
return null ;
}
/** @var Component\VCalendar $vCalendar */
$vCalendar = Reader :: read ( $webcalData );
$newRefreshRate = null ;
if ( isset ( $vCalendar -> { 'X-PUBLISHED-TTL' })) {
$newRefreshRate = $vCalendar -> { 'X-PUBLISHED-TTL' } -> getValue ();
}
if ( isset ( $vCalendar -> { 'REFRESH-INTERVAL' })) {
$newRefreshRate = $vCalendar -> { 'REFRESH-INTERVAL' } -> getValue ();
}
if ( ! $newRefreshRate ) {
return null ;
}
// check if new refresh rate is even valid
try {
DateTimeParser :: parseDuration ( $newRefreshRate );
2020-04-10 08:19:56 -04:00
} catch ( InvalidDataException $ex ) {
2020-02-10 10:04:38 -05:00
return null ;
}
return $newRefreshRate ;
}
/**
* update subscription stored in database
* used to set :
* - refreshrate
* - source
*
* @ param array $subscription
* @ param array $mutations
*/
private function updateSubscription ( array $subscription , array $mutations ) {
if ( empty ( $mutations )) {
return ;
}
$propPatch = new PropPatch ( $mutations );
$this -> calDavBackend -> updateSubscription ( $subscription [ 'id' ], $propPatch );
$propPatch -> commit ();
}
/**
* This method will strip authentication information and replace the
* 'webcal' or 'webcals' protocol scheme
*
* @ param string $url
* @ return string | null
*/
2022-03-18 13:07:24 -04:00
private function cleanURL ( string $url ) : ? string {
2020-02-10 10:04:38 -05:00
$parsed = parse_url ( $url );
if ( $parsed === false ) {
return null ;
}
if ( isset ( $parsed [ 'scheme' ]) && $parsed [ 'scheme' ] === 'http' ) {
$scheme = 'http' ;
} else {
$scheme = 'https' ;
}
$host = $parsed [ 'host' ] ? ? '' ;
$port = isset ( $parsed [ 'port' ]) ? ':' . $parsed [ 'port' ] : '' ;
$path = $parsed [ 'path' ] ? ? '' ;
$query = isset ( $parsed [ 'query' ]) ? '?' . $parsed [ 'query' ] : '' ;
$fragment = isset ( $parsed [ 'fragment' ]) ? '#' . $parsed [ 'fragment' ] : '' ;
$cleanURL = " $scheme :// $host $port $path $query $fragment " ;
// parse_url is giving some weird results if no url and no :// is given,
// so let's test the url again
$parsedClean = parse_url ( $cleanURL );
if ( $parsedClean === false || ! isset ( $parsedClean [ 'host' ])) {
return null ;
}
return $cleanURL ;
}
2020-03-16 10:04:21 -04:00
/**
* Returns a random uri for a calendar - object
*
* @ return string
*/
public function getRandomCalendarObjectUri () : string {
return UUIDUtil :: getUUID () . '.ics' ;
}
2020-02-10 10:04:38 -05:00
}