diff --git a/composer.json b/composer.json index 46caa25..6973539 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ "indieauth/client": "0.1.*", "mpratt/relativetime": ">=1.0", "firebase/php-jwt": "^4.0", - "p3k/multipart": "*" + "p3k/multipart": "*", + "minicodemonkey/amazon-alexa-php": "^0.1.5" }, "autoload": { "files": [ diff --git a/composer.lock b/composer.lock index 9412a67..1b534bc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "c399b5d1b32e020f809404e3bfa32275", - "content-hash": "63122eac6f996b58bc73ed1359b12099", + "hash": "ebaa6e4aed4c7cfa5a835cbe2b92e629", + "content-hash": "4b3b194b582b716faa00f4da8ce8859b", "packages": [ { "name": "barnabywalters/mf-cleaner", @@ -329,6 +329,45 @@ ], "time": "2015-07-12 14:10:01" }, + { + "name": "minicodemonkey/amazon-alexa-php", + "version": "0.1.5", + "source": { + "type": "git", + "url": "https://github.com/MiniCodeMonkey/amazon-alexa-php.git", + "reference": "006a1e1e775d8429574cde1a093a8b9a4da6a960" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MiniCodeMonkey/amazon-alexa-php/zipball/006a1e1e775d8429574cde1a093a8b9a4da6a960", + "reference": "006a1e1e775d8429574cde1a093a8b9a4da6a960", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Alexa\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Hansen", + "email": "me@codemonkey.io" + } + ], + "description": "Amazon Alexa interface for PHP", + "time": "2015-11-29 21:50:59" + }, { "name": "mpratt/relativetime", "version": "1.5.4", diff --git a/controllers/alexa.php b/controllers/alexa.php new file mode 100644 index 0000000..503bc11 --- /dev/null +++ b/controllers/alexa.php @@ -0,0 +1,257 @@ +get('/alexa', function() use($app) { + render('alexa', array( + 'title' => 'Teacup for Alexa' + )); +}); + +$app->get('/alexa/auth', function() use($app) { + $req = $app->request(); + $params = $req->params(); + + $required = ['client_id', 'response_type', 'state', 'redirect_uri']; + $params_present = array_keys($params); + + // Validate Alexa OAuth parameters + if(count(array_intersect($required, $params_present)) != count($required)) { + render('auth_error', array( + 'title' => 'Sign In', + 'error' => 'Missing parameters', + 'errorDescription' => 'One or more required parameters were missing', + 'footer' => false + )); + return; + } + + // Check that redirect URI is one that is allowed + if(!in_array($params['redirect_uri'], Config::$alexaRedirectURIs)) { + render('auth_error', array( + 'title' => 'Sign In', + 'error' => 'Invalid redirect URI', + 'errorDescription' => 'Alexa sent an invalid redirect URI', + 'footer' => false + )); + return; + } + + if($params['client_id'] != Config::$alexaClientID) { + render('auth_error', array( + 'title' => 'Sign In', + 'error' => 'Invalid Client ID', + 'errorDescription' => 'Alexa sent an invalid client ID', + 'footer' => false + )); + return; + } + + // Pass through the OAuth parameters + render('alexa-auth', [ + 'title' => 'Teacup for Alexa', + 'client_id' => $params['client_id'], + 'response_type' => $params['response_type'], + 'state' => $params['state'], + 'redirect_uri' => $params['redirect_uri'], + 'footer' => false + ]); +}); + +$app->post('/alexa/login', function() use($app) { + $req = $app->request(); + $params = $req->params(); + + file_put_contents('logs/login.txt', json_encode($params)); + + $required = ['code', 'client_id', 'state', 'redirect_uri']; + $params_present = array_keys($params); + + if(count(array_intersect($required, $params_present)) != count($required)) { + render('auth_error', array( + 'title' => 'Sign In', + 'error' => 'Missing parameters', + 'errorDescription' => 'One or more required parameters were missing', + 'footer' => false + )); + return; + } + + $user = ORM::for_table('users') + ->where('device_code', $params['code']) + ->where_gt('device_code_expires', date('Y-m-d H:i:s'))->find_one(); + + if(!$user) { + render('auth_error', array( + 'title' => 'Sign In', + 'error' => 'Invalid code', + 'errorDescription' => 'The code you entered is invalid or has expired', + 'footer' => false + )); + return; + } + + $code = JWT::encode(array( + 'user_id' => $user->id, + 'iat' => time(), + 'exp' => time()+300, + 'client_id' => $params['client_id'], + 'state' => $params['state'], + 'redirect_uri' => $params['redirect_uri'], + ), Config::$jwtSecret); + + $redirect = $params['redirect_uri'] . '?code=' . $code . '&state=' . $params['state']; + + $app->redirect($redirect, 302); +}); + +$app->post('/alexa/token', function() use($app) { + $req = $app->request(); + $params = $req->params(); + // Alexa requests a token given a code generated above + + // Verify the client ID and secret + if($params['client_id'] != Config::$alexaClientID + || $params['client_secret'] != Config::$alexaClientSecret) { + $app->response->setStatus(400); + $app->response()['Content-type'] = 'application/json'; + $app->response()->body(json_encode([ + 'error' => 'forbidden', + 'error_description' => 'The client ID and secret do not match' + ])); + return; + } + + if(array_key_exists('code', $params)) { + $jwt = $params['code']; + } elseif(array_key_exists('refresh_token', $params)) { + $jwt = $params['refresh_token']; + } else { + $app->response->setStatus(400); + $app->response()['Content-type'] = 'application/json'; + $app->response()->body(json_encode([ + 'error' => 'bad_request', + 'error_description' => 'Must provide either an authorization code or refresh token' + ])); + return; + } + + // Validate the JWT + try { + $user = JWT::decode($jwt, Config::$jwtSecret, ['HS256']); + } catch(Exception $e) { + $app->response->setStatus(400); + $app->response()['Content-type'] = 'application/json'; + $app->response()->body(json_encode([ + 'error' => 'unauthorized', + 'error_description' => 'The authorization code or refresh token was invalid' + ])); + return; + } + + // Generate an access token and refresh token + $access_token = JWT::encode([ + 'user_id' => $user->user_id, + 'client_id' => $user->client_id, + 'iat' => time(), + ], Config::$jwtSecret); + $refresh_token = JWT::encode([ + 'user_id' => $user->user_id, + 'client_id' => $user->client_id, + 'iat' => time(), + ], Config::$jwtSecret); + + + $app->response()['Content-type'] = 'application/json'; + $app->response()->body(json_encode([ + 'access_token' => $access_token, + 'refresh_token' => $refresh_token + ])); +}); + + +$app->post('/alexa/endpoint', function() use($app) { + + $input = file_get_contents('php://input'); + $json = json_decode($input, 'input'); + + $alexaRequest = \Alexa\Request\Request::fromData($json); + + if($alexaRequest instanceof Alexa\Request\IntentRequest) { + # file_put_contents('logs/request.txt', $input); + + # Verify the access token + try { + $data = JWT::decode($alexaRequest->user->accessToken, Config::$jwtSecret, ['HS256']); + } catch(Exception $e) { + $app->response->setStatus(401); + $app->response()['Content-type'] = 'application/json'; + $app->response()->body(json_encode([ + 'error' => 'unauthorized', + 'error_description' => 'The access token was invalid or has expired' + ])); + return; + } + + $user = ORM::for_table('users')->find_one($data->user_id); + + if(!$user) { + $app->response->setStatus(400); + return; + } + + $action = $alexaRequest->slots['Action']; + $food = ucfirst($alexaRequest->slots['Food']); + + + $entry = ORM::for_table('entries')->create(); + $entry->user_id = $user->id; + $entry->type = ($action == 'drank' ? 'drink' : 'eat'); + $entry->content = $food; + $entry->published = date('Y-m-d H:i:s'); + $entry->save(); + + $text_content = 'Just ' . $action . ': ' . $food; + + if($user->micropub_endpoint) { + $mp_request = array( + 'h' => 'entry', + 'published' => date('Y-m-d H:i:s'), + 'summary' => $text_content + ); + if($user->enable_array_micropub) { + $mp_request[$action] = [ + 'type' => 'h-food', + 'properties' => [ + 'name' => $food + ] + ]; + } else { + $mp_request['p3k-food'] = $food; + $mp_request['p3k-type'] = $entry->type; + } + + $r = micropub_post($user->micropub_endpoint, $mp_request, $user->access_token); + $request = $r['request']; + $response = $r['response']; + + $entry->micropub_response = $response; + if($response && preg_match('/Location: (.+)/', $response, $match)) { + $url = $match[1]; + $entry->micropub_success = 1; + $entry->canonical_url = $url; + } else { + $entry->micropub_success = 0; + $url = Config::$base_url . $user->url . '/' . $entry->id; + } + $entry->save(); + } + + + $response = new \Alexa\Response\Response; + $response->respond('Got it!') + ->withCard('You '.$action.': '.$food); + + $app->response()['Content-type'] = 'application/json'; + $app->response()->body(json_encode($response->render())); + } +}); diff --git a/controllers/controllers.php b/controllers/controllers.php index 9e94902..03b3cc9 100644 --- a/controllers/controllers.php +++ b/controllers/controllers.php @@ -76,8 +76,24 @@ $app->get('/new', function() use($app) { $app->get('/settings', function() use($app) { if($user=require_login($app)) { - $html = - $app->response()->body($html); + render('settings', [ + 'title' => 'Settings', + ]); + } +}); + +$app->post('/settings/device-code', function() use($app) { + if($user=require_login($app)) { + $code = mt_rand(100000,999999); + + $user->device_code = $code; + $user->device_code_expires = date('Y-m-d H:i:s', time()+300); + $user->save(); + + $app->response()['Content-Type'] = 'application/json'; + $app->response()->body(json_encode([ + 'code' => $code + ])); } }); @@ -111,6 +127,10 @@ $app->get('/docs', function() use($app) { render('docs', array('title' => 'Documentation')); }); +$app->get('/privacy', function() use($app) { + render('privacy', array('title' => 'Privacy Policy')); +}); + $app->get('/add-to-home', function() use($app) { $params = $app->request()->params(); header("Cache-Control: no-cache, must-revalidate"); diff --git a/lib/config.template.php b/lib/config.template.php index 3f200dc..1c70676 100644 --- a/lib/config.template.php +++ b/lib/config.template.php @@ -12,5 +12,12 @@ class Config { public static $jwtSecret = 'xxx'; public static $mf2Debug = false; + + public static $alexaClientID = ''; + public static $alexaClientSecret = ''; + public static $alexaRedirectURIs = [ + '', + '' + ]; } diff --git a/public/images/teacup-alexa-512.png b/public/images/teacup-alexa-512.png new file mode 100644 index 0000000..f3ea1dc Binary files /dev/null and b/public/images/teacup-alexa-512.png differ diff --git a/public/images/teacup-icon-108.png b/public/images/teacup-icon-108.png new file mode 100644 index 0000000..70564f0 Binary files /dev/null and b/public/images/teacup-icon-108.png differ diff --git a/public/index.php b/public/index.php index d183852..4c9b68b 100644 --- a/public/index.php +++ b/public/index.php @@ -14,6 +14,7 @@ $app = new \Slim\Slim(array( require 'controllers/auth.php'; require 'controllers/controllers.php'; require 'controllers/pebble.php'; +require 'controllers/alexa.php'; session_name('teacup'); session_set_cookie_params(86400*30); diff --git a/schema/migrations/0002.sql b/schema/migrations/0002.sql new file mode 100644 index 0000000..a1c9ca0 --- /dev/null +++ b/schema/migrations/0002.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +ADD COLUMN `device_code` varchar(10) DEFAULT NULL, +ADD COLUMN `device_code_expires` datetime DEFAULT NULL; diff --git a/schema/schema.sql b/schema/schema.sql index c45d4cc..622bf55 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -16,6 +16,8 @@ CREATE TABLE `users` ( `date_created` datetime DEFAULT NULL, `last_login` datetime DEFAULT NULL, `enable_array_micropub` tinyint(4) NOT NULL DEFAULT '1', + `device_code` varchar(10) DEFAULT NULL, + `device_code_expires` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/views/alexa-auth.php b/views/alexa-auth.php new file mode 100644 index 0000000..d0f1c2c --- /dev/null +++ b/views/alexa-auth.php @@ -0,0 +1,19 @@ +
+ + +

Sign in to Teacup

+ +

Go to teacup.p3k.io on your phone or computer and sign in.

+

Then go to the settings page to get a "device code", and enter that code here.

+ +
+
+ +
+ + + + +
+ +
\ No newline at end of file diff --git a/views/partials/footer.php b/views/partials/footer.php index 103d524..95418a7 100644 --- a/views/partials/footer.php +++ b/views/partials/footer.php @@ -11,7 +11,7 @@ -

© by Aaron Parecki. +

© by Aaron Parecki. This code is open source. Feel free to send a pull request, or file an issue.

- \ No newline at end of file + diff --git a/views/privacy.php b/views/privacy.php new file mode 100644 index 0000000..3b84b11 --- /dev/null +++ b/views/privacy.php @@ -0,0 +1,8 @@ +
+ + +

Privacy Policy

+ + + +
\ No newline at end of file diff --git a/views/settings.php b/views/settings.php new file mode 100644 index 0000000..0b1a6fb --- /dev/null +++ b/views/settings.php @@ -0,0 +1,36 @@ +
+ +
+ +

Device Code

+

If you are prompted to log in on a device, click the button below to generate a device code.

+ +
+ +
+ +
+ +
+ +