Merge pull request 'MTM Micropub Media support' (#68) from develop into main
Reviewed-on: #68
This commit is contained in:
commit
bc8d535b24
30 changed files with 309 additions and 67 deletions
92
app/Console/Commands/MigrateMedia.php
Normal file
92
app/Console/Commands/MigrateMedia.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ return [
|
|||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', env('APP_PREVIOUS_KEYS', ''))
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
||||
|
|
|
|||
|
|
@ -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_'),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ return [
|
|||
*/
|
||||
|
||||
'postmark' => [
|
||||
'token' => env('POSTMARK_TOKEN'),
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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()]);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:2/3}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content>p:first-child{margin-block-start:0}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{& button{appearance:none;background:0 0;border:none;&:hover{cursor:pointer}}& #theme-selector-dropdown{position-area:block-end span-inline-start;border:2px solid var(--clr-border);background-color:var(--clr-background);color:var(--clr-text);margin:0;position:absolute;& fieldset{border:0;flex-direction:row;gap:1ch;display:flex;& input{display:none}}}@media (prefers-reduced-motion:no-preference){& #theme-selector-dropdown{&:popover-open{opacity:1;transform:translateY(0)scale(1);@starting-style{opacity:0;transform:translateY(30px)scale(0)}}opacity:0;transition:transform,opacity,display allow-discrete,overlay allow-discrete;transform-origin:100% 0;transition-duration:.5s;transform:translateY(0)scale(0)}}}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}}
|
||||
@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:2/3}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{& button{appearance:none;background:0 0;border:none;&:hover{cursor:pointer}}& #theme-selector-dropdown{position-area:block-end span-inline-start;border:2px solid var(--clr-border);background-color:var(--clr-background);color:var(--clr-text);margin:0;position:absolute;& fieldset{border:0;flex-direction:row;gap:1ch;display:flex;& input{display:none}}}@media (prefers-reduced-motion:no-preference){& #theme-selector-dropdown{&:popover-open{opacity:1;transform:translateY(0)scale(1);@starting-style{opacity:0;transform:translateY(30px)scale(0)}}opacity:0;transition:transform,opacity,display allow-discrete,overlay allow-discrete;transform-origin:100% 0;transition-duration:.5s;transform:translateY(0)scale(0)}}}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}}
|
||||
/*# sourceMappingURL=/assets/css/app.css.map */
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -6,10 +6,17 @@
|
|||
border-radius: var(--border-radius);
|
||||
padding: 1ex 2ex;
|
||||
|
||||
.e-content > p:first-child {
|
||||
.e-content {
|
||||
> p:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.u-photo {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.syndication-links {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
@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>
|
||||
<img class="u-photo" alt="{{ $media->pivot->alt_text }}" @if($media->image_widths !== null) srcset="{{ $media->url }} {{ $media->image_widths }}w, {{ $media->mediumurl }} 1000w, {{ $media->smallurl }} 500w" sizes="min(80ch,80vw)" @else src="{{ $media->url }}" @endif>
|
||||
</a>
|
||||
@endif
|
||||
@if($media->type === 'audio')
|
||||
|
|
|
|||
|
|
@ -236,11 +236,11 @@ class MicropubControllerTest extends TestCase
|
|||
* Test a valid micropub requests using JSON syntax creates a new note.
|
||||
*/
|
||||
#[Test]
|
||||
public function micropub_client_api_request_creates_new_note(): void
|
||||
public function micropub_client_api_request_creates_new_note_with_photo(): void
|
||||
{
|
||||
Queue::fake();
|
||||
Media::create([
|
||||
'path' => 'test-photo.jpg',
|
||||
'path' => 'media/test-photo.jpg',
|
||||
'type' => 'image',
|
||||
]);
|
||||
SyndicationTarget::factory()->create([
|
||||
|
|
@ -265,12 +265,13 @@ class MicropubControllerTest extends TestCase
|
|||
'https://mastodon.social/@jonnybarnes',
|
||||
'https://bsky.app/profile/jonnybarnes.uk',
|
||||
],
|
||||
'photo' => [config('filesystems.disks.s3.url') . '/test-photo.jpg'],
|
||||
'photo' => [config('filesystems.disks.public.url') . '/media/test-photo.jpg'],
|
||||
],
|
||||
],
|
||||
['HTTP_Authorization' => 'Bearer ' . $this->getToken()]
|
||||
);
|
||||
$response
|
||||
->dump()
|
||||
->assertStatus(201)
|
||||
->assertJson(['response' => 'created']);
|
||||
Queue::assertPushed(SendWebMentions::class);
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class ProcessMediaJobTest extends TestCase
|
|||
{
|
||||
$manager = app()->make(ImageManager::class);
|
||||
Storage::disk('local')->put('media/test-image.jpg', file_get_contents(__DIR__.'/../../test-image.jpg'));
|
||||
$job = new ProcessMedia('test-image.jpg');
|
||||
$job = new ProcessMedia('media/test-image.jpg');
|
||||
$job->handle($manager);
|
||||
|
||||
// These need to look in public disk
|
||||
|
|
|
|||
|
|
@ -15,11 +15,18 @@ class MediaTest extends TestCase
|
|||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function get_the_note_that_media_instance_belongs_to(): void
|
||||
public function media_can_belong_to_multiple_notes(): void
|
||||
{
|
||||
$media = Media::factory()->for(Note::factory())->create();
|
||||
$media = Media::factory()->create();
|
||||
$note1 = Note::factory()->create();
|
||||
$note2 = Note::factory()->create();
|
||||
|
||||
$this->assertInstanceOf(Note::class, $media->note);
|
||||
$note1->media()->attach($media->id, ['alt_text' => 'Alt text for note 1']);
|
||||
$note2->media()->attach($media->id, ['alt_text' => 'Alt text for note 2']);
|
||||
|
||||
$this->assertCount(2, $media->notes);
|
||||
$this->assertTrue($media->notes->contains($note1));
|
||||
$this->assertTrue($media->notes->contains($note2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
|
|
|||
|
|
@ -434,4 +434,21 @@ class NotesTest extends TestCase
|
|||
|
||||
$this->assertSame($expected, $note->note);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function note_can_have_media_with_alt_text(): void
|
||||
{
|
||||
$note = Note::factory()->create();
|
||||
$media = Media::factory()->create();
|
||||
|
||||
$note->media()->attach($media->id, [
|
||||
'alt_text' => 'Test alt text',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$note->refresh();
|
||||
|
||||
$this->assertCount(1, $note->media);
|
||||
$this->assertEquals('Test alt text', $note->media->first()->pivot->alt_text);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue