From 29459f22dcd4026617c5f452728d3105a750866c Mon Sep 17 00:00:00 2001 From: Aaron Parecki Date: Sun, 15 May 2016 10:15:07 -0400 Subject: [PATCH] add photo support to Teacup posts photos to the media endpoint if set --- composer.json | 3 +- controllers/auth.php | 15 ++-- controllers/controllers.php | 38 ++++++++++ lib/helpers.php | 146 ++++++++++++++++++++++++++++-------- schema/migrations/0001.sql | 5 ++ schema/schema.sql | 0 views/new-post.php | 64 ++++++++++++++++ 7 files changed, 227 insertions(+), 44 deletions(-) create mode 100644 schema/migrations/0001.sql create mode 100644 schema/schema.sql diff --git a/composer.json b/composer.json index 88d96e8..5f14bcb 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,8 @@ "indieweb/date-formatter": "0.1.*", "indieauth/client": "0.1.*", "mpratt/relativetime": ">=1.0", - "firebase/php-jwt": "dev-master" + "firebase/php-jwt": "dev-master", + "p3k/multipart": "*" }, "autoload": { "files": [ diff --git a/controllers/auth.php b/controllers/auth.php index c4e08ce..09c8cde 100644 --- a/controllers/auth.php +++ b/controllers/auth.php @@ -250,14 +250,6 @@ $app->get('/auth/callback', function() use($app) { if(k($token['auth'], array('me','access_token','scope'))) { $_SESSION['auth'] = $token['auth']; $_SESSION['me'] = $params['me']; - - // TODO? - // This client requires the "post" scope. - - - // Make a request to the micropub endpoint to discover the syndication targets if any. - // Errors are silently ignored here. The user will be able to retry from the new post interface and get feedback. - // get_syndication_targets($user); } } else { @@ -281,9 +273,8 @@ $app->get('/auth/callback', function() use($app) { } } - // Verify the login actually succeeded - if(!array_key_exists('me', $_SESSION)) { + if(!k($token['auth'], 'me')) { $html = render('auth_error', array( 'title' => 'Sign-In Failed', 'error' => 'Unable to verify the sign-in attempt', @@ -316,6 +307,10 @@ $app->get('/auth/callback', function() use($app) { $_SESSION['user_id'] = $user->id(); + if($tokenEndpoint) { + // Make a request to the micropub endpoint to discover the media endpoint if set. + get_micropub_config($user); + } unset($_SESSION['auth_state']); diff --git a/controllers/controllers.php b/controllers/controllers.php index 4d721e2..4a62a5b 100644 --- a/controllers/controllers.php +++ b/controllers/controllers.php @@ -72,6 +72,7 @@ $app->get('/new', function() use($app) { $html = render('new-post', array( 'title' => 'New Post', 'micropub_endpoint' => $user->micropub_endpoint, + 'micropub_media_endpoint' => $user->micropub_media_endpoint, 'token_scope' => $user->token_scope, 'access_token' => $user->access_token, 'response_date' => $user->last_micropub_response_date, @@ -223,6 +224,10 @@ $app->post('/post', function() use($app) { $verb = 'ate'; } + if($user->micropub_media_endpoint && k($params, 'note_photo')) { + $entry->photo_url = $params['note_photo']; + } + $entry->type = $type; $entry->save(); @@ -240,6 +245,9 @@ $app->post('/post', function() use($app) { 'location' => k($params, 'location'), 'summary' => $text_content ); + if($entry->photo_url) { + $mp_request['photo'] = $entry->photo_url; + } if($user->enable_array_micropub) { $mp_request[$verb] = [ 'type' => 'h-food', @@ -281,6 +289,36 @@ $app->post('/post', function() use($app) { } }); +$app->post('/micropub/media', function() use($app) { + if($user=require_login($app)) { + $file = isset($_FILES['photo']) ? $_FILES['photo'] : null; + $error = validate_photo($file); + unset($_POST['null']); + + if(!$error) { + $file_path = $file['tmp_name']; + correct_photo_rotation($file_path); + $r = micropub_media_post($user->micropub_media_endpoint, $user->access_token, $file_path); + } else { + $r = array('error' => $error); + } + $response = $r['response']; + + $url = null; + if($response && preg_match('/Location: (.+)/', $response, $match)) { + $url = trim($match[1]); + } else { + $r['error'] = "No 'Location' header in response."; + } + + $app->response()['Content-type'] = 'application/json'; + $app->response()->body(json_encode(array( + 'location' => $url, + 'error' => (isset($r['error']) ? $r['error'] : null), + ))); + } +}); + $app->get('/options.json', function() use($app) { if($user=require_login($app)) { $params = $app->request()->params(); diff --git a/lib/helpers.php b/lib/helpers.php index d1bdd7d..d8826f2 100644 --- a/lib/helpers.php +++ b/lib/helpers.php @@ -95,6 +95,7 @@ if(!function_exists('http_build_url')) { return "$scheme$user$pass$host$port$path$query$fragment"; } } + function micropub_post($endpoint, $params, $access_token) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $endpoint); @@ -122,6 +123,33 @@ function micropub_post($endpoint, $params, $access_token) { ); } +function micropub_media_post($endpoint, $access_token, $file) { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $endpoint); + curl_setopt($ch, CURLOPT_HTTPHEADER, array( + 'Authorization: Bearer ' . $access_token + )); + curl_setopt($ch, CURLOPT_POST, true); + + $post = [ + 'photo' => new CURLFile($file) + ]; + + curl_setopt($ch, CURLOPT_POSTFIELDS, $post); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLINFO_HEADER_OUT, true); + $response = curl_exec($ch); + $error = curl_error($ch); + $sent_headers = curl_getinfo($ch, CURLINFO_HEADER_OUT); + + return array( + 'response' => $response, + 'error' => $error, + 'curlinfo' => curl_getinfo($ch) + ); +} + function micropub_get($endpoint, $params, $access_token) { $url = parse_url($endpoint); if(!k($url, 'query')) { @@ -134,13 +162,14 @@ function micropub_get($endpoint, $params, $access_token) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $endpoint); curl_setopt($ch, CURLOPT_HTTPHEADER, array( - 'Authorization: Bearer ' . $access_token + 'Authorization: Bearer ' . $access_token, + 'Accept: application/json', )); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); $data = array(); if($response) { - parse_str($response, $data); + $data = @json_decode($response, true); } $error = curl_error($ch); return array( @@ -151,43 +180,17 @@ function micropub_get($endpoint, $params, $access_token) { ); } -function get_syndication_targets(&$user) { +function get_micropub_config(&$user) { $targets = array(); - $r = micropub_get($user->micropub_endpoint, array('q'=>'syndicate-to'), $user->micropub_access_token); - if($r['data'] && array_key_exists('syndicate-to', $r['data'])) { - $targetURLs = preg_split('/, ?/', $r['data']['syndicate-to']); - foreach($targetURLs as $t) { - - // If the syndication target doesn't have a scheme, add http - if(!preg_match('/^http/', $t)) - $tmp = 'http://' . $t; - - // Parse the target expecting it to be a URL - $url = parse_url($tmp); - - // If there's a host, and the host contains a . then we can assume there's a favicon - // parse_url will parse strings like http://twitter into an array with a host of twitter, which is not resolvable - if(array_key_exists('host', $url) && strpos($url['host'], '.') !== false) { - $targets[] = array( - 'target' => $t, - 'favicon' => 'http://' . $url['host'] . '/favicon.ico' - ); - } else { - $targets[] = array( - 'target' => $t, - 'favicon' => false - ); - } - } - } - if(count($targets)) { - $user->syndication_targets = json_encode($targets); + $r = micropub_get($user->micropub_endpoint, [], $user->access_token); + + if(array_key_exists('media_endpoint', $r['data'])) { + $user->micropub_media_endpoint = $r['data']['media_endpoint']; $user->save(); } return array( - 'targets' => $targets, 'response' => $r ); } @@ -255,6 +258,83 @@ function entry_date($entry, $user) { // return $date; } +function validate_photo(&$file) { + try { + + if ($_SERVER['REQUEST_METHOD'] == 'POST' && count($_POST) < 1 ) { + throw new RuntimeException('File upload size exceeded.'); + } + + // Undefined | Multiple Files | $_FILES Corruption Attack + // If this request falls under any of them, treat it invalid. + if ( + !isset($file['error']) || + is_array($file['error']) + ) { + throw new RuntimeException('Invalid parameters.'); + } + + // Check $file['error'] value. + switch ($file['error']) { + case UPLOAD_ERR_OK: + break; + case UPLOAD_ERR_NO_FILE: + throw new RuntimeException('No file sent.'); + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + throw new RuntimeException('Exceeded filesize limit.'); + default: + throw new RuntimeException('Unknown errors.'); + } + + // You should also check filesize here. + if ($file['size'] > 4000000) { + throw new RuntimeException('Exceeded filesize limit.'); + } + + // DO NOT TRUST $file['mime'] VALUE !! + // Check MIME Type by yourself. + $finfo = new finfo(FILEINFO_MIME_TYPE); + if (false === $ext = array_search( + $finfo->file($file['tmp_name']), + array( + 'jpg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + ), + true + )) { + throw new RuntimeException('Invalid file format.'); + } + + } catch (RuntimeException $e) { + + return $e->getMessage(); + } +} + +// Reads the exif rotation data and actually rotates the photo. +// Only does anything if the exif library is loaded, otherwise is a noop. +function correct_photo_rotation($filename) { + if(class_exists('IMagick')) { + $image = new IMagick($filename); + $orientation = $image->getImageOrientation(); + switch($orientation) { + case IMagick::ORIENTATION_BOTTOMRIGHT: + $image->rotateImage(new ImagickPixel('#00000000'), 180); + break; + case IMagick::ORIENTATION_RIGHTTOP: + $image->rotateImage(new ImagickPixel('#00000000'), 90); + break; + case IMagick::ORIENTATION_LEFTBOTTOM: + $image->rotateImage(new ImagickPixel('#00000000'), -90); + break; + } + $image->setImageOrientation(IMagick::ORIENTATION_TOPLEFT); + $image->writeImage($filename); + } +} + function default_drink_options() { return [ ['title'=>'Coffee','subtitle'=>'','type'=>'drink'], diff --git a/schema/migrations/0001.sql b/schema/migrations/0001.sql new file mode 100644 index 0000000..b4537cd --- /dev/null +++ b/schema/migrations/0001.sql @@ -0,0 +1,5 @@ +ALTER TABLE users +ADD COLUMN `micropub_media_endpoint` VARCHAR(255) NOT NULL DEFAULT '' AFTER `micropub_endpoint`; + +ALTER TABLE entries +ADD COLUMN `photo_url` VARCHAR(255) NOT NULL DEFAULT '' AFTER `canonical_url`; diff --git a/schema/schema.sql b/schema/schema.sql new file mode 100644 index 0000000..e69de29 diff --git a/views/new-post.php b/views/new-post.php index e58f642..e5ad04b 100644 --- a/views/new-post.php +++ b/views/new-post.php @@ -11,6 +11,26 @@ + micropub_media_endpoint): ?> +
+

Photo

+ + + + + + +
+ +
@@ -97,6 +117,14 @@ $(function(){ return num; } + function replacePhotoWithPhotoURL(url) { + $("#note_photo").val(url); + $("#note_photo_upload").addClass("hidden"); + $("#note_photo_loading").addClass("hidden"); + $("#photo_preview").attr("src", url); + $("#photo_preview_container").removeClass("hidden"); + } + function bind_keyboard_shortcuts() { $(".text-custom-eat").keydown(function(e){ if(e.keyCode == 13) { @@ -204,6 +232,42 @@ $(function(){ }); } + $("#photo_preview_container").addClass("hidden"); + $("#note_photo_upload").on("change", function(e){ + + $("#note_photo_upload").addClass("hidden"); + $("#note_photo_loading").removeClass("hidden"); + + var formData = new FormData(); + formData.append("null","null"); + formData.append("photo", e.target.files[0]); + var request = new XMLHttpRequest(); + request.open("POST", "/micropub/media"); + request.onreadystatechange = function() { + if(request.readyState == XMLHttpRequest.DONE) { + try { + var response = JSON.parse(request.responseText); + if(response.location) { + // Replace the file upload form with the URL + replacePhotoWithPhotoURL(response.location); + saveNoteState(); + } else { + console.log("Endpoint did not return a location header", response); + } + } catch(e) { + console.log(e); + } + } + } + request.send(formData); + }); + $("#remove_photo").on("click", function(){ + $("#note_photo").val(""); + $("#note_photo_upload").removeClass("hidden").val(""); + $("#photo_preview").attr("src", "" ); + $("#photo_preview_container").addClass("hidden"); + }); + /////////////////////////////////////////////////////////////// // App Start