commit
054bba1da9
48 changed files with 1885 additions and 2435 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -11,6 +11,7 @@ yarn-error.log
|
|||
/lsp
|
||||
.phpstorm.meta.php
|
||||
_ide_helper.php
|
||||
ray.php
|
||||
# Custom paths in /public
|
||||
/public/coverage
|
||||
/public/hot
|
||||
|
|
|
@ -22,6 +22,7 @@ class Kernel extends ConsoleKernel
|
|||
*
|
||||
* @param \Illuminate\Console\Scheduling\Schedule $schedule
|
||||
* @return void
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
|
|
|
@ -53,7 +53,7 @@ class Handler extends ExceptionHandler
|
|||
{
|
||||
parent::report($throwable);
|
||||
|
||||
if ($this->shouldReport($throwable)) {
|
||||
if (config('logging.slack') && $this->shouldReport($throwable)) {
|
||||
$guzzle = new Client([
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
|
|
|
@ -41,13 +41,14 @@ class NotesController extends Controller
|
|||
/**
|
||||
* Process a request to make a new note.
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
* @param Request $request
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
public function store(): RedirectResponse
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
Note::create([
|
||||
'in-reply-to' => request()->input('in-reply-to'),
|
||||
'note' => request()->input('content'),
|
||||
'in_reply_to' => $request->input('in-reply-to'),
|
||||
'note' => $request->input('content'),
|
||||
]);
|
||||
|
||||
return redirect('/admin/notes');
|
||||
|
|
|
@ -111,7 +111,7 @@ class FeedsController extends Controller
|
|||
*/
|
||||
public function notesJson()
|
||||
{
|
||||
$notes = Note::latest()->take(20)->get();
|
||||
$notes = Note::latest()->with('media')->take(20)->get();
|
||||
$data = [
|
||||
'version' => 'https://jsonfeed.org/version/1',
|
||||
'title' => 'The JSON Feed for ' . config('app.display_name') . '’s notes',
|
||||
|
|
|
@ -23,7 +23,7 @@ class FrontPageController extends Controller
|
|||
return (new ActivityStreamsService())->siteOwnerResponse();
|
||||
}
|
||||
|
||||
$notes = Note::latest()->get();
|
||||
$notes = Note::latest()->with(['media', 'client', 'place'])->get();
|
||||
$articles = Article::latest()->get();
|
||||
$bookmarks = Bookmark::latest()->get();
|
||||
$likes = Like::latest()->get();
|
||||
|
|
|
@ -52,7 +52,7 @@ class MicropubController extends Controller
|
|||
{
|
||||
try {
|
||||
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
|
||||
} catch (RequiredConstraintsViolated | InvalidTokenStructure | CannotDecodeContent) {
|
||||
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
|
||||
$micropubResponses = new MicropubResponses();
|
||||
|
||||
return $micropubResponses->invalidTokenResponse();
|
||||
|
@ -124,7 +124,7 @@ class MicropubController extends Controller
|
|||
{
|
||||
try {
|
||||
$tokenData = $this->tokenService->validateToken(request()->input('access_token'));
|
||||
} catch (RequiredConstraintsViolated | InvalidTokenStructure) {
|
||||
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
|
||||
return (new MicropubResponses())->invalidTokenResponse();
|
||||
}
|
||||
|
||||
|
|
|
@ -37,7 +37,7 @@ class MicropubMediaController extends Controller
|
|||
{
|
||||
try {
|
||||
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
|
||||
} catch (RequiredConstraintsViolated | InvalidTokenStructure) {
|
||||
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
|
||||
$micropubResponses = new MicropubResponses();
|
||||
|
||||
return $micropubResponses->invalidTokenResponse();
|
||||
|
@ -75,7 +75,7 @@ class MicropubMediaController extends Controller
|
|||
return [
|
||||
'url' => $mediaItem->url,
|
||||
'published' => $mediaItem->created_at->toW3cString(),
|
||||
'mime_type' => $mediaItem->getMimeType(),
|
||||
'mime_type' => $mediaItem->mimetype,
|
||||
];
|
||||
});
|
||||
|
||||
|
@ -107,7 +107,7 @@ class MicropubMediaController extends Controller
|
|||
{
|
||||
try {
|
||||
$tokenData = $this->tokenService->validateToken(request()->input('access_token'));
|
||||
} catch (RequiredConstraintsViolated | InvalidTokenStructure $exception) {
|
||||
} catch (RequiredConstraintsViolated|InvalidTokenStructure $exception) {
|
||||
$micropubResponses = new MicropubResponses();
|
||||
|
||||
return $micropubResponses->invalidTokenResponse();
|
||||
|
|
|
@ -20,8 +20,7 @@ class ProcessBookmark implements ShouldQueue
|
|||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/** @var Bookmark */
|
||||
protected $bookmark;
|
||||
protected Bookmark $bookmark;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
|
@ -38,14 +37,13 @@ class ProcessBookmark implements ShouldQueue
|
|||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(): void
|
||||
{
|
||||
$uuid = (resolve(BookmarkService::class))->saveScreenshot($this->bookmark->url);
|
||||
$this->bookmark->screenshot = $uuid;
|
||||
SaveScreenshot::dispatch($this->bookmark);
|
||||
|
||||
try {
|
||||
$archiveLink = (resolve(BookmarkService::class))->getArchiveLink($this->bookmark->url);
|
||||
} catch (InternetArchiveException $e) {
|
||||
} catch (InternetArchiveException) {
|
||||
$archiveLink = null;
|
||||
}
|
||||
$this->bookmark->archive = $archiveLink;
|
||||
|
|
109
app/Jobs/SaveScreenshot.php
Executable file
109
app/Jobs/SaveScreenshot.php
Executable file
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Bookmark;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use JsonException;
|
||||
|
||||
class SaveScreenshot implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private Bookmark $bookmark;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Bookmark $bookmark)
|
||||
{
|
||||
$this->bookmark = $bookmark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// A normal Guzzle client
|
||||
$client = resolve(Client::class);
|
||||
// A Guzzle client with a custom Middleware to retry the CloudConvert API requests
|
||||
$retryClient = resolve('RetryGuzzle');
|
||||
|
||||
// First request that CloudConvert takes a screenshot of the URL
|
||||
$takeScreenshotJobResponse = $client->request('POST', 'https://api.cloudconvert.com/v2/capture-website', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
|
||||
],
|
||||
'json' => [
|
||||
'url' => $this->bookmark->url,
|
||||
'output_format' => 'png',
|
||||
'screen_width' => 1440,
|
||||
'screen_height' => 900,
|
||||
'wait_until' => 'networkidle0',
|
||||
'wait_time' => 100,
|
||||
],
|
||||
]);
|
||||
|
||||
$taskId = json_decode($takeScreenshotJobResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
|
||||
|
||||
// Now wait till the status job is finished
|
||||
$screenshotJobStatusResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $taskId, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
|
||||
],
|
||||
'query' => [
|
||||
'include' => 'payload',
|
||||
],
|
||||
]);
|
||||
|
||||
$finishedCaptureId = json_decode($screenshotJobStatusResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
|
||||
|
||||
// Now we can create a new job to request thst the screenshot is exported to a temporary URL we can download the screenshot from
|
||||
$exportImageJob = $client->request('POST', 'https://api.cloudconvert.com/v2/export/url', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
|
||||
],
|
||||
'json' => [
|
||||
'input' => $finishedCaptureId,
|
||||
'archive_multiple_files' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$exportImageJobId = json_decode($exportImageJob->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
|
||||
|
||||
// Again, wait till the status of this export job is finished
|
||||
$finalImageUrlResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/' . $exportImageJobId, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . config('services.cloudconvert.token'),
|
||||
],
|
||||
'query' => [
|
||||
'include' => 'payload',
|
||||
],
|
||||
]);
|
||||
|
||||
// Now we can download the screenshot and save it to the storage
|
||||
$finalImageUrl = json_decode($finalImageUrlResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->result->files[0]->url;
|
||||
|
||||
$finalImageUrlContent = $client->request('GET', $finalImageUrl);
|
||||
|
||||
Storage::disk('public')->put('/assets/img/bookmarks/' . $taskId . '.png', $finalImageUrlContent->getBody()->getContents());
|
||||
|
||||
$this->bookmark->screenshot = $taskId;
|
||||
$this->bookmark->save();
|
||||
}
|
||||
}
|
|
@ -6,8 +6,8 @@ namespace App\Jobs;
|
|||
|
||||
use App\Models\Note;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Psr7\Header;
|
||||
use GuzzleHttp\Psr7\Uri;
|
||||
use GuzzleHttp\Psr7\UriResolver;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
@ -22,8 +22,7 @@ class SendWebMentions implements ShouldQueue
|
|||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/** @var Note */
|
||||
protected $note;
|
||||
protected Note $note;
|
||||
|
||||
/**
|
||||
* Create the job instance, inject dependencies.
|
||||
|
@ -39,15 +38,14 @@ class SendWebMentions implements ShouldQueue
|
|||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(): void
|
||||
{
|
||||
//grab the URLs
|
||||
$inReplyTo = $this->note->in_reply_to ?? '';
|
||||
// above so explode doesn’t complain about null being passed in
|
||||
$urlsInReplyTo = explode(' ', $inReplyTo);
|
||||
$urlsInReplyTo = explode(' ', $this->note->in_reply_to ?? '');
|
||||
$urlsNote = $this->getLinks($this->note->note);
|
||||
$urls = array_filter(array_merge($urlsInReplyTo, $urlsNote)); //filter out none URLs
|
||||
$urls = array_filter(array_merge($urlsInReplyTo, $urlsNote));
|
||||
foreach ($urls as $url) {
|
||||
$endpoint = $this->discoverWebmentionEndpoint($url);
|
||||
if ($endpoint !== null) {
|
||||
|
@ -67,10 +65,12 @@ class SendWebMentions implements ShouldQueue
|
|||
*
|
||||
* @param string $url
|
||||
* @return string|null
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function discoverWebmentionEndpoint(string $url): ?string
|
||||
{
|
||||
//let’s not send webmentions to myself
|
||||
// let’s not send webmentions to myself
|
||||
if (parse_url($url, PHP_URL_HOST) === config('app.longurl')) {
|
||||
return null;
|
||||
}
|
||||
|
@ -80,6 +80,7 @@ class SendWebMentions implements ShouldQueue
|
|||
|
||||
$endpoint = null;
|
||||
|
||||
/** @var Client $guzzle */
|
||||
$guzzle = resolve(Client::class);
|
||||
$response = $guzzle->get($url);
|
||||
//check HTTP Headers for webmention endpoint
|
||||
|
@ -134,8 +135,6 @@ class SendWebMentions implements ShouldQueue
|
|||
/**
|
||||
* Resolve a URI if necessary.
|
||||
*
|
||||
* @todo Update deprecated resolve method
|
||||
*
|
||||
* @param string $url
|
||||
* @param string $base The base of the URL
|
||||
* @return string
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace App\Models;
|
|||
|
||||
use Cviebrock\EloquentSluggable\Sluggable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
@ -52,76 +53,65 @@ class Article extends Model
|
|||
}
|
||||
|
||||
/**
|
||||
* We shall set a blacklist of non-modifiable model attributes.
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $guarded = ['id'];
|
||||
protected $fillable = [
|
||||
'url',
|
||||
'title',
|
||||
'main',
|
||||
'published',
|
||||
];
|
||||
|
||||
/**
|
||||
* Process the article for display.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getHtmlAttribute(): string
|
||||
protected function html(): Attribute
|
||||
{
|
||||
$environment = new Environment();
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
$environment->addRenderer(FencedCode::class, new FencedCodeRenderer());
|
||||
$environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer());
|
||||
$markdownConverter = new MarkdownConverter($environment);
|
||||
return Attribute::get(
|
||||
get: function () {
|
||||
$environment = new Environment();
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
$environment->addRenderer(FencedCode::class, new FencedCodeRenderer());
|
||||
$environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer());
|
||||
$markdownConverter = new MarkdownConverter($environment);
|
||||
|
||||
return $markdownConverter->convert($this->main)->getContent();
|
||||
return $markdownConverter->convert($this->main)->getContent();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert updated_at to W3C time format.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getW3cTimeAttribute(): string
|
||||
protected function w3cTime(): Attribute
|
||||
{
|
||||
return $this->updated_at->toW3CString();
|
||||
return Attribute::get(
|
||||
get: fn () => $this->updated_at->toW3CString(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert updated_at to a tooltip appropriate format.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getTooltipTimeAttribute(): string
|
||||
protected function tooltipTime(): Attribute
|
||||
{
|
||||
return $this->updated_at->toRFC850String();
|
||||
return Attribute::get(
|
||||
get: fn () => $this->updated_at->toRFC850String(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert updated_at to a human readable format.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getHumanTimeAttribute(): string
|
||||
protected function humanTime(): Attribute
|
||||
{
|
||||
return $this->updated_at->diffForHumans();
|
||||
return Attribute::get(
|
||||
get: fn () => $this->updated_at->diffForHumans(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pubdate value for RSS feeds.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getPubdateAttribute(): string
|
||||
protected function pubdate(): Attribute
|
||||
{
|
||||
return $this->updated_at->toRSSString();
|
||||
return Attribute::get(
|
||||
get: fn () => $this->updated_at->toRSSString(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A link to the article, i.e. `/blog/1999/12/25/merry-christmas`.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getLinkAttribute(): string
|
||||
protected function link(): Attribute
|
||||
{
|
||||
return '/blog/' . $this->updated_at->year . '/' . $this->updated_at->format('m') . '/' . $this->titleurl;
|
||||
return Attribute::get(
|
||||
get: fn () => '/blog/' . $this->updated_at->year . '/' . $this->updated_at->format('m') . '/' . $this->titleurl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -134,7 +124,7 @@ class Article extends Model
|
|||
*/
|
||||
public function scopeDate(Builder $query, int $year = null, int $month = null): Builder
|
||||
{
|
||||
if ($year == null) {
|
||||
if ($year === null) {
|
||||
return $query;
|
||||
}
|
||||
$start = $year . '-01-01 00:00:00';
|
||||
|
|
|
@ -4,43 +4,11 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\Bookmark.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $url
|
||||
* @property string|null $name
|
||||
* @property string|null $content
|
||||
* @property string|null $screenshot
|
||||
* @property string|null $archive
|
||||
* @property array|null $syndicates
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read string $longurl
|
||||
* @property-read Collection|Tag[] $tags
|
||||
* @property-read int|null $tags_count
|
||||
*
|
||||
* @method static Builder|Bookmark newModelQuery()
|
||||
* @method static Builder|Bookmark newQuery()
|
||||
* @method static Builder|Bookmark query()
|
||||
* @method static Builder|Bookmark whereArchive($value)
|
||||
* @method static Builder|Bookmark whereContent($value)
|
||||
* @method static Builder|Bookmark whereCreatedAt($value)
|
||||
* @method static Builder|Bookmark whereId($value)
|
||||
* @method static Builder|Bookmark whereName($value)
|
||||
* @method static Builder|Bookmark whereScreenshot($value)
|
||||
* @method static Builder|Bookmark whereSyndicates($value)
|
||||
* @method static Builder|Bookmark whereUpdatedAt($value)
|
||||
* @method static Builder|Bookmark whereUrl($value)
|
||||
* @mixin Eloquent
|
||||
*/
|
||||
class Bookmark extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
@ -71,13 +39,10 @@ class Bookmark extends Model
|
|||
return $this->belongsToMany('App\Models\Tag');
|
||||
}
|
||||
|
||||
/**
|
||||
* The full url of a bookmark.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getLongurlAttribute(): string
|
||||
protected function longurl(): Attribute
|
||||
{
|
||||
return config('app.url') . '/bookmarks/' . $this->id;
|
||||
return Attribute::get(
|
||||
get: fn () => config('app.url') . '/bookmarks/' . $this->id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace App\Models;
|
||||
|
||||
use App\Traits\FilterHtml;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
|
@ -17,46 +18,38 @@ class Like extends Model
|
|||
|
||||
protected $fillable = ['url'];
|
||||
|
||||
/**
|
||||
* Normalize the URL of a Like.
|
||||
*
|
||||
* @param string $value The provided URL
|
||||
*/
|
||||
public function setUrlAttribute(string $value)
|
||||
protected function url(): Attribute
|
||||
{
|
||||
$this->attributes['url'] = normalize_url($value);
|
||||
return Attribute::set(
|
||||
set: fn ($value) => normalize_url($value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the URL of the author of the like.
|
||||
*
|
||||
* @param string|null $value The author’s url
|
||||
*/
|
||||
public function setAuthorUrlAttribute(?string $value)
|
||||
protected function authorUrl(): Attribute
|
||||
{
|
||||
$this->attributes['author_url'] = normalize_url($value);
|
||||
return Attribute::set(
|
||||
set: fn ($value) => normalize_url($value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the content contains HTML, filter it.
|
||||
*
|
||||
* @param string|null $value The content of the like
|
||||
* @return string|null
|
||||
*/
|
||||
public function getContentAttribute(?string $value): ?string
|
||||
protected function content(): Attribute
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
return Attribute::get(
|
||||
get: function ($value, $attributes) {
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mf2 = Mf2\parse($value, $this->url);
|
||||
$mf2 = Mf2\parse($value, $attributes['url']);
|
||||
|
||||
if (Arr::get($mf2, 'items.0.properties.content.0.html')) {
|
||||
return $this->filterHtml(
|
||||
$mf2['items'][0]['properties']['content'][0]['html']
|
||||
);
|
||||
}
|
||||
if (Arr::get($mf2, 'items.0.properties.content.0.html')) {
|
||||
return $this->filterHtml(
|
||||
$mf2['items'][0]['properties']['content'][0]['html']
|
||||
);
|
||||
}
|
||||
|
||||
return $value;
|
||||
return $value;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
@ -37,53 +38,63 @@ class Media extends Model
|
|||
return $this->belongsTo(Note::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for an S3 media file.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUrlAttribute(): string
|
||||
protected function url(): Attribute
|
||||
{
|
||||
if (Str::startsWith($this->path, 'https://')) {
|
||||
return $this->path;
|
||||
}
|
||||
return Attribute::get(
|
||||
get: function ($value, $attributes) {
|
||||
if (Str::startsWith($attributes['path'], 'https://')) {
|
||||
return $attributes['path'];
|
||||
}
|
||||
|
||||
return config('filesystems.disks.s3.url') . '/' . $this->path;
|
||||
return config('filesystems.disks.s3.url') . '/' . $attributes['path'];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for the medium size of an S3 image file.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getMediumurlAttribute(): string
|
||||
protected function mediumurl(): Attribute
|
||||
{
|
||||
$basename = $this->getBasename($this->path);
|
||||
$extension = $this->getExtension($this->path);
|
||||
|
||||
return config('filesystems.disks.s3.url') . '/' . $basename . '-medium.' . $extension;
|
||||
return Attribute::get(
|
||||
get: fn ($value, $attributes) => $this->getSizeUrl($attributes['path'], 'medium'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for the small size of an S3 image file.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getSmallurlAttribute(): string
|
||||
protected function smallurl(): Attribute
|
||||
{
|
||||
$basename = $this->getBasename($this->path);
|
||||
$extension = $this->getExtension($this->path);
|
||||
|
||||
return config('filesystems.disks.s3.url') . '/' . $basename . '-small.' . $extension;
|
||||
return Attribute::get(
|
||||
get: fn ($value, $attributes) => $this->getSizeUrl($attributes['path'], 'small'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the real part of a filename, i.e. strip the file extension.
|
||||
*
|
||||
* @param string $path
|
||||
* @return string
|
||||
*/
|
||||
public function getBasename(string $path): string
|
||||
protected function mimetype(): Attribute
|
||||
{
|
||||
return Attribute::get(
|
||||
get: function ($value, $attributes) {
|
||||
$extension = $this->getExtension($attributes['path']);
|
||||
|
||||
return match ($extension) {
|
||||
'gif' => 'image/gif',
|
||||
'jpeg', 'jpg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'svg' => 'image/svg+xml',
|
||||
'tiff' => 'image/tiff',
|
||||
'webp' => 'image/webp',
|
||||
'mp4' => 'video/mp4',
|
||||
'mkv' => 'video/mkv',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private function getSizeUrl(string $path, string $size): string
|
||||
{
|
||||
$basename = $this->getBasename($path);
|
||||
$extension = $this->getExtension($path);
|
||||
|
||||
return config('filesystems.disks.s3.url') . '/' . $basename . '-' . $size . '.' . $extension;
|
||||
}
|
||||
|
||||
private function getBasename(string $path): string
|
||||
{
|
||||
// the following achieves this data flow
|
||||
// foo.bar.png => ['foo', 'bar', 'png'] => ['foo', 'bar'] => foo.bar
|
||||
|
@ -95,40 +106,10 @@ class Media extends Model
|
|||
}, ''), '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the extension from a given filename.
|
||||
*
|
||||
* @param string $path
|
||||
* @return string
|
||||
*/
|
||||
public function getExtension(string $path): string
|
||||
private function getExtension(string $path): string
|
||||
{
|
||||
$parts = explode('.', $path);
|
||||
|
||||
return array_pop($parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mime type of the media file.
|
||||
*
|
||||
* For now we will just use the extension, but this could be improved.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getMimeType(): string
|
||||
{
|
||||
$extension = $this->getExtension($this->path);
|
||||
|
||||
return match ($extension) {
|
||||
'gif' => 'image/gif',
|
||||
'jpeg', 'jpg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'svg' => 'image/svg+xml',
|
||||
'tiff' => 'image/tiff',
|
||||
'webp' => 'image/webp',
|
||||
'mp4' => 'video/mp4',
|
||||
'mkv' => 'video/mkv',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use Jonnybarnes\IndieWeb\Numbers;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\Autolink\AutolinkExtension;
|
||||
|
@ -141,7 +140,6 @@ class Note extends Model
|
|||
*
|
||||
* @return array
|
||||
*/
|
||||
#[ArrayShape(['note' => 'null|string'])]
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
|
@ -190,7 +188,7 @@ class Note extends Model
|
|||
/**
|
||||
* Provide the content_html for JSON feed.
|
||||
*
|
||||
* In particular we want to include media links such as images.
|
||||
* In particular, we want to include media links such as images.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace App\Models;
|
|||
|
||||
use Cviebrock\EloquentSluggable\Sluggable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
@ -105,53 +106,46 @@ class Place extends Model
|
|||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Long URL for a place.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getLongurlAttribute(): string
|
||||
protected function longurl(): Attribute
|
||||
{
|
||||
return config('app.url') . '/places/' . $this->slug;
|
||||
return Attribute::get(
|
||||
get: fn ($value, $attributes) => config('app.url') . '/places/' . $attributes['slug'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Short URL for a place.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getShorturlAttribute(): string
|
||||
protected function shorturl(): Attribute
|
||||
{
|
||||
return config('app.shorturl') . '/places/' . $this->slug;
|
||||
return Attribute::get(
|
||||
get: fn ($value, $attributes) => config('app.shorturl') . '/places/' . $attributes['slug'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is an alternative for `longurl`.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUriAttribute(): string
|
||||
protected function uri(): Attribute
|
||||
{
|
||||
return $this->longurl;
|
||||
return Attribute::get(
|
||||
get: fn () => $this->longurl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dealing with a jsonb column, so we check input first.
|
||||
*
|
||||
* @param string|null $url
|
||||
*/
|
||||
public function setExternalUrlsAttribute(?string $url)
|
||||
protected function externalUrls(): Attribute
|
||||
{
|
||||
if ($url === null) {
|
||||
return;
|
||||
}
|
||||
$type = $this->getType($url);
|
||||
$already = [];
|
||||
if (array_key_exists('external_urls', $this->attributes)) {
|
||||
$already = json_decode($this->attributes['external_urls'], true);
|
||||
}
|
||||
$already[$type] = $url;
|
||||
$this->attributes['external_urls'] = json_encode($already);
|
||||
return Attribute::set(
|
||||
set: function ($value, $attributes) {
|
||||
if ($value === null) {
|
||||
return $attributes['external_urls'] ?? null;
|
||||
}
|
||||
|
||||
$type = $this->getType($value);
|
||||
$already = [];
|
||||
|
||||
if (array_key_exists('external_urls', $attributes)) {
|
||||
$already = json_decode($attributes['external_urls'], true);
|
||||
}
|
||||
$already[$type] = $value;
|
||||
|
||||
return json_encode($already);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
@ -40,14 +41,11 @@ class Tag extends Model
|
|||
return $this->belongsToMany('App\Models\Bookmark');
|
||||
}
|
||||
|
||||
/**
|
||||
* When creating a Tag model instance, invoke the nomralize method on the tag.
|
||||
*
|
||||
* @param string $value
|
||||
*/
|
||||
public function setTagAttribute(string $value)
|
||||
protected function tag(): Attribute
|
||||
{
|
||||
$this->attributes['tag'] = $this->normalize($value);
|
||||
return Attribute::set(
|
||||
set: fn ($value) => self::normalize($value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -6,13 +6,14 @@ namespace App\Models;
|
|||
|
||||
use App\Traits\FilterHtml;
|
||||
use Codebird\Codebird;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Jonnybarnes\WebmentionsParser\Authorship;
|
||||
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
|
||||
|
||||
class WebMention extends Model
|
||||
{
|
||||
|
@ -43,72 +44,79 @@ class WebMention extends Model
|
|||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the author of the webmention.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @throws AuthorshipParserException
|
||||
*/
|
||||
public function getAuthorAttribute(): array
|
||||
protected function author(): Attribute
|
||||
{
|
||||
$authorship = new Authorship();
|
||||
$hCard = $authorship->findAuthor(json_decode($this->mf2, true));
|
||||
return Attribute::get(
|
||||
get: function ($value, $attributes) {
|
||||
if (
|
||||
! array_key_exists('mf2', $attributes) ||
|
||||
$attributes['mf2'] === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($hCard === false) {
|
||||
return [];
|
||||
}
|
||||
$authorship = new Authorship();
|
||||
$hCard = $authorship->findAuthor(json_decode($attributes['mf2'], true));
|
||||
|
||||
if (
|
||||
array_key_exists('properties', $hCard) &&
|
||||
array_key_exists('photo', $hCard['properties'])
|
||||
) {
|
||||
$hCard['properties']['photo'][0] = $this->createPhotoLink($hCard['properties']['photo'][0]);
|
||||
}
|
||||
if ($hCard === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $hCard;
|
||||
}
|
||||
if (
|
||||
array_key_exists('properties', $hCard) &&
|
||||
array_key_exists('photo', $hCard['properties'])
|
||||
) {
|
||||
$hCard['properties']['photo'][0] = $this->createPhotoLink($hCard['properties']['photo'][0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the published value for the webmention.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getPublishedAttribute(): ?string
|
||||
{
|
||||
$mf2 = $this->mf2 ?? '';
|
||||
$microformats = json_decode($mf2, true);
|
||||
if (isset($microformats['items'][0]['properties']['published'][0])) {
|
||||
try {
|
||||
$published = carbon()->parse(
|
||||
$microformats['items'][0]['properties']['published'][0]
|
||||
)->toDayDateTimeString();
|
||||
} catch (\Exception $exception) {
|
||||
$published = $this->updated_at->toDayDateTimeString();
|
||||
return $hCard;
|
||||
}
|
||||
} else {
|
||||
$published = $this->updated_at->toDayDateTimeString();
|
||||
}
|
||||
|
||||
return $published;
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filtered HTML of a reply.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getReplyAttribute(): ?string
|
||||
protected function published(): Attribute
|
||||
{
|
||||
if ($this->mf2 === null) {
|
||||
return null;
|
||||
}
|
||||
$microformats = json_decode($this->mf2, true);
|
||||
if (isset($microformats['items'][0]['properties']['content'][0]['html'])) {
|
||||
return $this->filterHtml($microformats['items'][0]['properties']['content'][0]['html']);
|
||||
}
|
||||
return Attribute::get(
|
||||
get: function ($value, $attributes) {
|
||||
$mf2 = $attributes['mf2'] ?? '';
|
||||
$microformats = json_decode($mf2, true);
|
||||
if (isset($microformats['items'][0]['properties']['published'][0])) {
|
||||
try {
|
||||
$published = carbon()->parse(
|
||||
$microformats['items'][0]['properties']['published'][0]
|
||||
)->toDayDateTimeString();
|
||||
} catch (Exception) {
|
||||
$published = $this->updated_at->toDayDateTimeString();
|
||||
}
|
||||
} else {
|
||||
$published = $this->updated_at->toDayDateTimeString();
|
||||
}
|
||||
|
||||
return null;
|
||||
return $published;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function reply(): Attribute
|
||||
{
|
||||
return Attribute::get(
|
||||
get: function ($value, $attributes) {
|
||||
if (
|
||||
! array_key_exists('mf2', $attributes) ||
|
||||
$attributes['mf2'] === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$microformats = json_decode($attributes['mf2'], true);
|
||||
|
||||
if (isset($microformats['items'][0]['properties']['content'][0]['html'])) {
|
||||
return $this->filterHtml($microformats['items'][0]['properties']['content'][0]['html']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -121,11 +129,13 @@ class WebMention extends Model
|
|||
{
|
||||
$url = normalize_url($url);
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if ($host == 'pbs.twimg.com') {
|
||||
|
||||
if ($host === 'pbs.twimg.com') {
|
||||
//make sure we use HTTPS, we know twitter supports it
|
||||
return str_replace('http://', 'https://', $url);
|
||||
}
|
||||
if ($host == 'twitter.com') {
|
||||
|
||||
if ($host === 'twitter.com') {
|
||||
if (Cache::has($url)) {
|
||||
return Cache::get($url);
|
||||
}
|
||||
|
@ -137,6 +147,7 @@ class WebMention extends Model
|
|||
|
||||
return $profile_image;
|
||||
}
|
||||
|
||||
$filesystem = new Filesystem();
|
||||
if ($filesystem->exists(public_path() . '/assets/profile-images/' . $host . '/image')) {
|
||||
return '/assets/profile-images/' . $host . '/image';
|
||||
|
|
|
@ -5,6 +5,9 @@ namespace App\Providers;
|
|||
use App\Models\Note;
|
||||
use App\Observers\NoteObserver;
|
||||
use Codebird\Codebird;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Middleware;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
@ -102,6 +105,41 @@ class AppServiceProvider extends ServiceProvider
|
|||
->forceAttribute('a', 'rel', 'noopener nofollow')
|
||||
);
|
||||
});
|
||||
|
||||
// Configure Guzzle
|
||||
$this->app->bind('RetryGuzzle', function () {
|
||||
$handlerStack = \GuzzleHttp\HandlerStack::create();
|
||||
$handlerStack->push(Middleware::retry(
|
||||
function ($retries, $request, $response, $exception) {
|
||||
// Limit the number of retries to 5
|
||||
if ($retries >= 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Retry connection exceptions
|
||||
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry on server errors
|
||||
if ($response && $response->getStatusCode() >= 500) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Finally for CloudConvert, retry if status is not final
|
||||
return json_decode($response, false, 512, JSON_THROW_ON_ERROR)->data->status !== 'finished';
|
||||
},
|
||||
function () {
|
||||
// Retry after 1 second
|
||||
return 1000;
|
||||
}
|
||||
));
|
||||
|
||||
return new Client(['handler' => $handlerStack]);
|
||||
});
|
||||
|
||||
// Turn on Eloquent strict mode when developing
|
||||
Model::shouldBeStrict(! $this->app->isProduction());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
19
app/Services/ArticleService.php
Normal file
19
app/Services/ArticleService.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Article;
|
||||
|
||||
class ArticleService extends Service
|
||||
{
|
||||
public function create(array $request, ?string $client = null): Article
|
||||
{
|
||||
return Article::create([
|
||||
'title' => $this->getDataByKey($request, 'name'),
|
||||
'main' => $this->getDataByKey($request, 'content'),
|
||||
'published' => true,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -14,19 +14,17 @@ use GuzzleHttp\Client;
|
|||
use GuzzleHttp\Exception\ClientException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Spatie\Browsershot\Browsershot;
|
||||
use Spatie\Browsershot\Exceptions\CouldNotTakeBrowsershot;
|
||||
|
||||
class BookmarkService
|
||||
class BookmarkService extends Service
|
||||
{
|
||||
/**
|
||||
* Create a new Bookmark.
|
||||
*
|
||||
* @param array $request Data from request()->all()
|
||||
* @param string|null $client
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function createBookmark(array $request): Bookmark
|
||||
public function create(array $request, ?string $client = null): Bookmark
|
||||
{
|
||||
if (Arr::get($request, 'properties.bookmark-of.0')) {
|
||||
//micropub request
|
||||
|
@ -75,30 +73,6 @@ class BookmarkService
|
|||
return $bookmark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a URL, use `browsershot` to save an image of the page.
|
||||
*
|
||||
* @param string $url
|
||||
* @return string The uuid for the screenshot
|
||||
*
|
||||
* @throws CouldNotTakeBrowsershot
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function saveScreenshot(string $url): string
|
||||
{
|
||||
$browsershot = new Browsershot();
|
||||
|
||||
$uuid = Uuid::uuid4();
|
||||
|
||||
$browsershot->url($url)
|
||||
->setIncludePath('$PATH:/usr/local/bin')
|
||||
->noSandbox()
|
||||
->windowSize(960, 640)
|
||||
->save(public_path() . '/assets/img/bookmarks/' . $uuid . '.png');
|
||||
|
||||
return $uuid->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a URL, attempt to save it to the Internet Archive.
|
||||
*
|
||||
|
|
|
@ -8,7 +8,7 @@ use App\Jobs\ProcessLike;
|
|||
use App\Models\Like;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class LikeService
|
||||
class LikeService extends Service
|
||||
{
|
||||
/**
|
||||
* Create a new Like.
|
||||
|
@ -16,7 +16,7 @@ class LikeService
|
|||
* @param array $request
|
||||
* @return Like $like
|
||||
*/
|
||||
public function createLike(array $request): Like
|
||||
public function create(array $request, ?string $client = null): Like
|
||||
{
|
||||
if (Arr::get($request, 'properties.like-of.0')) {
|
||||
//micropub request
|
||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Services\Micropub;
|
||||
|
||||
use App\Services\ArticleService;
|
||||
use App\Services\BookmarkService;
|
||||
use App\Services\LikeService;
|
||||
use App\Services\NoteService;
|
||||
|
@ -21,13 +22,17 @@ class HEntryService
|
|||
public function process(array $request, ?string $client = null): ?string
|
||||
{
|
||||
if (Arr::get($request, 'properties.like-of') || Arr::get($request, 'like-of')) {
|
||||
return resolve(LikeService::class)->createLike($request)->longurl;
|
||||
return resolve(LikeService::class)->create($request)->longurl;
|
||||
}
|
||||
|
||||
if (Arr::get($request, 'properties.bookmark-of') || Arr::get($request, 'bookmark-of')) {
|
||||
return resolve(BookmarkService::class)->createBookmark($request)->longurl;
|
||||
return resolve(BookmarkService::class)->create($request)->longurl;
|
||||
}
|
||||
|
||||
return resolve(NoteService::class)->createNote($request, $client)->longurl;
|
||||
if (Arr::get($request, 'properties.name') || Arr::get($request, 'name')) {
|
||||
return resolve(ArticleService::class)->create($request)->longurl;
|
||||
}
|
||||
|
||||
return resolve(NoteService::class)->create($request, $client)->longurl;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ use App\Models\SyndicationTarget;
|
|||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NoteService
|
||||
class NoteService extends Service
|
||||
{
|
||||
/**
|
||||
* Create a new note.
|
||||
|
@ -23,12 +23,12 @@ class NoteService
|
|||
* @param string|null $client
|
||||
* @return Note
|
||||
*/
|
||||
public function createNote(array $request, ?string $client = null): Note
|
||||
public function create(array $request, ?string $client = null): Note
|
||||
{
|
||||
$note = Note::create(
|
||||
[
|
||||
'note' => $this->getContent($request),
|
||||
'in_reply_to' => $this->getInReplyTo($request),
|
||||
'note' => $this->getDataByKey($request, 'content'),
|
||||
'in_reply_to' => $this->getDataByKey($request, 'in-reply-to'),
|
||||
'client_id' => $client,
|
||||
]
|
||||
);
|
||||
|
@ -66,39 +66,6 @@ class NoteService
|
|||
return $note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content from the request to create a new note.
|
||||
*
|
||||
* @param array $request Data from request()->all()
|
||||
* @return string|null
|
||||
*/
|
||||
private function getContent(array $request): ?string
|
||||
{
|
||||
if (Arr::get($request, 'properties.content.0.html')) {
|
||||
return Arr::get($request, 'properties.content.0.html');
|
||||
}
|
||||
if (is_string(Arr::get($request, 'properties.content.0'))) {
|
||||
return Arr::get($request, 'properties.content.0');
|
||||
}
|
||||
|
||||
return Arr::get($request, 'content');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the in-reply-to from the request to create a new note.
|
||||
*
|
||||
* @param array $request Data from request()->all()
|
||||
* @return string|null
|
||||
*/
|
||||
private function getInReplyTo(array $request): ?string
|
||||
{
|
||||
if (Arr::get($request, 'properties.in-reply-to.0')) {
|
||||
return Arr::get($request, 'properties.in-reply-to.0');
|
||||
}
|
||||
|
||||
return Arr::get($request, 'in-reply-to');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the published time from the request to create a new note.
|
||||
*
|
||||
|
|
30
app/Services/Service.php
Normal file
30
app/Services/Service.php
Normal file
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
abstract class Service
|
||||
{
|
||||
abstract public function create(array $request, ?string $client = null): Model;
|
||||
|
||||
protected function getDataByKey(array $request, string $key): ?string
|
||||
{
|
||||
if (Arr::get($request, "properties.{$key}.0.html")) {
|
||||
return Arr::get($request, "properties.{$key}.0.html");
|
||||
}
|
||||
|
||||
if (is_string(Arr::get($request, "properties.{$key}.0"))) {
|
||||
return Arr::get($request, "properties.{$key}.0");
|
||||
}
|
||||
|
||||
if (is_string(Arr::get($request, "properties.{$key}"))) {
|
||||
return Arr::get($request, "properties.{$key}");
|
||||
}
|
||||
|
||||
return Arr::get($request, $key);
|
||||
}
|
||||
}
|
|
@ -28,7 +28,6 @@
|
|||
"league/commonmark": "^2.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"mf2/mf2": "~0.3",
|
||||
"spatie/browsershot": "~3.0",
|
||||
"spatie/commonmark-highlighter": "^3.0",
|
||||
"symfony/html-sanitizer": "^6.1"
|
||||
},
|
||||
|
@ -46,7 +45,7 @@
|
|||
"phpunit/phpunit": "^9.0",
|
||||
"spatie/laravel-ignition": "^1.0",
|
||||
"spatie/laravel-ray": "^1.12",
|
||||
"vimeo/psalm": "^4.0"
|
||||
"vimeo/psalm": "^5.0"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
|
|
1644
composer.lock
generated
1644
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -31,4 +31,8 @@ return [
|
|||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'cloudconvert' => [
|
||||
'token' => env('CLOUDCONVERT_API_TOKEN'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -212,6 +212,17 @@ EOF;
|
|||
->where('id', $noteWithLongUrl->id)
|
||||
->update(['updated_at' => $now->toDateTimeString()]);
|
||||
|
||||
$now = Carbon::now();
|
||||
$noteReplyingToMastodon = Note::create([
|
||||
'note' => 'Yup, #DevOps is hard',
|
||||
'in_reply_to' => 'https://mastodon.social/@Gargron/109381161123311795',
|
||||
'created_at' => $now,
|
||||
'client_id' => 'https://quill.p3k.io/',
|
||||
]);
|
||||
DB::table('notes')
|
||||
->where('id', $noteReplyingToMastodon->id)
|
||||
->update(['updated_at' => $now->toDateTimeString()]);
|
||||
|
||||
Note::factory(10)->create();
|
||||
}
|
||||
}
|
||||
|
|
1395
package-lock.json
generated
1395
package-lock.json
generated
File diff suppressed because it is too large
Load diff
23
package.json
23
package.json
|
@ -5,31 +5,30 @@
|
|||
"repository": "https://github.com/jonnybarnes/jonnybarnes.uk",
|
||||
"license": "CC0-1.0",
|
||||
"dependencies": {
|
||||
"normalize.css": "^8.0.1",
|
||||
"puppeteer": "^19.2.2"
|
||||
"normalize.css": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.2",
|
||||
"@babel/core": "^7.20.12",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"babel-loader": "^9.1.0",
|
||||
"babel-loader": "^9.1.2",
|
||||
"browserlist": "^1.0.1",
|
||||
"compression-webpack-plugin": "^10.0.0",
|
||||
"css-loader": "^6.7.2",
|
||||
"css-loader": "^6.7.3",
|
||||
"cssnano": "^5.1.14",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint": "^8.33.0",
|
||||
"eslint-webpack-plugin": "^3.2.0",
|
||||
"mini-css-extract-plugin": "^2.7.0",
|
||||
"postcss": "^8.4.19",
|
||||
"mini-css-extract-plugin": "^2.7.2",
|
||||
"postcss": "^8.4.21",
|
||||
"postcss-combine-duplicated-selectors": "^10.0.2",
|
||||
"postcss-combine-media-query": "^1.0.1",
|
||||
"postcss-import": "^15.0.0",
|
||||
"postcss-loader": "^7.0.1",
|
||||
"stylelint": "^14.15.0",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-loader": "^7.0.2",
|
||||
"stylelint": "^14.16.1",
|
||||
"stylelint-config-standard": "^29.0.0",
|
||||
"stylelint-webpack-plugin": "^3.1.1",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^5.0.0"
|
||||
"webpack-cli": "^5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"compress": "scripts/compress",
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<?xml version="1.0"?>
|
||||
<psalm
|
||||
totallyTyped="false"
|
||||
errorLevel="7"
|
||||
resolveFromConfigFile="true"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
|
|
|
@ -8,9 +8,7 @@
|
|||
generating a name property for the h-feed -->
|
||||
<span class="p-name"></span>
|
||||
@foreach ($notes as $note)
|
||||
<div class="h-entry">
|
||||
@include('templates.note', ['note' => $note])
|
||||
</div>
|
||||
@include('templates.note', ['note' => $note])
|
||||
@endforeach
|
||||
</div>
|
||||
{!! $notes->render() !!}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
@section('title'){{ strip_tags($note->getOriginal('note')) }} « Notes « @stop
|
||||
|
||||
@section('content')
|
||||
<div class="h-entry">
|
||||
@include('templates.note', ['note' => $note])
|
||||
@foreach($note->webmentions->filter(function ($webmention) {
|
||||
return ($webmention->type == 'in-reply-to');
|
||||
|
@ -49,7 +48,6 @@
|
|||
@endif
|
||||
<!-- this empty tags are for https://brid.gy’s publishing service -->
|
||||
<a href="https://brid.gy/publish/twitter"></a>
|
||||
</div>
|
||||
@stop
|
||||
|
||||
@section('scripts')
|
||||
|
|
|
@ -1 +1 @@
|
|||
<span class="u-category h-card mini-h-card"><a class="u-url p-name" href="{{ $contact->homepage }}">{!! $contact->name !!}</a><span class="hovercard">@if ($contact->facebook)<a class="u-url" href="https://www.facebook.com/{{ $contact->facebook }}"><img class="social-icon" src="/assets/img/social-icons/facebook.svg"> {{ $contact->facebook_name ?: 'Facebook' }}</a>@endif @if ($contact->twitter)<a class="u-url" href="https://twitter.com/{{ $contact->twitter }}"><img class="social-icon" src="/assets/img/social-icons/twitter.svg"> {{ $contact->twitter }}</a>@endif<img class="u-photo" alt="" src="{{ $contact->photo }}"></span></span>
|
||||
<span class="u-category h-card mini-h-card"><a class="u-url p-name" href="{{ $contact->homepage }}">{!! $contact->name !!}</a><span class="hovercard">@if ($contact->facebook)<a class="u-url" href="https://www.facebook.com/{{ $contact->facebook }}"><img class="social-icon" src="/assets/img/social-icons/facebook.svg"> Facebook</a>@endif @if ($contact->twitter)<a class="u-url" href="https://twitter.com/{{ $contact->twitter }}"><img class="social-icon" src="/assets/img/social-icons/twitter.svg"> {{ $contact->twitter }}</a>@endif<img class="u-photo" alt="" src="{{ $contact->photo }}"></span></span>
|
||||
|
|
|
@ -1,56 +1,75 @@
|
|||
@if ($note->twitter)
|
||||
{!! $note->twitter->html !!}
|
||||
@elseif ($note->in_reply_to)
|
||||
<div class="p-in-reply-to h-cite reply-to">
|
||||
In reply to <a href="{{ $note->in_reply_to }}" class="u-url">{{ $note->in_reply_to }}</a>
|
||||
</div>
|
||||
@endif
|
||||
<div class="note">
|
||||
<div class="e-content p-name">
|
||||
{!! $note->note !!}
|
||||
@foreach($note->media as $media)
|
||||
@if($media->type == 'image') <a class="naked-link" href="{{ $media->url }}"><img class="u-photo" src="{{ $media->url }}" alt="" @if($media->image_widths !== null) srcset="{{ $media->url }} {{ $media->image_widths }}w, {{ $media->mediumurl }} 1000w, {{ $media->smallurl }} 500w" sizes="80vh"@endif></a>@endif
|
||||
@if($media->type == 'audio') <audio class="u-audio" src="{{ $media->url }}" controls>@endif
|
||||
@if($media->type == 'video') <video class="u-video" src="{{ $media->url }}" controls>@endif
|
||||
@if($media->type == 'download') <p><a class="u-attachment" href="{{ $media->url }}">Download the attached media</a></p>@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($note->twitter_content)
|
||||
<div class="p-bridgy-twitter-content">
|
||||
{!! $note->twitter_content !!}
|
||||
</div>
|
||||
@endif
|
||||
<div class="note-metadata">
|
||||
<div>
|
||||
<a class="u-url" href="/notes/{{ $note->nb60id }}"><time class="dt-published" datetime="{{ $note->iso8601 }}" title="{{ $note->iso8601 }}">{{ $note->humandiff }}</time></a>@if($note->client) via <a class="client" href="{{ $note->client->client_url }}">{{ $note->client->client_name }}</a>@endif
|
||||
@if($note->place)@if($note->getOriginal('note')) in <span class="p-location h-card"><a class="p-name u-url" href="{{ $note->place->longurl }}">{{ $note->address }}</a><data class="p-latitude" value="{{ $note->place->latitude }}"></data><data class="p-longitude" value="{{ $note->place->longitude }}"></data></span>@endif
|
||||
@elseif($note->address) in <span class="p-location h-adr">{!! $note->address !!}<data class="p-latitude" value="{{ $note->latitude }}"></data><data class="p-longitude" value="{{ $note->longitude }}"></data></span>@endif
|
||||
@if($note->replies_count > 0) @include('templates.replies-icon'): {{ $note->replies_count }}@endif
|
||||
|
||||
</div>
|
||||
<div class="syndication-links">
|
||||
@if(
|
||||
$note->tweet_id ||
|
||||
$note->facebook_url ||
|
||||
$note->swarm_url ||
|
||||
$note->instagram_url ||
|
||||
$note->mastodon_url)
|
||||
@include('templates.social-links', [
|
||||
'tweet_id' => $note->tweet_id,
|
||||
'facebook_url' => $note->facebook_url,
|
||||
'swarm_url' => $note->swarm_url,
|
||||
'instagram_url' => $note->instagram_url,
|
||||
'mastodon_url' => $note->mastodon_url,
|
||||
])
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@if ($note->place)
|
||||
<div class="map"
|
||||
data-latitude="{{ $note->place->latitude }}"
|
||||
data-longitude="{{ $note->place->longitude }}"
|
||||
data-name="{{ $note->place->name }}"
|
||||
data-marker="{{ $note->place->icon }}"></div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="h-entry">
|
||||
@if ($note->twitter)
|
||||
{!! $note->twitter->html !!}
|
||||
@elseif ($note->in_reply_to)
|
||||
<div class="u-in-reply-to h-cite reply-to">
|
||||
In reply to <a href="{{ $note->in_reply_to }}" class="u-url">{{ $note->in_reply_to }}</a>
|
||||
</div>
|
||||
@endif
|
||||
<div class="note">
|
||||
<div class="e-content p-name">
|
||||
{!! $note->note !!}
|
||||
@foreach($note->media as $media)
|
||||
@if($media->type === 'image')
|
||||
<a class="naked-link" href="{{ $media->url }}">
|
||||
<img class="u-photo" src="{{ $media->url }}" alt="" @if($media->image_widths !== null) srcset="{{ $media->url }} {{ $media->image_widths }}w, {{ $media->mediumurl }} 1000w, {{ $media->smallurl }} 500w" sizes="80vh"@endif>
|
||||
</a>
|
||||
@endif
|
||||
@if($media->type === 'audio')
|
||||
<audio class="u-audio" src="{{ $media->url }}" controls>
|
||||
@endif
|
||||
@if($media->type === 'video')
|
||||
<video class="u-video" src="{{ $media->url }}" controls>
|
||||
@endif
|
||||
@if($media->type === 'download')
|
||||
<p><a class="u-attachment" href="{{ $media->url }}">Download the attached media</a></p>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($note->twitter_content)
|
||||
<div class="p-bridgy-twitter-content">
|
||||
{!! $note->twitter_content !!}
|
||||
</div>
|
||||
@endif
|
||||
<div class="note-metadata">
|
||||
<div>
|
||||
<a class="u-url" href="/notes/{{ $note->nb60id }}">
|
||||
<time class="dt-published" datetime="{{ $note->iso8601 }}" title="{{ $note->iso8601 }}">{{ $note->humandiff }}</time>
|
||||
</a>
|
||||
@if($note->client) via <a class="client" href="{{ $note->client->client_url }}">{{ $note->client->client_name }}</a>@endif
|
||||
@if($note->place)
|
||||
@if($note->getOriginal('note'))
|
||||
in <span class="p-location h-card"><a class="p-name u-url" href="{{ $note->place->longurl }}">{{ $note->address }}</a><data class="p-latitude" value="{{ $note->place->latitude }}"></data><data class="p-longitude" value="{{ $note->place->longitude }}"></data></span>
|
||||
@endif
|
||||
@elseif($note->address)
|
||||
in <span class="p-location h-adr">{!! $note->address !!}<data class="p-latitude" value="{{ $note->latitude }}"></data><data class="p-longitude" value="{{ $note->longitude }}"></data></span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="syndication-links">
|
||||
@if(
|
||||
$note->tweet_id ||
|
||||
$note->facebook_url ||
|
||||
$note->swarm_url ||
|
||||
$note->instagram_url ||
|
||||
$note->mastodon_url
|
||||
)
|
||||
@include('templates.social-links', [
|
||||
'tweet_id' => $note->tweet_id,
|
||||
'facebook_url' => $note->facebook_url,
|
||||
'swarm_url' => $note->swarm_url,
|
||||
'instagram_url' => $note->instagram_url,
|
||||
'mastodon_url' => $note->mastodon_url,
|
||||
])
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if ($note->place)
|
||||
<div class="map"
|
||||
data-latitude="{{ $note->place->latitude }}"
|
||||
data-longitude="{{ $note->place->longitude }}"
|
||||
data-name="{{ $note->place->name }}"
|
||||
data-marker="{{ $note->place->icon }}"
|
||||
></div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -41,6 +41,7 @@ class NotesTest extends TestCase
|
|||
$this->actingAs($user)->post('/admin/notes', [
|
||||
'content' => 'A new test note',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('notes', [
|
||||
'note' => 'A new test note',
|
||||
]);
|
||||
|
|
|
@ -675,7 +675,7 @@ class MicropubControllerTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function micropubClientWebReauestCanEncodeTokenWithinTheForm(): void
|
||||
public function micropubClientWebRequestCanEncodeTokenWithinTheForm(): void
|
||||
{
|
||||
$faker = Factory::create();
|
||||
$note = $faker->text;
|
||||
|
@ -691,4 +691,32 @@ class MicropubControllerTest extends TestCase
|
|||
$response->assertJson(['response' => 'created']);
|
||||
$this->assertDatabaseHas('notes', ['note' => $note]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function micropubClientApiRequestCreatesArticlesWhenItIncludesTheNameProperty(): void
|
||||
{
|
||||
$faker = Factory::create();
|
||||
$name = $faker->text(50);
|
||||
$content = $faker->paragraphs(5, true);
|
||||
|
||||
$response = $this->postJson(
|
||||
'/api/post',
|
||||
[
|
||||
'type' => ['h-entry'],
|
||||
'properties' => [
|
||||
'name' => $name,
|
||||
'content' => $content,
|
||||
],
|
||||
],
|
||||
['HTTP_Authorization' => 'Bearer ' . $this->getToken()]
|
||||
);
|
||||
|
||||
$response
|
||||
->assertJson(['response' => 'created'])
|
||||
->assertStatus(201);
|
||||
$this->assertDatabaseHas('articles', [
|
||||
'title' => $name,
|
||||
'main' => $content,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,12 +79,21 @@ class ArticlesTest extends TestCase
|
|||
|
||||
$emptyScope = Article::date()->get();
|
||||
$this->assertCount(2, $emptyScope);
|
||||
}
|
||||
|
||||
// Check the December case
|
||||
$article = Article::factory()->create([
|
||||
'created_at' => Carbon::now()->setMonth(12)->toDateTimeString(),
|
||||
'updated_at' => Carbon::now()->setMonth(12)->toDateTimeString(),
|
||||
/** @test */
|
||||
public function dateScopeReturnsExpectedArticlesForDecember(): void
|
||||
{
|
||||
Article::factory()->create([
|
||||
'created_at' => Carbon::now()->setDay(11)->setMonth(11)->toDateTimeString(),
|
||||
'updated_at' => Carbon::now()->setDay(11)->setMonth(11)->toDateTimeString(),
|
||||
]);
|
||||
|
||||
Article::factory()->create([
|
||||
'created_at' => Carbon::now()->setMonth(12)->setDay(12)->toDateTimeString(),
|
||||
'updated_at' => Carbon::now()->setMonth(12)->setDay(12)->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$this->assertCount(1, Article::date(date('Y'), 12)->get());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ class BookmarksTest extends TestCase
|
|||
{
|
||||
/**
|
||||
* @test
|
||||
*
|
||||
* @group puppeteer
|
||||
*
|
||||
public function takeScreenshotOfDuckDuckGo()
|
||||
|
|
|
@ -17,6 +17,7 @@ class HelpersTest extends TestCase
|
|||
|
||||
/**
|
||||
* @test
|
||||
*
|
||||
* @dataProvider urlProvider
|
||||
*
|
||||
* @param string $input
|
||||
|
|
|
@ -6,10 +6,11 @@ namespace Tests\Unit\Jobs;
|
|||
|
||||
use App\Exceptions\InternetArchiveException;
|
||||
use App\Jobs\ProcessBookmark;
|
||||
use App\Jobs\SaveScreenshot;
|
||||
use App\Models\Bookmark;
|
||||
use App\Services\BookmarkService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProcessBookmarkJobTest extends TestCase
|
||||
|
@ -17,13 +18,12 @@ class ProcessBookmarkJobTest extends TestCase
|
|||
use RefreshDatabase;
|
||||
|
||||
/** @test */
|
||||
public function screenshotAndArchiveLinkAreSavedByJob(): void
|
||||
public function archiveLinkIsSavedByJobAndScreenshotJobIsQueued(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$bookmark = Bookmark::factory()->create();
|
||||
$uuid = Uuid::uuid4();
|
||||
$service = $this->createMock(BookmarkService::class);
|
||||
$service->method('saveScreenshot')
|
||||
->willReturn($uuid->toString());
|
||||
$service->method('getArchiveLink')
|
||||
->willReturn('https://web.archive.org/web/1234');
|
||||
$this->app->instance(BookmarkService::class, $service);
|
||||
|
@ -32,19 +32,19 @@ class ProcessBookmarkJobTest extends TestCase
|
|||
$job->handle();
|
||||
|
||||
$this->assertDatabaseHas('bookmarks', [
|
||||
'screenshot' => $uuid->toString(),
|
||||
'archive' => 'https://web.archive.org/web/1234',
|
||||
]);
|
||||
|
||||
Queue::assertPushed(SaveScreenshot::class);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function archiveLinkSavedAsNullWhenExceptionThrown(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$bookmark = Bookmark::factory()->create();
|
||||
$uuid = Uuid::uuid4();
|
||||
$service = $this->createMock(BookmarkService::class);
|
||||
$service->method('saveScreenshot')
|
||||
->willReturn($uuid->toString());
|
||||
$service->method('getArchiveLink')
|
||||
->will($this->throwException(new InternetArchiveException()));
|
||||
$this->app->instance(BookmarkService::class, $service);
|
||||
|
@ -53,7 +53,7 @@ class ProcessBookmarkJobTest extends TestCase
|
|||
$job->handle();
|
||||
|
||||
$this->assertDatabaseHas('bookmarks', [
|
||||
'screenshot' => $uuid->toString(),
|
||||
'id' => $bookmark->id,
|
||||
'archive' => null,
|
||||
]);
|
||||
}
|
||||
|
|
160
tests/Unit/Jobs/SaveScreenshotJobTest.php
Normal file
160
tests/Unit/Jobs/SaveScreenshotJobTest.php
Normal file
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Jobs;
|
||||
|
||||
use App\Jobs\SaveScreenshot;
|
||||
use App\Models\Bookmark;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SaveScreenshotJobTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @test */
|
||||
public function screenshotIsSavedByJob(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$guzzleMock = new MockHandler([
|
||||
new Response(201, ['Content-Type' => 'application/json'], '{"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":null,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","result":null,"created_at":"2023-01-07T21:05:48+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
|
||||
new Response(201, ['Content-Type' => 'application/json'], '{"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":null,"code":null,"message":null,"percent":100,"operation":"export\/url","result":null,"created_at":"2023-01-07T21:10:02+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'image/png'], fopen(__DIR__ . '/../../theverge.com.png', 'rb')),
|
||||
]);
|
||||
$guzzleHandler = HandlerStack::create($guzzleMock);
|
||||
$guzzleClient = new Client(['handler' => $guzzleHandler]);
|
||||
$this->app->instance(Client::class, $guzzleClient);
|
||||
$retryMock = new MockHandler([
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":1,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":0,"code":null,"message":null,"percent":100,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"result":{"files":[{"filename":"theverge.com.png","size":811819,"url":"https:\/\/storage.cloudconvert.com\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb\/theverge.com.png?AWSAccessKeyId=cloudconvert-production&Expires=1673212203&Signature=xyz&response-content-disposition=attachment%3B%20filename%3D%22theverge.com.png%22&response-content-type=image%2Fpng"}]},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":"2023-01-07T21:10:03+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
|
||||
]);
|
||||
$retryHandler = HandlerStack::create($retryMock);
|
||||
$retryHandler->push(Middleware::retry(
|
||||
function ($retries, $request, $response, $exception) {
|
||||
// Limit the number of retries to 5
|
||||
if ($retries >= 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Retry connection exceptions
|
||||
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry on server errors
|
||||
if ($response && $response->getStatusCode() >= 500) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$responseBody = '';
|
||||
|
||||
if (is_string($response)) {
|
||||
$responseBody = $response;
|
||||
}
|
||||
|
||||
if ($response instanceof Response) {
|
||||
$responseBody = $response->getBody()->getContents();
|
||||
$response->getBody()->rewind();
|
||||
}
|
||||
|
||||
// Finally for CloudConvert, retry if status is not final
|
||||
return json_decode($responseBody, false, 512, JSON_THROW_ON_ERROR)?->data?->status !== 'finished';
|
||||
},
|
||||
function () {
|
||||
// Retry after 1 second
|
||||
return 1000;
|
||||
}
|
||||
));
|
||||
$retryClient = new Client(['handler' => $retryHandler]);
|
||||
$this->app->instance('RetryGuzzle', $retryClient);
|
||||
|
||||
$bookmark = Bookmark::factory()->create();
|
||||
$job = new SaveScreenshot($bookmark);
|
||||
$job->handle();
|
||||
$bookmark->refresh();
|
||||
|
||||
$this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot);
|
||||
Storage::disk('public')->assertExists('/assets/img/bookmarks/' . $bookmark->screenshot . '.png');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function screenshotJobHandlesUnfinishedTasks(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$guzzleMock = new MockHandler([
|
||||
new Response(201, ['Content-Type' => 'application/json'], '{"id":1,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"waiting","credits":null,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","result":null,"created_at":"2023-01-07T21:05:48+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
|
||||
new Response(201, ['Content-Type' => 'application/json'], '{"id":2,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"waiting","credits":null,"code":null,"message":null,"percent":100,"operation":"export\/url","result":null,"created_at":"2023-01-07T21:10:02+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'image/png'], fopen(__DIR__ . '/../../theverge.com.png', 'rb')),
|
||||
]);
|
||||
$guzzleHandler = HandlerStack::create($guzzleMock);
|
||||
$guzzleClient = new Client(['handler' => $guzzleHandler]);
|
||||
$this->app->instance(Client::class, $guzzleClient);
|
||||
$container = [];
|
||||
$history = Middleware::history($container);
|
||||
$retryMock = new MockHandler([
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"id":3,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"waiting","credits":1,"code":null,"message":null,"percent":50,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"id":4,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":1,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"id":5,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"waiting","credits":0,"code":null,"message":null,"percent":50,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
|
||||
new Response(200, ['Content-Type' => 'application/json'], '{"id":6,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":0,"code":null,"message":null,"percent":100,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"result":{"files":[{"filename":"theverge.com.png","size":811819,"url":"https:\/\/storage.cloudconvert.com\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb\/theverge.com.png?AWSAccessKeyId=cloudconvert-production&Expires=1673212203&Signature=xyz&response-content-disposition=attachment%3B%20filename%3D%22theverge.com.png%22&response-content-type=image%2Fpng"}]},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":"2023-01-07T21:10:03+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
|
||||
]);
|
||||
$retryHandler = HandlerStack::create($retryMock);
|
||||
$retryHandler->push($history);
|
||||
$retryHandler->push(Middleware::retry(
|
||||
function ($retries, $request, $response, $exception) {
|
||||
// Limit the number of retries to 5
|
||||
if ($retries >= 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Retry connection exceptions
|
||||
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry on server errors
|
||||
if ($response && $response->getStatusCode() >= 500) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$responseBody = '';
|
||||
|
||||
if (is_string($response)) {
|
||||
$responseBody = $response;
|
||||
}
|
||||
|
||||
if ($response instanceof Response) {
|
||||
$responseBody = $response->getBody()->getContents();
|
||||
$response->getBody()->rewind();
|
||||
}
|
||||
|
||||
// Finally for CloudConvert, retry if status is not final
|
||||
return json_decode($responseBody, false, 512, JSON_THROW_ON_ERROR)?->data?->status !== 'finished';
|
||||
},
|
||||
function () {
|
||||
// Retry after 1 second
|
||||
return 1000;
|
||||
}
|
||||
));
|
||||
$retryClient = new Client(['handler' => $retryHandler]);
|
||||
$this->app->instance('RetryGuzzle', $retryClient);
|
||||
|
||||
$bookmark = Bookmark::factory()->create();
|
||||
$job = new SaveScreenshot($bookmark);
|
||||
$job->handle();
|
||||
$bookmark->refresh();
|
||||
|
||||
$this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot);
|
||||
Storage::disk('public')->assertExists('/assets/img/bookmarks/' . $bookmark->screenshot . '.png');
|
||||
// Also assert we made the correct number of requests
|
||||
$this->assertCount(2, $container);
|
||||
// However with retries there should be more than 4 responses for the 2 requests
|
||||
$this->assertEquals(0, $retryMock->count());
|
||||
}
|
||||
}
|
|
@ -257,7 +257,7 @@ class NotesTest extends TestCase
|
|||
/** @test */
|
||||
public function reverseGeocodeACounty(): void
|
||||
{
|
||||
// Note I’ve removed everything below county to test for querires where
|
||||
// Note I’ve removed everything below county to test for queries where
|
||||
// that’s all that is returned
|
||||
// phpcs:disable Generic.Files.LineLength.TooLong
|
||||
$json = <<<JSON
|
||||
|
|
|
@ -83,7 +83,6 @@ class PlacesTest extends TestCase
|
|||
'url' => ['https://www.openstreetmap.org/way/1234'],
|
||||
],
|
||||
]);
|
||||
$this->assertInstanceOf('App\Models\Place', $ret);
|
||||
$this->assertCount(11, Place::all());
|
||||
}
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ class TagsTest extends TestCase
|
|||
|
||||
/**
|
||||
* @test
|
||||
*
|
||||
* @dataProvider tagsProvider
|
||||
*
|
||||
* @param string $input
|
||||
|
|
BIN
tests/theverge.com.png
Normal file
BIN
tests/theverge.com.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 793 KiB |
Loading…
Add table
Reference in a new issue