diff --git a/.gitignore b/.gitignore index cf92027..9011ff2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ Thumbs.db .project .idea/ *.iml + +#ics temp file +out.ics diff --git a/app/classes/Framadate/Services/ICalService.php b/app/classes/Framadate/Services/ICalService.php new file mode 100644 index 0000000..59bfa34 --- /dev/null +++ b/app/classes/Framadate/Services/ICalService.php @@ -0,0 +1,191 @@ +logService = $logService; + $this->notificationService = $notificationService; + $this->sessionService = $sessionService; + $this->pollRepository = RepositoryFactory::pollRepository(); + $this->slotRepository = RepositoryFactory::slotRepository(); + $this->voteRepository = RepositoryFactory::voteRepository(); + $this->commentRepository = RepositoryFactory::commentRepository(); + } + + /** + * Creates an ical-File and initiates the download. If possible, the provided time is used, else an all day event is created. + */ + public function getEvent($poll, string $start_day, string $start_time) { + if(!$this->dayIsReadable($start_day)) { + return; + } + + $ical_text = ""; + $elements = explode("-", $start_time); + $end_time = null; + if(count($elements) === 2) { + $start_time = trim($elements[0]); + $end_time = trim($elements[1]); + } + $start_time = $this->reviseTimeString($start_time); + if($end_time !== null) { + $end_time = $this->reviseTimeString($end_time); + } + if($start_time !== null) { + if($end_time !== null) { + $ical_text = $this->getTimedEvent($poll, $start_day . " " . $start_time, $start_day . " " . $end_time); + } else { + $ical_text = $this->getTimedEvent1Hour($poll, $start_day . " " . $start_time); + } + } + else { + $date = DateTime::createFromFormat('d-m-Y', $start_day); + $day = $date->format('Ymd'); + $ical_text = $this->getAllDayEvent($poll, $day); + } + $this->provideFile($poll->title, $ical_text); + } + + /** + * Calls getTimedEvent with one hour as a time slot, starting at $start_daytime + */ + function getTimedEvent1Hour($poll, string $start_daytime) { + $end_daytime = date(DATE_ATOM, strtotime('+1 hours', strtotime($start_daytime))); + return $this->getTimedEvent($poll, $start_daytime, $end_daytime); + } + + /** + * Generates the text for an ical event including the time + */ + function getTimedEvent($poll, string $start_daytime, string $end_daytime) { + $vcalendar = new VObject\Component\VCalendar([ + 'VEVENT' => [ + 'SUMMARY' => $poll->title, + 'DESCRIPTION' => $this->stripMD($poll->description), + 'DTSTART' => new \DateTime($start_daytime), + 'DTEND' => new \DateTime($end_daytime) + ], + 'PRODID' => ICAL_PRODID + ]); + return $vcalendar->serialize(); + } + + /** + * Generates the text for an ical event if the time is not known + */ + function getAllDayEvent($poll, string $day) { + $vcalendar = new VObject\Component\VCalendar(); + $vevent = $vcalendar->add('VEVENT'); + $vevent->add('SUMMARY', $poll->title); + $vevent->add('DESCRIPTION', $this->stripMD($poll->description)); + $dtstart = $vevent->add('DTSTART', $day); + $dtstart['VALUE'] = 'DATE'; + unset($vcalendar->PRODID); + $vcalendar->add('PRODID', ICAL_PRODID); + return $vcalendar->serialize(); + } + + /** + * Creates a file and initiates the download + * @param string $ical_text + */ + function provideFile(string $title, string $ical_text) { + header('Content-Description: File Transfer'); + header('Content-Disposition: attachment; filename=' . $this->stripTitle($title) . ICAL_ENDING); + header('Expires: 0'); + header('Cache-Control: must-revalidate'); + header('Pragma: public'); + header("Content-Type: text/plain"); + echo $ical_text; + exit; + } + + /** + * Reformats a string value into a time readable by DateTime + * @param string $time + * @return string the corrected value, null if the format is unknown + */ + function reviseTimeString(string $time) { + // 24-hour clock / international format + if (preg_match('/^\d\d(:)\d\d$/', $time)) { + return $time; + } + // 12-hour clock / using am and pm + else if (preg_match('/^\d[0-2]?:?\d{0,2}\s?[aApP][mM]$/', $time)) { + return $this->formatTime($time); + } + // french format HHhMM or HHh + else if (preg_match('/^\d\d?[hH]\d?\d?$/', $time)) { + return $this->formatTime(str_pad(str_ireplace("H", ":", $time), 5, "0")); + } + // Number only + else if (preg_match('/^\d{1,4}$/', $time)) { + return $this->formatTime(str_pad(str_pad($time, 2, "0", STR_PAD_LEFT), 4, "0")); + } + return null; + } + + /** + * @param string $time + * @return 1 if the day string can be parsed, 0 if not and false if an error occured + */ + function dayIsReadable(string $day) { + return preg_match('/^\d{2}-\d{2}-\d{4}$/', $day); + } + + /** + * @param string $time + * @return string date string in format H:i (e.g. 19:00) + */ + function formatTime(string $time) { + return date("H:i", strtotime($time)); + } + + /** + * Converts MD Code to HTML, then strips HTML away + */ + function stripMD(string $string) { + return strip_tags(smarty_modifier_markdown($string)); + } + + /** + * Strips a string so it's usable as a file name (only digits, letters and underline allowed) + */ + function stripTitle(string $string) { + return preg_replace('/[^a-z0-9_]+/', '-', strtolower($string)); + } +} diff --git a/app/inc/constants.php b/app/inc/constants.php index 07f6a5f..3ac28e4 100644 --- a/app/inc/constants.php +++ b/app/inc/constants.php @@ -42,3 +42,6 @@ const SESSION_EDIT_LINK_TIME = "EditLinkMail"; // CSRF (300s = 5min) const TOKEN_TIME = 300; + +const ICAL_ENDING = ".ics"; +const ICAL_PRODID = "-//Framasoft//Framadate//EN"; diff --git a/composer.json b/composer.json index 0e31ad6..3389d25 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,8 @@ "ircmaxell/password-compat": "dev-master", "roave/security-advisories": "dev-master", "erusev/parsedown": "^1.7", - "egulias/email-validator": "~2.1" + "egulias/email-validator": "~2.1", + "sabre/vobject": "~4.1" }, "require-dev": { "phpunit/phpunit": "^9", diff --git a/composer.lock b/composer.lock index 5a9f0f9..5e05f19 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e7fb9e571fc5d84bc2637bf28453ebe0", + "content-hash": "09c4e4991b22cda619447a5746b01a97", "packages": [ { "name": "doctrine/lexer", @@ -239,7 +239,7 @@ "version": "dev-develop", "source": { "type": "git", - "url": "git@github.com:olivierperez/o80-i18n.git", + "url": "https://github.com/olivierperez/o80-i18n.git", "reference": "ef98bd7bd733d23729999ac148f79ea1d7b9008c" }, "dist": { @@ -669,6 +669,221 @@ ], "time": "2020-12-08T15:02:56+00:00" }, + { + "name": "sabre/uri", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/uri.git", + "reference": "f502edffafea8d746825bd5f0b923a60fd2715ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/uri/zipball/f502edffafea8d746825bd5f0b923a60fd2715ff", + "reference": "f502edffafea8d746825bd5f0b923a60fd2715ff", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.16.1", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.0" + }, + "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" + ], + "time": "2020-10-03T10:33:23+00:00" + }, + { + "name": "sabre/vobject", + "version": "4.3.5", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/vobject.git", + "reference": "d8a0a9ae215a8acfb51afc29101c7344670b9c83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/vobject/zipball/d8a0a9ae215a8acfb51afc29101c7344670b9c83", + "reference": "d8a0a9ae215a8acfb51afc29101c7344670b9c83", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabre/xml": "^2.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.17.1", + "phpstan/phpstan": "^0.12", + "phpunit/php-invoker": "^2.0 || ^3.1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.0" + }, + "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" + ], + "time": "2021-02-12T06:28:04+00:00" + }, + { + "name": "sabre/xml", + "version": "2.2.3", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/xml.git", + "reference": "c3b959f821c19b36952ec4a595edd695c216bfc6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/xml/zipball/c3b959f821c19b36952ec4a595edd695c216bfc6", + "reference": "c3b959f821c19b36952ec4a595edd695c216bfc6", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "lib-libxml": ">=2.6.20", + "php": "^7.1 || ^8.0", + "sabre/uri": ">=1.0,<3.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.16.1", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Sabre\\Xml\\": "lib/" + }, + "files": [ + "lib/Deserializer/functions.php", + "lib/Serializer/functions.php" + ] + }, + "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" + ], + "time": "2020-10-03T10:08:14+00:00" + }, { "name": "smarty/smarty", "version": "v3.1.36", @@ -4518,12 +4733,12 @@ "version": "1.9.1", "source": { "type": "git", - "url": "https://github.com/webmozart/assert.git", + "url": "https://github.com/webmozarts/assert.git", "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389", "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389", "shasum": "" }, diff --git a/locale/de.json b/locale/de.json index 40a8f51..567eb81 100644 --- a/locale/de.json +++ b/locale/de.json @@ -445,6 +445,7 @@ "studs": { "Adding the vote succeeded": "Die Wertung wurde hinzugefügt", "Deletion date:": "Löschdatum:", + "Download as ical/ics file": "Als ical/ics-Datei herunterladen", "If you want to vote in this poll, you have to give your name, choose the values that fit best for you and validate with the plus button at the end of the line.": "Wenn Sie an dieser Umfrage teilnehmen möchten, müssen Sie ihren Namen angeben, die Auswahl treffen, die Ihnen am ehesten zusagt und mit dem Plus-Button am Ende der Zeile bestätigen.", "POLL_LOCKED_WARNING": "Die Abstimmung wurde vom Administrator gesperrt. Bewertungen und Kommentare werden eingefroren; es ist nicht mehr möglich teilzunehmen", "The poll is expired, it will be deleted soon.": "Der Zeitraum für die Umfrage ist überschritten, sie wird bald gelöscht werden.", diff --git a/locale/en.json b/locale/en.json index 197f622..068ec0d 100644 --- a/locale/en.json +++ b/locale/en.json @@ -449,6 +449,7 @@ "studs": { "Adding the vote succeeded": "Vote added", "Deletion date:": "Deletion date:", + "Download as ical/ics file": "Download as ical/ics file", "If you want to vote in this poll, you have to give your name, choose the values that fit best for you and validate with the plus button at the end of the line.": "If you want to vote in this poll, you have to give your name, make your choice, and submit it with the plus button at the end of the line.", "POLL_LOCKED_WARNING": "The administrator locked this poll. Votes and comments are frozen, it is no longer possible to participate", "The poll is expired, it will be deleted soon.": "The poll has expired, it will soon be deleted.", diff --git a/studs.php b/studs.php index a90514d..d352aad 100644 --- a/studs.php +++ b/studs.php @@ -22,6 +22,7 @@ use Framadate\Exception\ConcurrentEditionException; use Framadate\Exception\ConcurrentVoteException; use Framadate\Message; use Framadate\Security\Token; +use Framadate\Services\ICalService; use Framadate\Services\InputService; use Framadate\Services\LogService; use Framadate\Services\MailService; @@ -62,6 +63,7 @@ $mailService = new MailService($config['use_smtp'], $config['smtp_options']); $notificationService = new NotificationService($mailService); $securityService = new SecurityService(); $sessionService = new SessionService(); +$icalService = new ICalService($logService, $notificationService, $sessionService); /* PAGE */ /* ---- */ @@ -215,6 +217,20 @@ function getMessageForOwnVoteEditableVote(SessionService &$sessionService, Smart return $message; } +// ------------------------------- +// Get iCal file +// ------------------------------- +if (isset($_GET['get_ical_file'])) { + $dayAndTime = strval(filter_input(INPUT_GET, 'get_ical_file', FILTER_DEFAULT)); + $dayAndTime = strval(Utils::base64url_decode($dayAndTime)); + $elements = explode("|", $dayAndTime); + if(count($elements) > 1) { + $icalService->getEvent($poll, strval($elements[0]), strval($elements[1])); + } + header('HTTP/1.1 500 Internal Server Error'); + echo 'Internal error'; +} + // Retrieve data if ($resultPubliclyVisible || $accessGranted) { $slots = $pollService->allSlotsByPoll($poll); diff --git a/tpl/part/vote_table_date.tpl b/tpl/part/vote_table_date.tpl index 420026c..f7b3f80 100644 --- a/tpl/part/vote_table_date.tpl +++ b/tpl/part/vote_table_date.tpl @@ -406,7 +406,10 @@ {foreach $slots as $slot} {foreach $slot->moments as $moment} {if $best_choices['y'][$i] == $max} -