diff --git a/composer.json b/composer.json index b05110ee..26810ee4 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "slim/slim": "^3.8", "morris/lessql": "^0.3.4", "rubellum/slim-blade-view": "^0.1.1", - "tuupola/cors-middleware": "^0.7.0" + "tuupola/cors-middleware": "^0.7.0", + "eluceo/ical": "^0.15.0" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index c8704d8b..b06121b6 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": "c1bc4c17739e9d0ee8b33628f6d4b9a4", + "content-hash": "0b203f875499dfeaa61890cdec018a2d", "packages": [ { "name": "container-interop/container-interop", @@ -104,6 +104,57 @@ ], "time": "2018-01-09T20:05:19+00:00" }, + { + "name": "eluceo/ical", + "version": "0.15.0", + "source": { + "type": "git", + "url": "https://github.com/markuspoerschke/iCal.git", + "reference": "add0ca99aa1f77f134a2e8b071f2ebc22b115139" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/markuspoerschke/iCal/zipball/add0ca99aa1f77f134a2e8b071f2ebc22b115139", + "reference": "add0ca99aa1f77f134a2e8b071f2ebc22b115139", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "suggest": { + "ext-mbstring": "Massive performance enhancement of line folding" + }, + "type": "library", + "autoload": { + "psr-4": { + "Eluceo\\iCal\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Markus Poerschke", + "email": "markus@eluceo.de", + "role": "Developer" + } + ], + "description": "The eluceo/iCal package offers a abstraction layer for creating iCalendars. You can easily create iCal files by using PHP object instead of typing your *.ics file by hand. The output will follow RFC 5545 as best as possible.", + "homepage": "https://github.com/markuspoerschke/iCal", + "keywords": [ + "calendar", + "iCalendar", + "ical", + "ics", + "php calendar" + ], + "time": "2019-01-13T22:00:58+00:00" + }, { "name": "http-interop/http-factory", "version": "0.3.0", diff --git a/controllers/CalendarApiController.php b/controllers/CalendarApiController.php new file mode 100644 index 00000000..e579e532 --- /dev/null +++ b/controllers/CalendarApiController.php @@ -0,0 +1,62 @@ +CalendarService = new CalendarService(); + $this->ApiKeyService = new ApiKeyService(); + } + + protected $CalendarService; + protected $ApiKeyService; + + public function Ical(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + try + { + $vCalendar = new \Eluceo\iCal\Component\Calendar('grocy'); + + $events = $this->CalendarService->GetEvents(); + foreach($events as $event) + { + $vEvent = new \Eluceo\iCal\Component\Event(); + $vEvent->setDtStart(new \DateTime($event['start'])) + ->setDtEnd(new \DateTime($event['start'])) + ->setSummary($event['title']) + ->setNoTime($event['date_format'] === 'date') + ->setUseUtc(false); + + $vCalendar->addComponent($vEvent); + } + + $response->write($vCalendar->render()); + $response = $response->withHeader('Content-Type', 'text/calendar; charset=utf-8'); + return $response->withHeader('Content-Disposition', 'attachment; filename="grocy.ics"'); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } + + public function IcalSharingLink(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + try + { + return $this->ApiResponse(array( + 'url' => $this->AppContainer->UrlManager->ConstructUrl('/api/calendar/ical?secret=' . $this->ApiKeyService->GetOrCreateApiKey(ApiKeyService::API_KEY_TYPE_SPECIAL_PURPOSE_CALENDAR_ICAL)) + )); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } +} diff --git a/controllers/CalendarController.php b/controllers/CalendarController.php index 7bfcd9cc..5b53e83c 100644 --- a/controllers/CalendarController.php +++ b/controllers/CalendarController.php @@ -2,80 +2,22 @@ namespace Grocy\Controllers; -use \Grocy\Services\StockService; -use \Grocy\Services\TasksService; -use \Grocy\Services\ChoresService; -use \Grocy\Services\BatteriesService; +use \Grocy\Services\CalendarService; class CalendarController extends BaseController { public function __construct(\Slim\Container $container) { parent::__construct($container); - $this->StockService = new StockService(); - $this->TasksService = new TasksService(); - $this->ChoresService = new ChoresService(); - $this->BatteriesService = new BatteriesService(); + $this->CalendarService = new CalendarService(); } - protected $StockService; - protected $TasksService; - protected $ChoresService; - protected $BatteriesService; + protected $CalendarService; public function Overview(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { - $products = $this->Database->products(); - $titlePrefix = $this->LocalizationService->Localize('Product expires') . ': '; - $stockEvents = array(); - foreach($this->StockService->GetCurrentStock() as $currentStockEntry) - { - $stockEvents[] = array( - 'title' => $titlePrefix . FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name, - 'start' => $currentStockEntry->best_before_date - ); - } - - $titlePrefix = $this->LocalizationService->Localize('Task due') . ': '; - $taskEvents = array(); - foreach($this->TasksService->GetCurrent() as $currentTaskEntry) - { - $taskEvents[] = array( - 'title' => $titlePrefix . $currentTaskEntry->name, - 'start' => $currentTaskEntry->due_date - ); - } - - $chores = $this->Database->chores(); - $titlePrefix = $this->LocalizationService->Localize('Chore due') . ': '; - $choreEvents = array(); - foreach($this->ChoresService->GetCurrent() as $currentChoreEntry) - { - $choreEvents[] = array( - 'title' => $titlePrefix . FindObjectInArrayByPropertyValue($chores, 'id', $currentChoreEntry->chore_id)->name, - 'start' => $currentChoreEntry->next_estimated_execution_time - ); - } - - $batteries = $this->Database->batteries(); - $titlePrefix = $this->LocalizationService->Localize('Battery charge cycle due') . ': '; - $batteryEvents = array(); - foreach($this->BatteriesService->GetCurrent() as $currentBatteryEntry) - { - $batteryEvents[] = array( - 'title' => $titlePrefix . FindObjectInArrayByPropertyValue($batteries, 'id', $currentBatteryEntry->battery_id)->name, - 'start' => $currentBatteryEntry->next_estimated_charge_time - ); - } - - $fullcalendarEventSources = array(); - $fullcalendarEventSources[] = $stockEvents; - $fullcalendarEventSources[] = $taskEvents; - $fullcalendarEventSources[] = $choreEvents; - $fullcalendarEventSources[] = $batteryEvents; - return $this->AppContainer->view->render($response, 'calendar', [ - 'fullcalendarEventSources' => $fullcalendarEventSources + 'fullcalendarEventSources' => $this->CalendarService->GetEvents() ]); } } diff --git a/grocy.openapi.json b/grocy.openapi.json index 164db3e4..a9aac3d5 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -1859,6 +1859,61 @@ } } } + }, + "/calendar/ical": { + "get": { + "summary": "Returns the calendar in iCal format", + "tags": [ + "Calendar" + ], + "responses": { + "200": { + "description": "The iCal file contents", + "content": { + "text/calendar": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The operation was not successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericErrorResponse" + } + } + } + } + } + } + }, + "/calendar/ical/sharing-link": { + "get": { + "summary": "Returns a (public) sharing link for the calendar in iCal format", + "tags": [ + "Calendar" + ], + "responses": { + "200": { + "description": "The (public) sharing link for the calendar in iCal format", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + } + } + } + } + } + } } }, "components": { diff --git a/localization/en/strings.php b/localization/en/strings.php index a4402e71..f2e4c377 100644 --- a/localization/en/strings.php +++ b/localization/en/strings.php @@ -333,5 +333,7 @@ return array( 'Picture' => 'Picture', 'Uncheck ingredients to not put them on the shopping list' => 'Uncheck ingredients to not put them on the shopping list', 'This is for statistical purposes only' => 'This is for statistical purposes only', - 'You have to select a recipe' => 'You have to select a recipe' + 'You have to select a recipe' => 'You have to select a recipe', + 'Key type' => 'Key type', + 'Export as iCal' => 'Export as iCal' ); diff --git a/middleware/ApiKeyAuthMiddleware.php b/middleware/ApiKeyAuthMiddleware.php index 793a81ad..dd0bc62b 100644 --- a/middleware/ApiKeyAuthMiddleware.php +++ b/middleware/ApiKeyAuthMiddleware.php @@ -44,6 +44,18 @@ class ApiKeyAuthMiddleware extends BaseMiddleware $validApiKey = false; } + // Handling of special purpose API keys + if (!$validApiKey) + { + if ($routeName === 'calendar-ical') + { + if ($request->getQueryParam('secret') !== null && $apiKeyService->IsValidApiKey($request->getQueryParam('secret'), ApiKeyService::API_KEY_TYPE_SPECIAL_PURPOSE_CALENDAR_ICAL)) + { + $validApiKey = true; + } + } + } + if (!$validSession && !$validApiKey) { define('GROCY_AUTHENTICATED', false); diff --git a/migrations/0056.sql b/migrations/0056.sql new file mode 100644 index 00000000..42037f29 --- /dev/null +++ b/migrations/0056.sql @@ -0,0 +1,2 @@ +ALTER TABLE api_keys +ADD key_type TEXT NOT NULL DEFAULT 'default'; diff --git a/public/viewjs/calendar.js b/public/viewjs/calendar.js index 9d029515..b8725b95 100644 --- a/public/viewjs/calendar.js +++ b/public/viewjs/calendar.js @@ -9,3 +9,19 @@ "eventLimit": true, "eventSources": fullcalendarEventSources }); + +$("#ical-button").on("click", function(e) +{ + e.preventDefault(); + + Grocy.Api.Get('calendar/ical/sharing-link', + function(result) + { + location.href = result.url; + }, + function(xhr) + { + console.error(xhr); + } + ); +}); diff --git a/routes.php b/routes.php index a66f656b..4cbce34b 100644 --- a/routes.php +++ b/routes.php @@ -182,6 +182,13 @@ $app->group('/api', function() $this->get('/tasks', '\Grocy\Controllers\TasksApiController:Current'); $this->post('/tasks/{taskId}/complete', '\Grocy\Controllers\TasksApiController:MarkTaskAsCompleted'); } + + // Calendar + if (GROCY_FEATURE_FLAG_CALENDAR) + { + $this->get('/calendar/ical', '\Grocy\Controllers\CalendarApiController:Ical')->setName('calendar-ical'); + $this->get('/calendar/ical/sharing-link', '\Grocy\Controllers\CalendarApiController:IcalSharingLink'); + } })->add(new ApiKeyAuthMiddleware($appContainer, $appContainer->LoginControllerInstance->GetSessionCookieName(), $appContainer->ApiKeyHeaderName)) ->add(JsonMiddleware::class) ->add(new CorsMiddleware([ diff --git a/services/ApiKeyService.php b/services/ApiKeyService.php index 5b3f5899..36def7c9 100644 --- a/services/ApiKeyService.php +++ b/services/ApiKeyService.php @@ -4,10 +4,13 @@ namespace Grocy\Services; class ApiKeyService extends BaseService { + const API_KEY_TYPE_DEFAULT = 'default'; + const API_KEY_TYPE_SPECIAL_PURPOSE_CALENDAR_ICAL = 'special-purpose-calendar-ical'; + /** * @return boolean */ - public function IsValidApiKey($apiKey) + public function IsValidApiKey($apiKey, $keyType = self::API_KEY_TYPE_DEFAULT) { if ($apiKey === null || empty($apiKey)) { @@ -15,7 +18,7 @@ class ApiKeyService extends BaseService } else { - $apiKeyRow = $this->Database->api_keys()->where('api_key = :1 AND expires > :2', $apiKey, date('Y-m-d H:i:s', time()))->fetch(); + $apiKeyRow = $this->Database->api_keys()->where('api_key = :1 AND expires > :2 AND key_type = :3', $apiKey, date('Y-m-d H:i:s', time()), $keyType)->fetch(); if ($apiKeyRow !== null) { $apiKeyRow->update(array( @@ -33,14 +36,15 @@ class ApiKeyService extends BaseService /** * @return string */ - public function CreateApiKey() + public function CreateApiKey($keyType = self::API_KEY_TYPE_DEFAULT) { $newApiKey = $this->GenerateApiKey(); $apiKeyRow = $this->Database->api_keys()->createRow(array( 'api_key' => $newApiKey, 'user_id' => GROCY_USER_ID, - 'expires' => '2999-12-31 23:59:59' // Default is that API keys expire never + 'expires' => '2999-12-31 23:59:59', // Default is that API keys expire never + 'key_type' => $keyType )); $apiKeyRow->save(); @@ -68,6 +72,28 @@ class ApiKeyService extends BaseService return null; } + // Returns any valid key for $keyType, + // not allowed for key type "default" + public function GetOrCreateApiKey($keyType) + { + if ($keyType === self::API_KEY_TYPE_DEFAULT) + { + return null; + } + else + { + $apiKeyRow = $this->Database->api_keys()->where('key_type = :1 AND expires > :2', $keyType, date('Y-m-d H:i:s', time()))->fetch(); + if ($apiKeyRow !== null) + { + return $apiKeyRow->api_key; + } + else + { + return $this->CreateApiKey($keyType); + } + } + } + private function GenerateApiKey() { return RandomString(50); diff --git a/services/CalendarService.php b/services/CalendarService.php new file mode 100644 index 00000000..3505eaf1 --- /dev/null +++ b/services/CalendarService.php @@ -0,0 +1,80 @@ +StockService = new StockService(); + $this->TasksService = new TasksService(); + $this->ChoresService = new ChoresService(); + $this->BatteriesService = new BatteriesService(); + } + + protected $StockService; + protected $TasksService; + protected $ChoresService; + protected $BatteriesService; + + public function GetEvents() + { + $products = $this->Database->products(); + $titlePrefix = $this->LocalizationService->Localize('Product expires') . ': '; + $stockEvents = array(); + foreach($this->StockService->GetCurrentStock() as $currentStockEntry) + { + if ($currentStockEntry->amount > 0) + { + $stockEvents[] = array( + 'title' => $titlePrefix . FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name, + 'start' => $currentStockEntry->best_before_date, + 'date_format' => 'date' + ); + } + } + + $titlePrefix = $this->LocalizationService->Localize('Task due') . ': '; + $taskEvents = array(); + foreach($this->TasksService->GetCurrent() as $currentTaskEntry) + { + $taskEvents[] = array( + 'title' => $titlePrefix . $currentTaskEntry->name, + 'start' => $currentTaskEntry->due_date, + 'date_format' => 'date' + ); + } + + $chores = $this->Database->chores(); + $titlePrefix = $this->LocalizationService->Localize('Chore due') . ': '; + $choreEvents = array(); + foreach($this->ChoresService->GetCurrent() as $currentChoreEntry) + { + $choreEvents[] = array( + 'title' => $titlePrefix . FindObjectInArrayByPropertyValue($chores, 'id', $currentChoreEntry->chore_id)->name, + 'start' => $currentChoreEntry->next_estimated_execution_time, + 'date_format' => 'datetime' + ); + } + + $batteries = $this->Database->batteries(); + $titlePrefix = $this->LocalizationService->Localize('Battery charge cycle due') . ': '; + $batteryEvents = array(); + foreach($this->BatteriesService->GetCurrent() as $currentBatteryEntry) + { + $batteryEvents[] = array( + 'title' => $titlePrefix . FindObjectInArrayByPropertyValue($batteries, 'id', $currentBatteryEntry->battery_id)->name, + 'start' => $currentBatteryEntry->next_estimated_charge_time, + 'date_format' => 'datetime' + ); + } + + return array_merge($stockEvents, $taskEvents, $choreEvents, $batteryEvents); + } +} diff --git a/views/calendar.blade.php b/views/calendar.blade.php index 9a09b6ca..d38f8f62 100644 --- a/views/calendar.blade.php +++ b/views/calendar.blade.php @@ -16,12 +16,17 @@ @section('content')
-

@yield('title')

+

+ @yield('title') + +  {{ $L('Export as iCal') }} + +

diff --git a/views/manageapikeys.blade.php b/views/manageapikeys.blade.php index e876d52b..61695396 100644 --- a/views/manageapikeys.blade.php +++ b/views/manageapikeys.blade.php @@ -38,6 +38,7 @@ {{ $L('Expires') }} {{ $L('Last used') }} {{ $L('Created') }} + {{ $L('Key type') }} @@ -66,6 +67,9 @@ {{ $apiKey->row_created_timestamp }} + + {{ $apiKey->key_type }} + @endforeach