Merge pull request 'MTM Micropub Media support' (#68) from develop into main

Reviewed-on: #68
This commit is contained in:
Jonny Barnes 2026-01-04 11:37:18 +01:00
commit bc8d535b24
30 changed files with 309 additions and 67 deletions

View file

@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Models\Media;
use App\Models\Note;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class MigrateMedia extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:migrate-media';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate media';
/**
* Execute the console command.
*/
public function handle(): void
{
// First check new `media_note` table exists
if (! DB::getSchemaBuilder()->hasTable('media_note')) {
$this->error('The table "media_note" does not exist.');
exit(1);
}
// Load all media already saved in `media_endpoint` table
$this->line('Updating existing local media');
$mediaEndpointMedia = Media::all();
// Save relationship in new `media_note` table based on `media_endpoint.note_id`
$this->withProgressBar($mediaEndpointMedia, function (Media $mediaEndpointMediaItem) {
$note = Note::find($mediaEndpointMediaItem->note_id);
if ($note) {
$note->media()->attach($mediaEndpointMediaItem->id);
}
});
// Load all media records from `media` table
$this->line('');
$this->line('Migrating old media from S3');
$oldMedia = DB::table('media')->get();
foreach ($oldMedia as $oldMediaItem) {
// We only want to process the S3 media
if ($oldMediaItem->disk !== 's3') {
$this->warn('Original media item never stored in S3');
continue;
}
// Check media exists in S3
if (! Storage::disk('s3')->exists($oldMediaItem->id.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)) {
$this->warn('Original media item not found in S3');
continue;
}
// We want to just copy the file, check it does not already exist locally
if (Storage::disk('public')->exists('media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)) {
$this->warn('File already exists locally with filename of original media item');
continue;
}
// Copy the file
Storage::disk('public')->writeStream('media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name, Storage::disk('s3')->readStream($oldMediaItem->id.DIRECTORY_SEPARATOR.$oldMediaItem->file_name));
// Save relationship based on `media.model_id`
// I have already checked they are all notes
$note = $oldMediaItem->model_id;
$newMediaItem = Media::create([
'path' => 'media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name,
'type' => 'image',
]);
$note->media()->attach($newMediaItem->id);
$this->info('Media item migrated from S3');
}
$this->line('');
$this->line('Migration finished');
}
}

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Note;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
@ -53,7 +52,7 @@ class NotesController extends Controller
->withCount(['webmentions AS reposts' => function ($query) {
$query->where('type', 'repost-of');
}])->firstOrFail();
} catch (ModelNotFoundException $exception) {
} catch (\Exception) {
abort(404);
}

View file

@ -80,6 +80,9 @@ class MicropubRequest extends FormRequest
// Add checkin value
$this->micropubData['checkin'] = Arr::get($data, 'checkin');
$this->micropubData['syndication'] = Arr::get($data, 'properties.syndication.0');
// Add photos
$this->micropubData['photos'] = Arr::get($data, 'properties.photo');
}
private function normalizeMicropubForm(): void

View file

@ -33,20 +33,20 @@ class ProcessMedia implements ShouldQueue
public function handle(ImageManager $manager): void
{
// Load file
$file = Storage::disk('local')->get('media/' . $this->filename);
$file = Storage::disk('local')->get($this->filename);
// Open file
try {
$image = $manager->read($file);
} catch (DecoderException) {
// not an image; delete file and end job
Storage::disk('local')->delete('media/' . $this->filename);
Storage::disk('local')->delete($this->filename);
return;
}
// Save the file publicly
Storage::disk('public')->put('media/' . $this->filename, $file);
Storage::disk('public')->put($this->filename, $file);
// Create smaller versions if necessary
if ($image->width() > 1000) {
@ -57,13 +57,13 @@ class ProcessMedia implements ShouldQueue
$basename = trim(implode('.', $filenameParts), '.');
$medium = $image->resize(width: 1000);
Storage::disk('public')->put('media/' . $basename . '-medium.' . $extension, (string) $medium->encode());
Storage::disk('public')->put($basename . '-medium.' . $extension, (string) $medium->encode());
$small = $image->resize(width: 500);
Storage::disk('public')->put('media/' . $basename . '-small.' . $extension, (string) $small->encode());
Storage::disk('public')->put($basename . '-small.' . $extension, (string) $small->encode());
}
// Now we can delete the locally saved image
Storage::disk('local')->delete('media/' . $this->filename);
Storage::disk('local')->delete($this->filename);
}
}

