diff --git a/app/Http/Controllers/LikesController.php b/app/Http/Controllers/LikesController.php new file mode 100644 index 00000000..02922e86 --- /dev/null +++ b/app/Http/Controllers/LikesController.php @@ -0,0 +1,20 @@ +paginate(20); + + return view('likes.index', compact('likes')); + } + + public function show(Like $like) + { + return view('likes.show', compact('like')); + } +} diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index c6498a69..36cba4ab 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -6,8 +6,9 @@ use Storage; use Monolog\Logger; use Ramsey\Uuid\Uuid; use App\Jobs\ProcessImage; -use App\{Media, Note, Place}; +use App\Services\LikeService; use Monolog\Handler\StreamHandler; +use App\{Like, Media, Note, Place}; use Intervention\Image\ImageManager; use Illuminate\Http\{Request, Response}; use App\Exceptions\InvalidTokenException; @@ -73,6 +74,14 @@ class MicropubController extends Controller if (stristr($tokenData->getClaim('scope'), 'create') === false) { return $this->returnInsufficientScopeResponse(); } + if ($request->has('properties.like-of') || $request->has('like-of')) { + $like = (new LikeService())->createLike($request); + + return response()->json([ + 'response' => 'created', + 'location' => config('app.url') . "/likes/$like->id", + ], 201)->header('Location', config('app.url') . "/likes/$like->id"); + } $data = []; $data['client-id'] = $tokenData->getClaim('client_id'); if ($request->header('Content-Type') == 'application/json') { diff --git a/app/Http/Controllers/NotesController.php b/app/Http/Controllers/NotesController.php index dbf5adc7..9801c8ea 100644 --- a/app/Http/Controllers/NotesController.php +++ b/app/Http/Controllers/NotesController.php @@ -22,14 +22,13 @@ class NotesController extends Controller return (new ActivityStreamsService)->siteOwnerResponse(); } - $notes = Note::orderBy('id', 'desc') + $notes = Note::latest() ->with('place', 'media', 'client') ->withCount(['webmentions As replies' => function ($query) { $query->where('type', 'in-reply-to'); }])->paginate(10); - $aslink = config('app.url'); - return view('notes.index', compact('notes', 'aslink')); + return view('notes.index', compact('notes')); } /** @@ -46,9 +45,7 @@ class NotesController extends Controller return (new ActivityStreamsService)->singleNoteResponse($note); } - $aslink = $note->longurl; - - return view('notes.show', compact('note', 'aslink')); + return view('notes.show', compact('note')); } /** diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index b2fa4ba4..1f67d385 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -38,6 +38,7 @@ class Kernel extends HttpKernel \App\Http\Middleware\LinkHeadersMiddleware::class, //\App\Http\Middleware\DevTokenMiddleware::class, \App\Http\Middleware\LocalhostSessionMiddleware::class, + \App\Http\Middleware\ActivityStreamLinks::class, ], 'api' => [ diff --git a/app/Http/Middleware/ActivityStreamLinks.php b/app/Http/Middleware/ActivityStreamLinks.php new file mode 100644 index 00000000..4c240759 --- /dev/null +++ b/app/Http/Middleware/ActivityStreamLinks.php @@ -0,0 +1,28 @@ +path() === '/') { + $response->header('Link', '<' . config('app.url') . '>; rel="application/activity+json"', false); + } + if ($request->is('notes/*')) { + $response->header('Link', '<' . $request->url() . '>; rel="application/activity+json"', false); + } + + return $response; + } +} diff --git a/app/Jobs/ProcessLike.php b/app/Jobs/ProcessLike.php new file mode 100644 index 00000000..84fffa7d --- /dev/null +++ b/app/Jobs/ProcessLike.php @@ -0,0 +1,59 @@ +like = $like; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle(Client $client, Authorship $authorship) + { + $response = $client->request('GET', $this->like->url); + $mf2 = \Mf2\parse((string) $response->getBody(), $this->like->url); + if (array_has($mf2, 'items.0.properties.content')) { + $this->like->content = $mf2['items'][0]['properties']['content'][0]['html']; + } + + try { + $author = $authorship->findAuthor($mf2); + if (is_array($author)) { + $this->like->author_name = $author['name']; + $this->like->author_url = $author['url']; + } + if (is_string($author) && $author !== '') { + $this->like->author_name = $author; + } + } catch (AuthorshipParserException $exception) { + return; + } + + $this->like->save(); + } +} diff --git a/app/Jobs/SyndicateToFacebook.php b/app/Jobs/SyndicateToFacebook.php index c8826396..c117ae13 100644 --- a/app/Jobs/SyndicateToFacebook.php +++ b/app/Jobs/SyndicateToFacebook.php @@ -40,6 +40,7 @@ class SyndicateToFacebook implements ShouldQueue 'form_params' => [ 'source' => $this->note->longurl, 'target' => 'https://brid.gy/publish/facebook', + 'bridgy_omit_link' => 'maybe', ], ] ); diff --git a/app/Jobs/SyndicateToTwitter.php b/app/Jobs/SyndicateToTwitter.php index fb91dc4b..1d50f904 100644 --- a/app/Jobs/SyndicateToTwitter.php +++ b/app/Jobs/SyndicateToTwitter.php @@ -41,6 +41,7 @@ class SyndicateToTwitter implements ShouldQueue 'form_params' => [ 'source' => $this->note->longurl, 'target' => 'https://brid.gy/publish/twitter', + 'bridgy_omit_link' => 'maybe', ], ] ); diff --git a/app/Like.php b/app/Like.php new file mode 100644 index 00000000..aae728e6 --- /dev/null +++ b/app/Like.php @@ -0,0 +1,44 @@ +attributes['url'] = normalize_url($value); + } + + public function setAuthorUrlAttribute($value) + { + $this->attributes['author_url'] = normalize_url($value); + } + + public function getContentAttribute($value) + { + if ($value === null) { + return $this->url; + } + + $mf2 = Mf2\parse($value, $this->url); + + return $this->filterHTML($mf2['items'][0]['properties']['content'][0]['html']); + } + + public function filterHTML($html) + { + $config = HTMLPurifier_Config::createDefault(); + $config->set('Cache.SerializerPath', storage_path() . '/HTMLPurifier'); + $config->set('HTML.TargetBlank', true); + $purifier = new HTMLPurifier($config); + + return $purifier->purify($html); + } +} diff --git a/app/Services/LikeService.php b/app/Services/LikeService.php new file mode 100644 index 00000000..d4ff6f76 --- /dev/null +++ b/app/Services/LikeService.php @@ -0,0 +1,37 @@ +header('Content-Type') == 'application/json') { + //micropub request + $url = normalize_url($request->input('properties.like-of.0')); + } + if ( + ($request->header('Content-Type') == 'x-www-url-formencoded') + || + ($request->header('Content-Type') == 'multipart/form-data') + ) { + $url = normalize_url($request->input('like-of')); + } + + $like = Like::create(['url' => $url]); + ProcessLike::dispatch($like); + + return $like; + } +} diff --git a/changelog.md b/changelog.md index 56bd18b4..e1100688 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## Version 0.9 (2017-10-06) + - Add support for `likes` (issue#69) + - Only included links on truncated syndicated notes https://brid.gy/about#omit-link + +## Version 0.8.1 (2017-09-16) + - Order notes by latest (issue#70) + - AcitivtyStream support is now indicated with HTTP Link headers + ## Version 0.8 (2017-09-16) - Improve embedding of tweets (issue#66) - Allow for “responsive” images (issue#62) diff --git a/composer.lock b/composer.lock index 3f639063..e4d03966 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "aws/aws-sdk-php", - "version": "3.36.7", + "version": "3.36.9", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "421088947540b1c7956cd693b032124e2c74eb76" + "reference": "7b89fa65cccb966da1599b715dcea8c09eafc175" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/421088947540b1c7956cd693b032124e2c74eb76", - "reference": "421088947540b1c7956cd693b032124e2c74eb76", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7b89fa65cccb966da1599b715dcea8c09eafc175", + "reference": "7b89fa65cccb966da1599b715dcea8c09eafc175", "shasum": "" }, "require": { @@ -84,7 +84,7 @@ "s3", "sdk" ], - "time": "2017-09-13T18:56:17+00:00" + "time": "2017-09-15T19:12:04+00:00" }, { "name": "barnabywalters/mf-cleaner", @@ -2085,16 +2085,16 @@ }, { "name": "laravel/scout", - "version": "v3.0.9", + "version": "v3.0.10", "source": { "type": "git", "url": "https://github.com/laravel/scout.git", - "reference": "84762c8ed51cb57f09b5f465e09993e48baf9d55" + "reference": "681c15a26bbc973528af2e77e0bb61981dc07206" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/scout/zipball/84762c8ed51cb57f09b5f465e09993e48baf9d55", - "reference": "84762c8ed51cb57f09b5f465e09993e48baf9d55", + "url": "https://api.github.com/repos/laravel/scout/zipball/681c15a26bbc973528af2e77e0bb61981dc07206", + "reference": "681c15a26bbc973528af2e77e0bb61981dc07206", "shasum": "" }, "require": { @@ -2146,7 +2146,7 @@ "laravel", "search" ], - "time": "2017-09-13T18:24:31+00:00" + "time": "2017-09-14T12:32:30+00:00" }, { "name": "laravel/tinker", diff --git a/database/factories/LikeFactory.php b/database/factories/LikeFactory.php new file mode 100644 index 00000000..ad3e9551 --- /dev/null +++ b/database/factories/LikeFactory.php @@ -0,0 +1,12 @@ +define(App\Like::class, function (Faker $faker) { + return [ + 'url' => $faker->url, + 'author_name' => $faker->name, + 'author_url' => $faker->url, + 'content' => '
' . $faker->realtext() . '
', + ]; +}); diff --git a/database/migrations/2017_09_16_191741_create_likes_table.php b/database/migrations/2017_09_16_191741_create_likes_table.php new file mode 100644 index 00000000..5f413c82 --- /dev/null +++ b/database/migrations/2017_09_16_191741_create_likes_table.php @@ -0,0 +1,35 @@ +increments('id'); + $table->string('url'); + $table->string('author_name')->nullable(); + $table->string('author_url')->nullable(); + $table->text('content')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('likes'); + } +} diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php index 8e906692..bb2d4f85 100644 --- a/database/seeds/DatabaseSeeder.php +++ b/database/seeds/DatabaseSeeder.php @@ -18,5 +18,6 @@ class DatabaseSeeder extends Seeder $this->call(NotesTableSeeder::class); $this->call(WebMentionsTableSeeder::class); $this->call(IndieWebUserTableSeeder::class); + $this->call(LikesTableSeeder::class); } } diff --git a/database/seeds/LikesTableSeeder.php b/database/seeds/LikesTableSeeder.php new file mode 100644 index 00000000..59c5af07 --- /dev/null +++ b/database/seeds/LikesTableSeeder.php @@ -0,0 +1,16 @@ +create(); + } +} diff --git a/database/seeds/NotesTableSeeder.php b/database/seeds/NotesTableSeeder.php index 334ba2df..3509ae8a 100644 --- a/database/seeds/NotesTableSeeder.php +++ b/database/seeds/NotesTableSeeder.php @@ -12,6 +12,7 @@ class NotesTableSeeder extends Seeder public function run() { factory(App\Note::class, 10)->create(); + sleep(1); $noteWithPlace = App\Note::create([ 'note' => 'Having a #beer at the local. 🍺', 'tweet_id' => '123456789', @@ -19,17 +20,21 @@ class NotesTableSeeder extends Seeder $place = App\Place::find(1); $noteWithPlace->place()->associate($place); $noteWithPlace->save(); + sleep(1); $noteWithContact = App\Note::create([ 'note' => 'Hi @tantek' ]); + sleep(1); $noteWithContactPlusPic = App\Note::create([ 'note' => 'Hi @aaron', 'client_id' => 'https://jbl5.dev/notes/new' ]); + sleep(1); $noteWithoutContact = App\Note::create([ 'note' => 'Hi @bob', 'client_id' => 'https://quill.p3k.io' ]); + sleep(1); //copy aaron’s profile pic in place $spl = new SplFileInfo(public_path() . '/assets/profile-images/aaronparecki.com'); if ($spl->isDir() === false) { @@ -40,6 +45,7 @@ class NotesTableSeeder extends Seeder 'note' => 'Note from somehwere', 'location' => '53.499,-2.379' ]); + sleep(1); $noteSyndicated = App\Note::create([ 'note' => 'This note has all the syndication targets', 'tweet_id' => '123456', diff --git a/resources/views/likes/index.blade.php b/resources/views/likes/index.blade.php new file mode 100644 index 00000000..8c114d98 --- /dev/null +++ b/resources/views/likes/index.blade.php @@ -0,0 +1,27 @@ +@extends('master') + +@section('title') +Likes « +@stop + +@section('content') +
+@foreach($likes as $like) +
+
+ Liked a post by + + @isset($like->author_url) + {{ $like->author_name }} + @else + {{ $like->author_name }} + @endisset + : +
+ {!! $like->content !!} +
+
+
+@endforeach +
+@stop diff --git a/resources/views/likes/show.blade.php b/resources/views/likes/show.blade.php new file mode 100644 index 00000000..ca06c018 --- /dev/null +++ b/resources/views/likes/show.blade.php @@ -0,0 +1,23 @@ +@extends('master') + +@section('title') +Like « +@stop + +@section('content') +
+
+ Liked a post by + + @isset($like->author_url) + {{ $like->author_name }} + @else + {{ $like->author_name }} + @endisset + : +
+ {!! $like->content !!} +
+
+
+@stop diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index 0c9855dc..fbe53414 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -12,8 +12,6 @@ -@isset($aslink) -@endisset diff --git a/routes/web.php b/routes/web.php index ebb209a0..ae1713bb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -108,6 +108,12 @@ Route::group(['domain' => config('url.longurl')], function () { }); Route::get('note/{id}', 'NotesController@redirect'); // for legacy note URLs + // Likes + Route::group(['prefix' => 'likes'], function () { + Route::get('/', 'LikesController@index'); + Route::get('/{like}', 'LikesController@show'); + }); + // Micropub Client Route::group(['prefix' => 'micropub'], function () { Route::get('/create', 'MicropubClientController@create')->name('micropub-client'); diff --git a/tests/Feature/LikesTest.php b/tests/Feature/LikesTest.php new file mode 100644 index 00000000..e33f3d12 --- /dev/null +++ b/tests/Feature/LikesTest.php @@ -0,0 +1,88 @@ +get('/likes'); + $response->assertViewIs('likes.index'); + } + + public function test_single_like_page() + { + $response = $this->get('/likes'); + $response->assertViewIs('likes.index'); + } + + public function test_like_micropub_request() + { + Queue::fake(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->getToken(), + ])->json('POST', '/api/post', [ + 'type' => ['h-entry'], + 'properties' => [ + 'like-of' => ['https://example.org/blog-post'], + ], + ]); + + $response->assertJson(['response' => 'created']); + + Queue::assertPushed(ProcessLike::class); + $this->assertDatabaseHas('likes', ['url' => 'https://example.org/blog-post']); + } + + public function test_process_like_job() + { + $like = new Like(); + $like->url = 'http://example.org/note/id'; + $like->save(); + $id = $like->id; + + $job = new ProcessLike($like); + + $content = << + +
+
+ A post that I like. +
+ by Fred Bloggs +
+ + +END; + $mock = new MockHandler([ + new Response(200, [], $content), + new Response(200, [], $content), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $this->app->bind(Client::class, $client); + $authorship = new Authorship(); + + $job->handle($client, $authorship); + + $this->assertEquals('Fred Bloggs', Like::find($id)->author_name); + } +} diff --git a/tests/Feature/MicropubClientControllerTest.php b/tests/Feature/MicropubClientControllerTest.php index b1283793..315216a6 100644 --- a/tests/Feature/MicropubClientControllerTest.php +++ b/tests/Feature/MicropubClientControllerTest.php @@ -38,6 +38,7 @@ class MicropubClientControllerTest extends TestCase 'mp-syndicate-to' => ['https://twitter.com/jonnybarnes', 'https://facebook.com/jonnybarnes'], ] ); + $expected = '{"type":["h-entry"],"properties":{"content":["Hello Fred"],"in-reply-to":["https:\/\/fredbloggs.com\/note\/abc"],"mp-syndicate-to":["https:\/\/twitter.com\/jonnybarnes","https:\/\/facebook.com\/jonnybarnes"]}}'; foreach ($container as $transaction) { diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 41720505..925d0932 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -3,13 +3,14 @@ namespace Tests\Feature; use Tests\TestCase; +use Tests\TestToken; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Signer\Hmac\Sha256; use Illuminate\Foundation\Testing\DatabaseTransactions; class MicropubControllerTest extends TestCase { - use DatabaseTransactions; + use DatabaseTransactions, TestToken; /** * Test a GET request for the micropub endpoint without a token gives a @@ -314,42 +315,4 @@ class MicropubControllerTest extends TestCase 'swarm_url' => 'https://www.swarmapp.com/checkin/123' ]); } - - /** - * Generate a valid token to be used in the tests. - * - * @return Lcobucci\JWT\Token\Plain $token - */ - private function getToken() - { - $signer = new Sha256(); - $token = (new Builder()) - ->set('client_id', 'https://quill.p3k.io') - ->set('me', 'https://jonnybarnes.localhost') - ->set('scope', 'create update') - ->set('issued_at', time()) - ->sign($signer, env('APP_KEY')) - ->getToken(); - - return $token; - } - - /** - * Generate an invalid token to be used in the tests. - * - * @return Lcobucci\JWT\Token\Plain $token - */ - private function getInvalidToken() - { - $signer = new Sha256(); - $token = (new Builder()) - ->set('client_id', 'https://quill.p3k.io') - ->set('me', 'https://jonnybarnes.localhost') - ->set('scope', 'view') //error here - ->set('issued_at', time()) - ->sign($signer, env('APP_KEY')) - ->getToken(); - - return $token; - } } diff --git a/tests/Feature/OwnYourGramTest.php b/tests/Feature/OwnYourGramTest.php index ca0c3549..e071b511 100644 --- a/tests/Feature/OwnYourGramTest.php +++ b/tests/Feature/OwnYourGramTest.php @@ -3,13 +3,14 @@ namespace Tests\Feature; use Tests\TestCase; +use Tests\TestToken; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Signer\Hmac\Sha256; use Illuminate\Foundation\Testing\DatabaseTransactions; class OwnYourGramTest extends TestCase { - use DatabaseTransactions; + use DatabaseTransactions, TestToken; public function test_ownyourgram_post() { @@ -40,18 +41,4 @@ class OwnYourGramTest extends TestCase 'instagram_url' => 'https://www.instagram.com/p/BVC_nVTBFfi/' ]); } - - private function getToken() - { - $signer = new Sha256(); - $token = (new Builder()) - ->set('client_id', 'https://ownyourgram.com') - ->set('me', config('app.url')) - ->set('scope', 'create') - ->set('issued_at', time()) - ->sign($signer, env('APP_KEY')) - ->getToken(); - - return $token; - } } diff --git a/tests/Feature/SwarmTest.php b/tests/Feature/SwarmTest.php index 6e553102..25780dc7 100644 --- a/tests/Feature/SwarmTest.php +++ b/tests/Feature/SwarmTest.php @@ -3,13 +3,14 @@ namespace Tests\Feature; use Tests\TestCase; +use Tests\TestToken; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Signer\Hmac\Sha256; use Illuminate\Foundation\Testing\DatabaseTransactions; class SwarmTest extends TestCase { - use DatabaseTransactions; + use DatabaseTransactions, TestToken; public function test_faked_ownyourswarm_request() { @@ -82,23 +83,4 @@ class SwarmTest extends TestCase 'swarm_url' => 'https://www.swarmapp.com/checkin/def' ]); } - - /** - * Generate a valid token to be used in the tests. - * - * @return Lcobucci\JWT\Token\Plain $token - */ - private function getToken() - { - $signer = new Sha256(); - $token = (new Builder()) - ->set('client_id', 'https://ownyourswarm.p3k.io') - ->set('me', 'https://jonnybarnes.localhost') - ->set('scope', 'create update') - ->set('issued_at', time()) - ->sign($signer, env('APP_KEY')) - ->getToken(); - - return $token; - } } diff --git a/tests/TestToken.php b/tests/TestToken.php new file mode 100644 index 00000000..4cce0892 --- /dev/null +++ b/tests/TestToken.php @@ -0,0 +1,37 @@ +set('client_id', 'https://quill.p3k.io') + ->set('me', 'https://jonnybarnes.localhost') + ->set('scope', 'create update') + ->set('issued_at', time()) + ->sign($signer, env('APP_KEY')) + ->getToken(); + + return $token; + } + + public function getInvalidToken() + { + $signer = new Sha256(); + $token = (new Builder()) + ->set('client_id', 'https://quill.p3k.io') + ->set('me', 'https://jonnybarnes.localhost') + ->set('scope', 'view') //error here + ->set('issued_at', time()) + ->sign($signer, env('APP_KEY')) + ->getToken(); + + return $token; + } +}