From 99b2a84667c88e21a201bfc16a0fdcafefd83467 Mon Sep 17 00:00:00 2001 From: Bernd Bestel Date: Sat, 21 Apr 2018 19:18:00 +0200 Subject: [PATCH] Finish API documentation and token auth (references #5) --- app.php | 4 + bower.json | 3 +- controllers/OpenApiController.php | 29 +- grocy.openapi.json | 1137 +++++++++++++++++++++++++ helpers/UrlManager.php | 17 +- helpers/grocy.openapi.json | 47 - localization/de.php | 11 + middleware/ApiKeyAuthMiddleware.php | 58 ++ migrations/0022.sql | 7 + migrations/0023.sql | 1 + migrations/0024.sql | 2 + public/css/grocy.css | 9 + public/viewjs/apidoc.js | 18 - public/viewjs/batteries.js | 7 +- public/viewjs/components/habitcard.js | 2 +- public/viewjs/consume.js | 2 +- public/viewjs/habitform.js | 2 +- public/viewjs/habits.js | 7 +- public/viewjs/inventory.js | 4 +- public/viewjs/locations.js | 7 +- public/viewjs/manageapikeys.js | 50 ++ public/viewjs/openapiui.js | 26 + public/viewjs/products.js | 7 +- public/viewjs/quantityunits.js | 7 +- routes.php | 19 +- services/ApiKeyService.php | 64 ++ services/SessionService.php | 15 +- version.txt | 2 +- views/apidoc.blade.php | 17 - views/batteries.blade.php | 4 +- views/habits.blade.php | 4 +- views/layout/default.blade.php | 36 +- views/locations.blade.php | 4 +- views/manageapikeys.blade.php | 64 ++ views/openapiui.blade.php | 36 + views/products.blade.php | 4 +- views/purchase.blade.php | 2 +- views/quantityunits.blade.php | 4 +- views/shoppinglist.blade.php | 4 +- views/stockoverview.blade.php | 2 +- 40 files changed, 1617 insertions(+), 128 deletions(-) create mode 100644 grocy.openapi.json delete mode 100644 helpers/grocy.openapi.json create mode 100644 middleware/ApiKeyAuthMiddleware.php create mode 100644 migrations/0022.sql create mode 100644 migrations/0023.sql create mode 100644 migrations/0024.sql delete mode 100644 public/viewjs/apidoc.js create mode 100644 public/viewjs/manageapikeys.js create mode 100644 public/viewjs/openapiui.js create mode 100644 services/ApiKeyService.php delete mode 100644 views/apidoc.blade.php create mode 100644 views/manageapikeys.blade.php create mode 100644 views/openapiui.blade.php diff --git a/app.php b/app.php index d2310bd0..5a3b4fb2 100644 --- a/app.php +++ b/app.php @@ -26,6 +26,10 @@ $appContainer = new \Slim\Container([ 'UrlManager' => function($container) { return new UrlManager(BASE_URL); + }, + 'ApiKeyHeaderName' => function($container) + { + return 'GROCY-API-KEY'; } ]); $app = new \Slim\App($appContainer); diff --git a/bower.json b/bower.json index 4c0df17d..69fbacb7 100644 --- a/bower.json +++ b/bower.json @@ -18,6 +18,7 @@ "toastr": "^2.1.3", "tagmanager": "^3.0.2", "eonasdan-bootstrap-datetimepicker": "^4.17.47", - "swagger-ui": "^3.13.4" + "swagger-ui": "^3.13.4", + "jquery-ui": "^1.12.1" } } diff --git a/controllers/OpenApiController.php b/controllers/OpenApiController.php index 4e6942de..843fdc5e 100644 --- a/controllers/OpenApiController.php +++ b/controllers/OpenApiController.php @@ -3,21 +3,46 @@ namespace Grocy\Controllers; use \Grocy\Services\ApplicationService; +use \Grocy\Services\ApiKeyService; class OpenApiController extends BaseApiController { + public function __construct(\Slim\Container $container) + { + parent::__construct($container); + $this->ApiKeyService = new ApiKeyService(); + } + + protected $ApiKeyService; + public function DocumentationUi(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { - return $this->AppContainer->view->render($response, 'apidoc'); + return $this->AppContainer->view->render($response, 'openapiui'); } public function DocumentationSpec(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { $applicationService = new ApplicationService(); - $specJson = json_decode(file_get_contents(__DIR__ . '/../helpers/grocy.openapi.json')); + $specJson = json_decode(file_get_contents(__DIR__ . '/../grocy.openapi.json')); $specJson->info->version = $applicationService->GetInstalledVersion(); + $specJson->info->description = str_replace('PlaceHolderManageApiKeysUrl', $this->AppContainer->UrlManager->ConstructUrl('/manageapikeys'), $specJson->info->description); + $specJson->servers[0]->url = $this->AppContainer->UrlManager->ConstructUrl('/api'); return $this->ApiResponse($specJson); } + + public function ApiKeysList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + return $this->AppContainer->view->render($response, 'manageapikeys', [ + 'apiKeys' => $this->Database->api_keys() + ]); + } + + public function CreateNewApiKey(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + $newApiKey = $this->ApiKeyService->CreateApiKey(); + $newApiKeyId = $this->ApiKeyService->GetApiKeyId($newApiKey); + return $response->withRedirect($this->AppContainer->UrlManager->ConstructUrl("/manageapikeys?CreatedApiKeyId=$newApiKeyId")); + } } diff --git a/grocy.openapi.json b/grocy.openapi.json new file mode 100644 index 00000000..b6de56b2 --- /dev/null +++ b/grocy.openapi.json @@ -0,0 +1,1137 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "grocy REST API", + "description": "Authentication is done via API keys (header *GROCY-API-KEY*), which you can manage [here](PlaceHolderManageApiKeysUrl).
Additionally requests from within the frontend are also valid (via session cookie).", + "version": "xxx", + "contact": { + "email": "bernd@berrnd.de" + }, + "license": { + "name": "grocy.info", + "url": "https://grocy.info" + } + }, + "servers": [ + { + "url": "xxx" + } + ], + "tags": [ + { + "name": "Generic entity interactions", + "description": "A limited set of entities are directly exposed for convenience" + } + ], + "paths": { + "/get-objects/{entity}": { + "get": { + "description": "Returns all objects of the given entity", + "tags": [ + "Generic entity interactions" + ], + "parameters": [ + { + "in": "path", + "name": "entity", + "required": true, + "description": "A valid entity name", + "schema": { + "$ref": "#/components/internalSchemas/Entity" + } + } + ], + "responses": { + "200": { + "description": "An entity object", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/Product" + }, + { + "$ref": "#/components/schemas/Habit" + }, + { + "$ref": "#/components/schemas/Battery" + }, + { + "$ref": "#/components/schemas/Location" + }, + { + "$ref": "#/components/schemas/QuantityUnit" + }, + { + "$ref": "#/components/schemas/ShoppingListItem" + }, + { + "$ref": "#/components/schemas/StockEntry" + } + ] + } + } + } + } + } + } + } + }, + "/get-object/{entity}/{objectId}": { + "get": { + "description": "Returns a single object of the given entity", + "tags": [ + "Generic entity interactions" + ], + "parameters": [ + { + "in": "path", + "name": "entity", + "required": true, + "description": "A valid entity name", + "schema": { + "$ref": "#/components/internalSchemas/Entity" + } + }, + { + "in": "path", + "name": "objectId", + "required": true, + "description": "A valid object id of the given entity", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "An entity object", + "content": { + "application/json": { + "schema":{ + "type": "object", + "oneOf": [ + { + "$ref": "#/components/schemas/Product" + }, + { + "$ref": "#/components/schemas/Habit" + }, + { + "$ref": "#/components/schemas/Battery" + }, + { + "$ref": "#/components/schemas/Location" + }, + { + "$ref": "#/components/schemas/QuantityUnit" + }, + { + "$ref": "#/components/schemas/ShoppingListItem" + }, + { + "$ref": "#/components/schemas/StockEntry" + } + ] + } + } + } + } + } + } + }, + "/add-object/{entity}": { + "post": { + "description": "Adds a single object of the given entity", + "tags": [ + "Generic entity interactions" + ], + "parameters": [ + { + "in": "path", + "name": "entity", + "required": true, + "description": "A valid entity name", + "schema": { + "$ref": "#/components/internalSchemas/Entity" + } + } + ], + "requestBody": { + "description": "A valid entity object of entity specified in parameter *entity*", + "required": true, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Product" + }, + { + "$ref": "#/components/schemas/Habit" + }, + { + "$ref": "#/components/schemas/Battery" + }, + { + "$ref": "#/components/schemas/Location" + }, + { + "$ref": "#/components/schemas/QuantityUnit" + }, + { + "$ref": "#/components/schemas/ShoppingListItem" + }, + { + "$ref": "#/components/schemas/StockEntry" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + } + } + } + }, + "/edit-object/{entity}/{objectId}": { + "post": { + "description": "Edits the given object of the given entity", + "tags": [ + "Generic entity interactions" + ], + "parameters": [ + { + "in": "path", + "name": "entity", + "required": true, + "description": "A valid entity name", + "schema": { + "$ref": "#/components/internalSchemas/Entity" + } + }, + { + "in": "path", + "name": "objectId", + "required": true, + "description": "A valid object id of the given entity", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "description": "A valid entity object of entity specified in parameter *entity*", + "required": true, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Product" + }, + { + "$ref": "#/components/schemas/Habit" + }, + { + "$ref": "#/components/schemas/Battery" + }, + { + "$ref": "#/components/schemas/Location" + }, + { + "$ref": "#/components/schemas/QuantityUnit" + }, + { + "$ref": "#/components/schemas/ShoppingListItem" + }, + { + "$ref": "#/components/schemas/StockEntry" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + } + } + } + }, + "/delete-object/{entity}/{objectId}": { + "get": { + "description": "Deletes a single object of the given entity", + "tags": [ + "Generic entity interactions" + ], + "parameters": [ + { + "in": "path", + "name": "entity", + "required": true, + "description": "A valid entity name", + "schema": { + "$ref": "#/components/internalSchemas/Entity" + } + }, + { + "in": "path", + "name": "objectId", + "required": true, + "description": "A valid object id of the given entity", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + } + } + } + }, + "/stock/add-product/{productId}/{amount}": { + "get": { + "description": "Adds the the given amount of the given product to stock", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "productId", + "required": true, + "description": "A valid product id", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "amount", + "required": true, + "description": "The amount to add", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "bestbeforedate", + "required": false, + "description": "The best before date of the product to add, when omitted, the current date is used", + "schema": { + "type": "date" + } + }, + { + "in": "query", + "name": "transactiontype", + "required": false, + "description": "The transaction type for this transaction, when omitted, *purchase* is used", + "schema": { + "$ref": "#/components/internalSchemas/StockTransactionType" + } + } + ], + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + } + } + } + }, + "/stock/consume-product/{productId}/{amount}": { + "get": { + "description": "Removes the the given amount of the given product from stock", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "productId", + "required": true, + "description": "A valid product id", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "amount", + "required": false, + "description": "The amount to remove", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "in": "query", + "name": "spoiled", + "required": true, + "description": "True when the given product was spoiled, defaults to false", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "transactiontype", + "required": false, + "description": "The transaction type for this transaction, when omitted, *consume* is used", + "schema": { + "$ref": "#/components/internalSchemas/StockTransactionType" + } + } + ], + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + } + } + } + }, + "/stock/inventory-product/{productId}/{newAmount}": { + "get": { + "description": "Inventories the the given product (adds/removes based on the given new current amount)", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "productId", + "required": true, + "description": "A valid product id", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "newAmount", + "required": true, + "description": "The new current amount for the given product", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "bestbeforedate", + "required": false, + "description": "The best before date which applies to added products", + "schema": { + "type": "date" + } + } + ], + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + } + } + } + }, + "/stock/get-product-details/{productId}": { + "get": { + "description": "Returns details of the given product", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "productId", + "required": true, + "description": "A valid product id", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "A ProductDetailsResponse object", + "content": { + "application/json": { + "schema":{ + "$ref": "#/components/schemas/ProductDetailsResponse" + } + } + } + } + } + } + }, + "/stock/get-current-stock": { + "get": { + "description": "Returns all products which are currently in stock incl. the next expiring date per product", + "tags": [ + "Stock" + ], + "responses": { + "200": { + "description": "An array of CurrentStockResponse objects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Product" + } + } + } + } + } + } + } + }, + "/stock/add-missing-products-to-shoppinglist": { + "get": { + "description": "Adds currently missing products (below defined min. stock amount) to the shopping list", + "tags": [ + "Stock" + ], + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + } + } + } + }, + "/habits/track-habit-execution/{habitId}": { + "get": { + "description": "Tracks an execution of the given habit", + "tags": [ + "Habits" + ], + "parameters": [ + { + "in": "path", + "name": "habitId", + "required": true, + "description": "A valid habit id", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "tracked_time", + "required": false, + "description": "The time of when the habit was executed, when omitted, the current time is used", + "schema": { + "type": "date-time" + } + } + ], + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + } + } + } + }, + "/habits/get-habit-details/{habitId}": { + "get": { + "description": "Returns details of the given habit", + "tags": [ + "Habits" + ], + "parameters": [ + { + "in": "path", + "name": "habitId", + "required": true, + "description": "A valid habit id", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "A HabitDetailsResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HabitDetailsResponse" + } + } + } + } + } + } + }, + "/batteries/track-charge-cycle/{batteryId}": { + "get": { + "description": "Tracks a charge cycle of the given battery", + "tags": [ + "Batteries" + ], + "parameters": [ + { + "in": "path", + "name": "batteryId", + "required": true, + "description": "A valid battery id", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + } + } + } + }, + "/batteries/get-battery-details/{batteryId}": { + "get": { + "description": "Returns details of the given battery", + "tags": [ + "Batteries" + ], + "parameters": [ + { + "in": "path", + "name": "batteryId", + "required": true, + "description": "A valid battery id", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "A BatteryDetailsResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatteryDetailsResponse" + } + } + } + } + } + } + } + }, + "components": { + "internalSchemas": { + "Entity": { + "type": "string", + "enum": [ + "products", + "habits", + "batteries", + "locations", + "quantity_units", + "shopping_list", + "stock" + ] + }, + "StockTransactionType": { + "type": "string", + "enum": [ + "purchase", + "consume", + "inventory-correction" + ] + } + }, + "schemas": { + "Product": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "location_id": { + "type": "integer" + }, + "qu_id_purchase": { + "type": "integer" + }, + "qu_id_stock": { + "type": "integer" + }, + "qu_factor_purchase_to_stock": { + "type": "number", + "format": "double" + }, + "barcode": { + "type": "string", + "description": "Can contain multiple barcodes separated by comma" + }, + "min_stock_amount": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "default_best_before_days": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "QuantityUnit": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "Location": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "StockEntry": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "product_id": { + "type": "integer" + }, + "amount": { + "type": "integer" + }, + "best_before_date": { + "type": "string", + "format": "date" + }, + "purchased_date": { + "type": "string", + "format": "date" + }, + "stock_id": { + "type": "string", + "description": "A unique id which references this stock entry during its lifetime" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ProductDetailsResponse": { + "type": "object", + "properties": { + "product": { + "$ref": "#/components/schemas/Product" + }, + "quantity_unit_purchase": { + "$ref": "#/components/schemas/QuantityUnit" + }, + "quantity_unit_stock": { + "$ref": "#/components/schemas/QuantityUnit" + }, + "last_purchased": { + "type": "string", + "format": "date" + }, + "last_used": { + "type": "string", + "format": "date-time" + }, + "stock_amount": { + "type": "integer" + } + } + }, + "HabitDetailsResponse": { + "type": "object", + "properties": { + "habit": { + "$ref": "#/components/schemas/Habit" + }, + "last_tracked": { + "type": "string", + "format": "date-time", + "description": "When this habit was last tracked" + }, + "track_count": { + "type": "integer", + "description": "How often this habit was tracked so far" + } + } + }, + "BatteryDetailsResponse": { + "type": "object", + "properties": { + "habit": { + "$ref": "#/components/schemas/Battery" + }, + "last_charged": { + "type": "string", + "format": "date-time", + "description": "When this battery was last charged" + }, + "charge_cycles_count": { + "type": "integer", + "description": "How often this battery was charged so far" + } + } + }, + "Session": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "session_key": { + "type": "string" + }, + "expires": { + "type": "string", + "format": "date-time" + }, + "last_used": { + "type": "string", + "format": "date-time" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiKey": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "api_key": { + "type": "string" + }, + "expires": { + "type": "string", + "format": "date-time" + }, + "last_used": { + "type": "string", + "format": "date-time" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ShoppingListItem": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "product_id": { + "type": "integer" + }, + "note": { + "type": "string" + }, + "amount": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "The manual entered amount" + }, + "amount_autoadded": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "The automatically added amount based on defined minimum stock amounts" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "Battery": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "used_in": { + "type": "string" + }, + "charge_interval_days": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "BatteryChargeCycle": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "battery_id": { + "type": "integer" + }, + "tracked_time": { + "type": "string", + "format": "date-time" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "Habit": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "period_type": { + "type": "string", + "enum": [ + "manually", + "dynamic-regular" + ] + }, + "period_days": { + "type": "integer" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "HabitLogEntry": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "habit_id": { + "type": "integer" + }, + "tracked_time": { + "type": "string", + "format": "date-time" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "StockLogEntry": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "product_id": { + "type": "integer" + }, + "amount": { + "type": "integer" + }, + "best_before_date": { + "type": "string", + "format": "date" + }, + "purchased_date": { + "type": "string", + "format": "date-time" + }, + "used_date": { + "type": "string", + "format": "date-time" + }, + "spoiled": { + "type": "boolean", + "default": false + }, + "stock_id": { + "type": "string" + }, + "transaction_type": { + "$ref": "#/components/internalSchemas/StockTransactionType" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "VoidApiActionResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "default": true + } + } + }, + "CurrentStockResponse": { + "type": "object", + "properties": { + "product_id": { + "type": "integer" + }, + "amount": { + "type": "integer" + }, + "best_before_date": { + "type": "date", + "description": "The next best before date for this product" + } + } + } + }, + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "GROCY-API-KEY" + } + } + }, + "security": [ + { + "ApiKeyAuth": [ ] + } + ] +} diff --git a/helpers/UrlManager.php b/helpers/UrlManager.php index dd6886a2..f5bb271e 100644 --- a/helpers/UrlManager.php +++ b/helpers/UrlManager.php @@ -4,8 +4,16 @@ namespace Grocy\Helpers; class UrlManager { - public function __construct(string $basePath) { - $this->BasePath = $basePath; + public function __construct(string $basePath) + { + if ($basePath === '/') + { + $this->BasePath = $this->GetBaseUrl(); + } + else + { + $this->BasePath = $basePath; + } } protected $BasePath; @@ -14,4 +22,9 @@ class UrlManager { return rtrim($this->BasePath, '/') . $relativePath; } + + private function GetBaseUrl() + { + return (isset($_SERVER['HTTPS']) ? "https" : "http") . "://$_SERVER[HTTP_HOST]"; + } } diff --git a/helpers/grocy.openapi.json b/helpers/grocy.openapi.json deleted file mode 100644 index c10b8824..00000000 --- a/helpers/grocy.openapi.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "grocy REST API", - "description": "xxx", - "version": "xxx" - }, - "servers": [ - { - "url": "xxx" - } - ], - "paths": { - "/get-objects/{entity}": { - "get": { - "description": "Returns all objects of the given entity", - "parameters": [ - { - "in": "path", - "name": "entity", - "required": true, - "description": "A valid entity name", - "schema": { - "$ref": "#/components/schemas/Entity" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - } - }, - "components": { - "schemas": { - "Entity": { - "type": "string", - "enum": [ - "product", - "habit" - ] - } - } - } -} diff --git a/localization/de.php b/localization/de.php index dbdc09d1..5ea498f2 100644 --- a/localization/de.php +++ b/localization/de.php @@ -101,6 +101,17 @@ return array( 'Are you sure to delete quantity unit "#1"?' => 'Mengeneinheit "#1" wirklich löschen?', 'Are you sure to delete product "#1"?' => 'Produkt "#1" wirklich löschen?', 'Are you sure to delete location "#1"?' => 'Standort "#1" wirklich löschen?', + 'Manage API keys' => 'API-Keys verwalten', + 'REST API & data model documentation' => 'REST-API & Datenmodell Dokumentation', + 'API keys' => 'API-Keys', + 'Create new API key' => 'Neuen API-Key erstellen', + 'API key' => 'API-Key', + 'Expires' => 'Läuft ab', + 'Created' => 'Erstellt', + 'This product is not in stock' => 'Dieses Produkt ist nicht vorrätig', + 'This means #1 will be added to stock' => 'Das bedeutet #1 wird dem Bestand hinzugefügt', + 'This means #1 will be removed from stock' => 'Das bedeutet #1 wird aus dem Bestand entfernt', + 'This means it is estimated that a new execution of this habit is tracked #1 days after the last was tracked' => 'Das bedeutet, dass eine erneute Ausführung der Gewohnheit #1 Tage nach der letzten Ausführung geplant wird', //Constants 'manually' => 'Manuell', diff --git a/middleware/ApiKeyAuthMiddleware.php b/middleware/ApiKeyAuthMiddleware.php new file mode 100644 index 00000000..3e28dfea --- /dev/null +++ b/middleware/ApiKeyAuthMiddleware.php @@ -0,0 +1,58 @@ +SessionCookieName = $sessionCookieName; + $this->ApiKeyHeaderName = $apiKeyHeaderName; + } + + protected $SessionCookieName; + protected $ApiKeyHeaderName; + + public function __invoke(\Slim\Http\Request $request, \Slim\Http\Response $response, callable $next) + { + $route = $request->getAttribute('route'); + $routeName = $route->getName(); + + if ($this->ApplicationService->IsDemoInstallation()) + { + $response = $next($request, $response); + } + else + { + $validSession = true; + $validApiKey = true; + + $sessionService = new SessionService(); + if (!isset($_COOKIE[$this->SessionCookieName]) || !$sessionService->IsValidSession($_COOKIE[$this->SessionCookieName])) + { + $validSession = false; + } + + $apiKeyService = new ApiKeyService(); + if (!$request->hasHeader($this->ApiKeyHeaderName) || !$apiKeyService->IsValidApiKey($request->getHeaderLine($this->ApiKeyHeaderName))) + { + $validApiKey = false; + } + + if (!$validSession && !$validApiKey) + { + $response = $response->withStatus(401); + } + else + { + $response = $next($request, $response); + } + } + + return $response; + } +} diff --git a/migrations/0022.sql b/migrations/0022.sql new file mode 100644 index 00000000..2629ec83 --- /dev/null +++ b/migrations/0022.sql @@ -0,0 +1,7 @@ +CREATE TABLE api_keys ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + api_key TEXT NOT NULL UNIQUE, + expires DATETIME, + last_used DATETIME, + row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) +) diff --git a/migrations/0023.sql b/migrations/0023.sql new file mode 100644 index 00000000..6479ee13 --- /dev/null +++ b/migrations/0023.sql @@ -0,0 +1 @@ +DELETE FROM sessions diff --git a/migrations/0024.sql b/migrations/0024.sql new file mode 100644 index 00000000..ed815485 --- /dev/null +++ b/migrations/0024.sql @@ -0,0 +1,2 @@ +ALTER TABLE sessions +ADD COLUMN last_used DATETIME diff --git a/public/css/grocy.css b/public/css/grocy.css index a2b4a30d..a393caad 100644 --- a/public/css/grocy.css +++ b/public/css/grocy.css @@ -188,3 +188,12 @@ a.discrete-link:focus { #toast-container > div { box-shadow: none; } + +.navbar-default .navbar-nav > .open > a { + background-color: #d6d6d6 !important; +} + +.dropdown-menu > li > a:hover, +.dropdown-menu > li > a:focus { + background-color: #e5e5e5 !important; +} diff --git a/public/viewjs/apidoc.js b/public/viewjs/apidoc.js deleted file mode 100644 index f8497e60..00000000 --- a/public/viewjs/apidoc.js +++ /dev/null @@ -1,18 +0,0 @@ -$(function () -{ - const swaggerUi = SwaggerUIBundle({ - url: U('/api/get-open-api-specification'), - dom_id: '#swagger-ui', - deepLinking: true, - presets: [ - SwaggerUIBundle.presets.apis, - SwaggerUIStandalonePreset - ], - plugins: [ - SwaggerUIBundle.plugins.DownloadUrl - ], - layout: 'StandaloneLayout' - }); - - window.ui = swaggerUi; -}); diff --git a/public/viewjs/batteries.js b/public/viewjs/batteries.js index a75ba564..5e16d5c9 100644 --- a/public/viewjs/batteries.js +++ b/public/viewjs/batteries.js @@ -1,7 +1,10 @@ $(document).on('click', '.battery-delete-button', function(e) { + var objectName = $(e.currentTarget).attr('data-battery-name'); + var objectId = $(e.currentTarget).attr('data-battery-id'); + bootbox.confirm({ - message: L('Are you sure to delete battery "#1"?', $(e.currentTarget).attr('data-battery-name')), + message: L('Are you sure to delete battery "#1"?', objectName), buttons: { confirm: { label: L('Yes'), @@ -16,7 +19,7 @@ { if (result === true) { - Grocy.Api.Get('delete-object/batteries/' + $(e.currentTarget).attr('data-battery-id'), + Grocy.Api.Get('delete-object/batteries/' + objectId, function(result) { window.location.href = U('/batteries'); diff --git a/public/viewjs/components/habitcard.js b/public/viewjs/components/habitcard.js index 41cbcbd2..5514c4d5 100644 --- a/public/viewjs/components/habitcard.js +++ b/public/viewjs/components/habitcard.js @@ -1,6 +1,6 @@ Grocy.Components.HabitCard = { }; -Grocy.Components.HabitCard.Refresh = function (habitId) +Grocy.Components.HabitCard.Refresh = function(habitId) { Grocy.Api.Get('habits/get-habit-details/' + habitId, function(habitDetails) diff --git a/public/viewjs/consume.js b/public/viewjs/consume.js index 563b6bae..db0ff57b 100644 --- a/public/viewjs/consume.js +++ b/public/viewjs/consume.js @@ -60,7 +60,7 @@ $('#product_id').on('change', function(e) $('#product_id_text_input').addClass('has-error'); $('#product_id_text_input').parent('.input-group').addClass('has-error'); $('#product_id_text_input').closest('.form-group').addClass('has-error'); - $('#product-error').text('This product is not in stock.'); + $('#product-error').text(L('This product is not in stock')); $('#product-error').show(); $('#product_id_text_input').focus(); } diff --git a/public/viewjs/habitform.js b/public/viewjs/habitform.js index 0f0fd7a6..c4f37df6 100644 --- a/public/viewjs/habitform.js +++ b/public/viewjs/habitform.js @@ -41,7 +41,7 @@ $('.input-group-habit-period-type').on('change', function(e) if (periodType === 'dynamic-regular') { - $('#habit-period-type-info').text('This means it is estimated that a new "execution" of this habit is tracked ' + periodDays.toString() + ' days after the last was tracked.'); + $('#habit-period-type-info').text(L('This means it is estimated that a new execution of this habit is tracked #1 days after the last was tracked', periodDays.toString())); $('#habit-period-type-info').show(); } else diff --git a/public/viewjs/habits.js b/public/viewjs/habits.js index 84e52197..2b6b0644 100644 --- a/public/viewjs/habits.js +++ b/public/viewjs/habits.js @@ -1,7 +1,10 @@ $(document).on('click', '.habit-delete-button', function(e) { + var objectName = $(e.currentTarget).attr('data-habit-name'); + var objectId = $(e.currentTarget).attr('data-habit-id'); + bootbox.confirm({ - message: L('Are you sure to delete habit "#1"?', $(e.currentTarget).attr('data-habit-name')), + message: L('Are you sure to delete habit "#1"?', objectName), buttons: { confirm: { label: L('Yes'), @@ -16,7 +19,7 @@ { if (result === true) { - Grocy.Api.Get('delete-object/habits/' + $(e.currentTarget).attr('data-habit-id'), + Grocy.Api.Get('delete-object/habits/' + objectId, function(result) { window.location.href = U('/habits'); diff --git a/public/viewjs/inventory.js b/public/viewjs/inventory.js index d4fc67e9..030ed5cd 100644 --- a/public/viewjs/inventory.js +++ b/public/viewjs/inventory.js @@ -291,14 +291,14 @@ $('#new_amount').on('change', function(e) if (newAmount > productStockAmount) { var amountToAdd = newAmount - productDetails.stock_amount; - $('#inventory-change-info').text('This means ' + amountToAdd.toString() + ' ' + productDetails.quantity_unit_stock.name + ' will be added to stock'); + $('#inventory-change-info').text(L('This means #1 will be added to stock', amountToAdd.toString() + ' ' + productDetails.quantity_unit_stock.name)); $('#inventory-change-info').show(); $('#best_before_date').attr('required', 'required'); } else if (newAmount < productStockAmount) { var amountToRemove = productStockAmount - newAmount; - $('#inventory-change-info').text('This means ' + amountToRemove.toString() + ' ' + productDetails.quantity_unit_stock.name + ' will be removed from stock'); + $('#inventory-change-info').text(L('This means #1 will be removed from stock', amountToRemove.toString() + ' ' + productDetails.quantity_unit_stock.name)); $('#inventory-change-info').show(); $('#best_before_date').removeAttr('required'); } diff --git a/public/viewjs/locations.js b/public/viewjs/locations.js index 619f1a00..1f4043ce 100644 --- a/public/viewjs/locations.js +++ b/public/viewjs/locations.js @@ -1,7 +1,10 @@ $(document).on('click', '.location-delete-button', function(e) { + var objectName = $(e.currentTarget).attr('data-location-name'); + var objectId = $(e.currentTarget).attr('data-location-id'); + bootbox.confirm({ - message: L('Are you sure to delete location "#1"?', $(e.currentTarget).attr('data-location-name')), + message: L('Are you sure to delete location "#1"?', objectName), buttons: { confirm: { label: L('Yes'), @@ -16,7 +19,7 @@ { if (result === true) { - Grocy.Api.Get('delete-object/locations/' + $(e.currentTarget).attr('data-location-id'), + Grocy.Api.Get('delete-object/locations/' + objectId, function(result) { window.location.href = U('/locations'); diff --git a/public/viewjs/manageapikeys.js b/public/viewjs/manageapikeys.js new file mode 100644 index 00000000..40bc25a1 --- /dev/null +++ b/public/viewjs/manageapikeys.js @@ -0,0 +1,50 @@ +$(document).on('click', '.apikey-delete-button', function(e) +{ + var objectName = $(e.currentTarget).attr('data-apikey-apikey'); + var objectId = $(e.currentTarget).attr('data-apikey-id'); + + bootbox.confirm({ + message: L('Are you sure to delete API key "#1"?', objectName), + buttons: { + confirm: { + label: L('Yes'), + className: 'btn-success' + }, + cancel: { + label: L('No'), + className: 'btn-danger' + } + }, + callback: function(result) + { + if (result === true) + { + Grocy.Api.Get('delete-object/api_keys/' + objectId, + function(result) + { + window.location.href = U('/manageapikeys'); + }, + function(xhr) + { + console.error(xhr); + } + ); + } + } + }); +}); + +$('#apikeys-table').DataTable({ + 'pageLength': 50, + 'order': [[4, 'desc']], + 'columnDefs': [ + { 'orderable': false, 'targets': 0 } + ], + 'language': JSON.parse(L('datatables_localization')) +}); + +var createdApiKeyId = GetUriParam('CreatedApiKeyId'); +if (createdApiKeyId !== undefined) +{ + $('#apiKeyRow_' + createdApiKeyId).effect('highlight', { }, 3000); +} diff --git a/public/viewjs/openapiui.js b/public/viewjs/openapiui.js new file mode 100644 index 00000000..a598aa75 --- /dev/null +++ b/public/viewjs/openapiui.js @@ -0,0 +1,26 @@ +function HideTopbarPlugin() +{ + return { + components: { + Topbar: function () { return null } + } + } +} + +const swaggerUi = SwaggerUIBundle({ + url: Grocy.OpenApi.SpecUrl, + dom_id: '#swagger-ui', + deepLinking: true, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl, + HideTopbarPlugin + ], + layout: 'StandaloneLayout', + docExpansion: "list" +}); + +window.ui = swaggerUi; diff --git a/public/viewjs/products.js b/public/viewjs/products.js index ff58d44e..5a2d5477 100644 --- a/public/viewjs/products.js +++ b/public/viewjs/products.js @@ -1,7 +1,10 @@ $(document).on('click', '.product-delete-button', function(e) { + var objectName = $(e.currentTarget).attr('data-product-name'); + var objectId = $(e.currentTarget).attr('data-product-id'); + bootbox.confirm({ - message: L('Are you sure to delete product "#1"?', $(e.currentTarget).attr('data-product-name')), + message: L('Are you sure to delete product "#1"?', objectName), buttons: { confirm: { label: L('Yes'), @@ -16,7 +19,7 @@ { if (result === true) { - Grocy.Api.Get('delete-object/products/' + $(e.currentTarget).attr('data-product-id'), + Grocy.Api.Get('delete-object/products/' + objectId, function(result) { window.location.href = U('/products'); diff --git a/public/viewjs/quantityunits.js b/public/viewjs/quantityunits.js index ef28335c..44302c2a 100644 --- a/public/viewjs/quantityunits.js +++ b/public/viewjs/quantityunits.js @@ -1,7 +1,10 @@ $(document).on('click', '.quantityunit-delete-button', function(e) { + var objectName = $(e.currentTarget).attr('data-quantityunit-name'); + var objectId = $(e.currentTarget).attr('data-quantityunit-id'); + bootbox.confirm({ - message: L('Are you sure to delete quantity unit "#1"?', $(e.currentTarget).attr('data-quantityunit-name')), + message: L('Are you sure to delete quantity unit "#1"?', objectName), buttons: { confirm: { label: 'Yes', @@ -16,7 +19,7 @@ { if (result === true) { - Grocy.Api.Get('delete-object/quantity_units/' + $(e.currentTarget).attr('data-quantityunit-id'), + Grocy.Api.Get('delete-object/quantity_units/' + objectId, function(result) { window.location.href = U('/quantityunits'); diff --git a/routes.php b/routes.php index e828c272..b7855ca5 100644 --- a/routes.php +++ b/routes.php @@ -3,6 +3,8 @@ use \Grocy\Middleware\JsonMiddleware; use \Grocy\Middleware\CliMiddleware; use \Grocy\Middleware\SessionAuthMiddleware; +use \Grocy\Middleware\ApiKeyAuthMiddleware; +use \Tuupola\Middleware\CorsMiddleware; $app->group('', function() { @@ -47,12 +49,14 @@ $app->group('', function() $this->get('/battery/{batteryId}', 'Grocy\Controllers\BatteriesController:BatteryEditForm'); // Other routes - $this->get('/apidoc', 'Grocy\Controllers\OpenApiController:DocumentationUi'); + $this->get('/api', 'Grocy\Controllers\OpenApiController:DocumentationUi'); + $this->get('/manageapikeys', 'Grocy\Controllers\OpenApiController:ApiKeysList'); + $this->get('/manageapikeys/new', 'Grocy\Controllers\OpenApiController:CreateNewApiKey'); })->add(new SessionAuthMiddleware($appContainer, $appContainer->LoginControllerInstance->GetSessionCookieName())); $app->group('/api', function() { - $this->get('/get-open-api-specification', 'Grocy\Controllers\OpenApiController:DocumentationSpec'); + $this->get('/get-openapi-specification', 'Grocy\Controllers\OpenApiController:DocumentationSpec'); $this->get('/get-objects/{entity}', 'Grocy\Controllers\GenericEntityApiController:GetObjects'); $this->get('/get-object/{entity}/{objectId}', 'Grocy\Controllers\GenericEntityApiController:GetObject'); @@ -72,7 +76,16 @@ $app->group('/api', function() $this->get('/batteries/track-charge-cycle/{batteryId}', 'Grocy\Controllers\BatteriesApiController:TrackChargeCycle'); $this->get('/batteries/get-battery-details/{batteryId}', 'Grocy\Controllers\BatteriesApiController:BatteryDetails'); -})->add(new SessionAuthMiddleware($appContainer, $appContainer->LoginControllerInstance->GetSessionCookieName()))->add(JsonMiddleware::class); +})->add(new ApiKeyAuthMiddleware($appContainer, $appContainer->LoginControllerInstance->GetSessionCookieName(), $appContainer->ApiKeyHeaderName)) +->add(JsonMiddleware::class) +->add(new CorsMiddleware([ + 'origin' => ["*"], + 'methods' => ["GET", "POST"], + 'headers.allow' => [ $appContainer->ApiKeyHeaderName ], + 'headers.expose' => [ ], + 'credentials' => false, + 'cache' => 0, +])); $app->group('/cli', function() { diff --git a/services/ApiKeyService.php b/services/ApiKeyService.php new file mode 100644 index 00000000..06756ec5 --- /dev/null +++ b/services/ApiKeyService.php @@ -0,0 +1,64 @@ +Database->api_keys()->where('api_key = :1 AND expires > :2', $apiKey, date('Y-m-d H:i:s', time()))->fetch(); + if ($apiKeyRow !== null) + { + $apiKeyRow->update(array( + 'last_used' => date('Y-m-d H:i:s', time()) + )); + return true; + } + else + { + return false; + } + } + } + + /** + * @return string + */ + public function CreateApiKey() + { + $newApiKey = $this->GenerateApiKey(); + + $apiKeyRow = $this->Database->api_keys()->createRow(array( + 'api_key' => $newApiKey, + 'expires' => '2999-12-31 23:59:59' // Default is that API keys expire never + )); + $apiKeyRow->save(); + + return $newApiKey; + } + + public function RemoveApiKey($apiKey) + { + $this->Database->api_keys()->where('api_key', $apiKey)->delete(); + } + + public function GetApiKeyId($apiKey) + { + $apiKey = $this->Database->api_keys()->where('api_key', $apiKey)->fetch(); + return $apiKey->id; + } + + private function GenerateApiKey() + { + return RandomString(50); + } +} diff --git a/services/SessionService.php b/services/SessionService.php index 780e10bc..44becd41 100644 --- a/services/SessionService.php +++ b/services/SessionService.php @@ -15,7 +15,18 @@ class SessionService extends BaseService } else { - return $this->Database->sessions()->where('session_key = :1 AND expires > :2', $sessionKey, time())->count() === 1; + $sessionRow = $this->Database->sessions()->where('session_key = :1 AND expires > :2', $sessionKey, date('Y-m-d H:i:s', time()))->fetch(); + if ($sessionRow !== null) + { + $sessionRow->update(array( + 'last_used' => date('Y-m-d H:i:s', time()) + )); + return true; + } + else + { + return false; + } } } @@ -28,7 +39,7 @@ class SessionService extends BaseService $sessionRow = $this->Database->sessions()->createRow(array( 'session_key' => $newSessionKey, - 'expires' => time() + 2592000 // 30 days + 'expires' => date('Y-m-d H:i:s', time() + 2592000) // Default is that sessions expire in 30 days )); $sessionRow->save(); diff --git a/version.txt b/version.txt index 53adb84c..f8e233b2 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.8.2 +1.9.0 diff --git a/views/apidoc.blade.php b/views/apidoc.blade.php deleted file mode 100644 index 3df710b3..00000000 --- a/views/apidoc.blade.php +++ /dev/null @@ -1,17 +0,0 @@ -@extends('layout.default') - -@section('title', $L('REST API documentation')) -@section('viewJsName', 'apidoc') - -@section('content') -
-@stop - -@push('pageStyles') - -@endpush - -@push('pageScripts') - - -@endpush diff --git a/views/batteries.blade.php b/views/batteries.blade.php index df484c89..588c8436 100644 --- a/views/batteries.blade.php +++ b/views/batteries.blade.php @@ -9,7 +9,7 @@

@yield('title') - +  {{ $L('Add') }}

@@ -28,7 +28,7 @@ @foreach($batteries as $battery) - + diff --git a/views/habits.blade.php b/views/habits.blade.php index 327625cc..08fa83d5 100644 --- a/views/habits.blade.php +++ b/views/habits.blade.php @@ -9,7 +9,7 @@

@yield('title') - +  {{ $L('Add') }}

@@ -29,7 +29,7 @@ @foreach($habits as $habit) - + diff --git a/views/layout/default.blade.php b/views/layout/default.blade.php index 68d208b3..c3e55478 100644 --- a/views/layout/default.blade.php +++ b/views/layout/default.blade.php @@ -1,5 +1,5 @@ - + @@ -9,7 +9,7 @@ - + @yield('title') | grocy @@ -49,8 +49,20 @@ @@ -111,8 +123,20 @@ diff --git a/views/locations.blade.php b/views/locations.blade.php index e861887a..c1a7f18a 100644 --- a/views/locations.blade.php +++ b/views/locations.blade.php @@ -9,7 +9,7 @@

@yield('title') - +  {{ $L('Add') }}

@@ -27,7 +27,7 @@ @foreach($locations as $location) - + diff --git a/views/manageapikeys.blade.php b/views/manageapikeys.blade.php new file mode 100644 index 00000000..875f67eb --- /dev/null +++ b/views/manageapikeys.blade.php @@ -0,0 +1,64 @@ +@extends('layout.default') + +@section('title', $L('API keys')) +@section('activeNav', '') +@section('viewJsName', 'manageapikeys') + +@push('pageScripts') + +@endpush + +@section('content') +
+ +

+ @yield('title') + +  {{ $L('Create new API key') }} + +

+ +

{{ $L('REST API & data model documentation') }}

+ +
+ + + + + + + + + + + + @foreach($apiKeys as $apiKey) + + + + + + + + @endforeach + +
#{{ $L('API key') }}{{ $L('Expires') }}{{ $L('Last used') }}{{ $L('Created') }}
+ + + + + {{ $apiKey->api_key }} + + {{ $apiKey->expires }} + + + @if(empty($apiKey->last_used)){{ $L('never') }}@else{{ $apiKey->last_used }}@endif + + + {{ $apiKey->row_created_timestamp }} + +
+
+ +
+@stop diff --git a/views/openapiui.blade.php b/views/openapiui.blade.php new file mode 100644 index 00000000..b7f49bdb --- /dev/null +++ b/views/openapiui.blade.php @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + {{ $L('REST API & data model documentation') }} | grocy + + + + + + + +
+ + + + + + @if(file_exists(__DIR__ . '/../../data/add_before_end_body.html')) + @php include __DIR__ . '/../../data/add_before_end_body.html' @endphp + @endif + + diff --git a/views/products.blade.php b/views/products.blade.php index 3d6ed2ab..09fc99d3 100644 --- a/views/products.blade.php +++ b/views/products.blade.php @@ -9,7 +9,7 @@

@yield('title') - +  {{ $L('Add') }}

@@ -32,7 +32,7 @@ @foreach($products as $product) - + diff --git a/views/purchase.blade.php b/views/purchase.blade.php index 2e71fa90..27e1a291 100644 --- a/views/purchase.blade.php +++ b/views/purchase.blade.php @@ -20,7 +20,7 @@ @endforeach
-
will be added to the list of barcodes for the selected product on submit.
+
{{ $L('will be added to the list of barcodes for the selected product on submit') }}
@include('components.datepicker', array( diff --git a/views/quantityunits.blade.php b/views/quantityunits.blade.php index 86b69b5d..207139e1 100644 --- a/views/quantityunits.blade.php +++ b/views/quantityunits.blade.php @@ -9,7 +9,7 @@

@yield('title') - +  Add

@@ -27,7 +27,7 @@ @foreach($quantityunits as $quantityunit) - + diff --git a/views/shoppinglist.blade.php b/views/shoppinglist.blade.php index d21f4221..af532dfb 100644 --- a/views/shoppinglist.blade.php +++ b/views/shoppinglist.blade.php @@ -9,7 +9,7 @@

@yield('title') - +  {{ $L('Add') }} @@ -30,7 +30,7 @@ @foreach($listItems as $listItem) - + diff --git a/views/stockoverview.blade.php b/views/stockoverview.blade.php index b4a39690..98bfc5d6 100644 --- a/views/stockoverview.blade.php +++ b/views/stockoverview.blade.php @@ -7,7 +7,7 @@ @section('content')
-

{{ $L('Stock overview') }} {{ $L('#1 products with #2 units in stock', count($currentStock), SumArrayValue($currentStock, 'amount')) }}

+

{{ $L('Stock overview') }} {{ $L('#1 products with #2 units in stock', count($currentStock), SumArrayValue($currentStock, 'amount')) }}