View file

@ -7,7 +7,7 @@ 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;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str;
class Media extends Model
@ -20,9 +20,11 @@ class Media extends Model
/** @var array<int, string> */
protected $fillable = ['token', 'path', 'type', 'image_widths'];
public function note(): BelongsTo
public function notes(): BelongsToMany
{
return $this->belongsTo(Note::class);
return $this->belongsToMany(Note::class)
->withPivot('alt_text', 'order')
->withTimestamps();
}
protected function url(): Attribute

View file

@ -14,7 +14,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Cache;
@ -92,9 +91,12 @@ class Note extends Model
return $this->belongsTo(Place::class);
}
public function media(): HasMany
public function media(): BelongsToMany
{
return $this->hasMany(Media::class);
return $this->BelongsToMany(Media::class)
->withPivot('alt_text', 'order')
->withTimestamps()
->orderBy('order');
}
/**

View file

@ -48,12 +48,15 @@ class NoteService
$note->place()->associate($this->getCheckin($data));
$note->swarm_url = $this->getSwarmUrl($data);
}
//
// $note->instagram_url = $this->getInstagramUrl($request);
//
// foreach ($this->getMedia($request) as $media) {
// $note->media()->save($media);
// }
foreach ($this->getMedia($data) as $index => $media) {
$note->media()->attach($media['value']->id, [
'alt_text' => $media['alt'],
'order' => $index,
]);
}
$note->save();
@ -188,23 +191,44 @@ class NoteService
/**
* Get the media URLs from the request to create a new note.
*/
private function getMedia(array $request): array
private function getMedia(array $data): array
{
$media = [];
$photos = Arr::get($request, 'photo') ?? Arr::get($request, 'properties.photo');
$photos = Arr::get($data, 'photos');
if (isset($photos)) {
foreach ((array) $photos as $photo) {
// $photo can be a string of the URL opf the photo
// or it can be an object with a `value` and `alt`
$photoUrl = null;
$photoAlt = null;
if (is_string($photo)) {
$photoUrl = $photo;
} elseif (is_array($photo)) {
$photoUrl = $photo['value'];
$photoAlt = $photo['alt'];
}
if (empty($photoUrl)) {
continue;
}
// check the media was uploaded to my endpoint, and use path
if (Str::startsWith($photo, config('filesystems.disks.s3.url'))) {
$path = substr($photo, strlen(config('filesystems.disks.s3.url')));
$media[] = Media::where('path', ltrim($path, '/'))->firstOrFail();
if (Str::startsWith($photoUrl, config('filesystems.disks.public.url'))) {
$path = substr($photoUrl, strlen(config('filesystems.disks.public.url')));
$media[] = [
'value' => Media::where('path', ltrim($path, '/'))->firstOrFail(),
'alt' => $photoAlt,
];
} else {
$newMedia = Media::firstOrNew(['path' => $photo]);
$newMedia = Media::firstOrNew(['path' => $photoUrl]);
// currently assuming this is a photo from Swarm or OwnYourGram
$newMedia->type = 'image';
$newMedia->save();
$media[] = $newMedia;
$media[] = [
'value' => $newMedia,
'alt' => $photoAlt,
];
}
}
}

View file

@ -101,7 +101,7 @@ return [
'previous_keys' => [
...array_filter(
explode(',', env('APP_PREVIOUS_KEYS', ''))
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],

View file

@ -104,7 +104,7 @@ return [
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the amount of seconds before a password confirmation
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|

View file

@ -26,8 +26,9 @@ return [
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "apc", "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane", "null"
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
@ -89,6 +90,14 @@ return [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
@ -102,6 +111,6 @@ return [
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'_cache_'),
];

View file

@ -40,6 +40,7 @@ return [
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
@ -58,7 +59,7 @@ return [
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@ -78,7 +79,7 @@ return [
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@ -147,7 +148,7 @@ return [
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'_database_'),
'persistent' => env('REDIS_PERSISTENT', false),
],
@ -158,6 +159,10 @@ return [
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
@ -167,6 +172,10 @@ return [
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],

View file

@ -41,7 +41,7 @@ return [
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'url' => rtrim(env('APP_URL'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,

View file

@ -1,7 +1,6 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
@ -54,7 +53,7 @@ return [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', env('LOG_STACK', 'single')),
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
@ -97,11 +96,10 @@ return [
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],

View file

@ -30,7 +30,8 @@ return [
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "log", "array", "failover", "roundrobin"
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
@ -45,7 +46,7 @@ return [
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
@ -60,6 +61,10 @@ return [
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
@ -80,6 +85,16 @@ return [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],

View file

@ -24,7 +24,8 @@ return [
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
@ -72,6 +73,22 @@ return [
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*

View file

@ -15,7 +15,11 @@ return [
*/
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [

View file

@ -13,8 +13,8 @@ return [
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "apc",
| "memcached", "redis", "dynamodb", "array"
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
@ -97,7 +97,7 @@ return [
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "apc", "dynamodb", "memcached", "redis"
| Affects: "dynamodb", "memcached", "redis"
|
*/
@ -125,12 +125,11 @@ return [
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
@ -153,7 +152,7 @@ return [
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain and all subdomains. Typically, this shouldn't be changed.
| domain without subdomains. Typically, this shouldn't be changed.
|
*/

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('media_note', function (Blueprint $table) {
$table->id();
$table->foreignId('note_id')->constrained()->onDelete('cascade');
$table->foreignId('media_id')->constrained('media_endpoint')->onDelete('cascade');
$table->text('alt_text')->nullable();
$table->integer('order')->default(0);
$table->timestamps();
$table->unique(['note_id', 'media_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('media_note');
}
};

View file

@ -84,7 +84,9 @@ class NotesTableSeeder extends Seeder
// copy aaron’s profile pic in place
$spl = new SplFileInfo(public_path() . '/assets/profile-images/aaronparecki.com');
if ($spl->isDir() === false) {
mkdir(public_path() . '/assets/profile-images/aaronparecki.com', 0755);
if (! mkdir($concurrentDirectory = public_path() . '/assets/profile-images/aaronparecki.com', 0755) && ! is_dir($concurrentDirectory)) {
throw new \RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
}
copy(base_path() . '/tests/aaron.png', public_path() . '/assets/profile-images/aaronparecki.com/image');
}
@ -171,7 +173,9 @@ class NotesTableSeeder extends Seeder
$noteWithOnlyImage->setCreatedAt($now);
$noteWithOnlyImage->setUpdatedAt($now);
$noteWithOnlyImage->save();
$noteWithOnlyImage->media()->save($media);
$noteWithOnlyImage->media()->attach($media->id, [
'alt_text' => 'Test alt text',
]);
DB::table('notes')
->where('id', $noteWithOnlyImage->id)
->update(['updated_at' => $now->toDateTimeString()]);

View file

@ -2,11 +2,11 @@
services:
laravel.test:
build:
context: ./vendor/laravel/sail/runtimes/8.4
context: ./vendor/laravel/sail/runtimes/8.5
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP}'
image: sail-8.4/app
image: sail-8.5/app
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:

Some files were not shown because too many files have changed in this diff Show more