feat/docker-compose-update (#18)

Co-authored-by: jon brookes <marshyon@gmail.com>
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/18
This commit is contained in:
Jon Brookes 2026-02-08 18:04:18 +01:00
parent fd43495e2d
commit 1a22fd156d
70 changed files with 1068 additions and 745 deletions

View file

@ -1,6 +1,7 @@
# Ignore .env files
.env
.env.*
.envrc
# Ignore node_modules
node_modules/
@ -25,4 +26,7 @@ Thumbs.db
/storage/framework/cache/*
/storage/framework/sessions/*
/storage/framework/views/*
/storage/logs/*
/storage/logs/*
# Ignore database files if in development
database/database.sqlite

View file

@ -66,5 +66,7 @@ AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
MEDIA_DISK=s3
VITE_APP_NAME="${APP_NAME}"

View file

@ -499,3 +499,12 @@ Fortify is a headless authentication backend that provides authentication routes
- `Features::updatePasswords()` to let users change their passwords.
- `Features::resetPasswords()` for password reset via email.
</laravel-boost-guidelines>
=== docker/core rules ===
## Over-Engineering & Bloat
- Do not add unnecessary boilerplate or "nice-to-have" features.
- Only implement what solves the immediate problem.
- Ask before adding optional infrastructure or configuration sections.
- If a system worked before without something, don't add it "just in case".
- Minimize configuration, complexity, and dependencies.

4
.gitignore vendored
View file

@ -9,6 +9,7 @@
.env
.env.backup
.env.production
.env.dev
.phpactor.json
.phpunit.result.cache
Homestead.json
@ -28,3 +29,6 @@ log*.txt
.envrc
database/backups
*backup.tar.gz
public/css
public/js
notes

View file

@ -1,6 +1,6 @@
when:
- event: push
branch: feat/docker-compose-update
branch: dev
steps:
build-local:
image: docker:24-dind
@ -14,10 +14,10 @@ steps:
- docker pull quay.io/marshyon/share-lt:latest || true
- echo "Building image for testing (amd64 only for CI compatibility)..."
- docker build --platform linux/amd64 --cache-from=quay.io/marshyon/share-lt:latest -t share-lt:test .
- echo "Tagging test image as quay.io/marshyon/share-lt:v0.0.2..."
- docker tag share-lt:test quay.io/marshyon/share-lt:v0.0.2
- echo "Tagging test image as quay.io/marshyon/share-lt:v0.0.5..."
- docker tag share-lt:test quay.io/marshyon/share-lt:v0.0.5
- echo "Generating SBOM..."
- docker run --rm -v /var/run/docker.sock:/var/run/docker.sock anchore/syft:latest scan quay.io/marshyon/share-lt:v0.0.2 -o cyclonedx-json > sbom.json
- docker run --rm -v /var/run/docker.sock:/var/run/docker.sock anchore/syft:latest scan quay.io/marshyon/share-lt:v0.0.5 -o cyclonedx-json > sbom.json
scan-vulnerabilities:
image: aquasec/trivy:0.67.2
volumes:
@ -41,7 +41,7 @@ steps:
repo: quay.io/marshyon/share-lt
platforms: linux/amd64
tags:
- v0.0.2
- v0.0.5
- latest
username:
from_secret: QUAY_USERNAME
@ -57,5 +57,6 @@ steps:
COSIGN_REGISTRY_PASSWORD:
from_secret: QUAY_PASSWORD
commands:
- cosign attach sbom --sbom sbom.json quay.io/marshyon/share-lt:v0.0.2 || echo "SBOM attach failed"
- echo "Done - trivy report saved to workspace for manual review"
- cosign attach sbom --sbom sbom.json quay.io/marshyon/share-lt:v0.0.5 || echo "SBOM attach failed"
- echo "Done - trivy report saved to workspace for manual review"

View file

@ -1,12 +1,8 @@
FROM php:8.5-fpm-alpine
FROM php:8.4-fpm-alpine3.23
# Copy composer.lock and composer.json
COPY composer.lock composer.json /var/www/
# Set working directory
ENV APP_ENV=production
ENV APP_DEBUG=false
WORKDIR /var/www
# # Install dependencies
RUN apk update && apk add --no-cache \
build-base \
libpng-dev \
@ -20,70 +16,86 @@ RUN apk update && apk add --no-cache \
curl \
libzip-dev \
oniguruma-dev \
icu-dev \
sqlite \
sqlite-dev \
nodejs \
npm
npm \
icu-dev \
sqlite-dev \
sqlite-libs \
nginx \
supervisor \
su-exec \
tini \
unzip \
bash \
jq \
&& rm -rf /var/cache/apk/*
RUN curl -sSL https://github.com/nats-io/natscli/releases/download/v0.3.1/nats-0.3.1-linux-amd64.zip -o /tmp/nats.zip \
&& unzip /tmp/nats.zip -d /tmp/nats \
&& mv /tmp/nats/nats-0.3.1-linux-amd64/nats /usr/local/bin/nats \
&& chmod +x /usr/local/bin/nats \
&& rm -rf /tmp/nats /tmp/nats.zip
# # Clear cache
RUN rm -rf /var/cache/apk/*
# # Install extensions
RUN docker-php-ext-install mbstring zip exif pcntl intl pdo_sqlite
RUN docker-php-ext-install gd
# # Install composer
RUN docker-php-ext-install mbstring zip exif pcntl intl gd pdo pdo_sqlite bcmath
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Add user for laravel application
RUN addgroup -g 1000 www
RUN adduser -u 1000 -G www -s /bin/sh -D www
# Copy entrypoint script
COPY cmd/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# # Copy existing application directory contents
COPY . /var/www
# Copy supervisord configuration
COPY ./docker/supervisord.conf /etc/supervisord.conf
RUN mkdir -p /var/log/supervisor \
&& mkdir -p /run/nginx /var/cache/nginx /var/lib/nginx /var/tmp/nginx \
&& chown -R root:root /run/nginx /var/cache/nginx /var/lib/nginx /var/tmp/nginx
# # Copy existing application directory permissions
COPY --chown=www-data:www-data . /var/www
# Create www user and add to www-data group
RUN adduser -u 1000 -G www-data -s /bin/sh -D www
# # Install PHP dependencies
RUN composer install --no-dev --optimize-autoloader
# Configure PHP-FPM to run as www user
RUN sed -i 's/user = www-data/user = www/g' /usr/local/etc/php-fpm.d/www.conf
# Install Node.js dependencies
RUN npm install
# Remove the semicolon to uncomment the listen directive
RUN sed -i 's/;listen = 127.0.0.1:9000/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
# Build assets
RUN npm run build
# Ensure the worker running the code is correct (usually www-data or nginx)
RUN sed -i 's/;listen.owner = www-data/listen.owner = www/' /usr/local/etc/php-fpm.d/www.conf
RUN sed -i 's/;listen.group = www-data/listen.group = www-data/' /usr/local/etc/php-fpm.d/www.conf
# Change ownership of /var/www to www-data
RUN chmod -R u+rw /var/www && chown -R www:www /var/www
# Update nginx.conf to use 'www' user instead of 'nginx'
RUN sed -i 's/user nginx;/user www;/' /etc/nginx/nginx.conf
# # RUN php artisan optimize
# # removed as it calls all 4 commands below and fails du to
# # multi-site configuration
# Remove user and group directives from nginx and php-fpm configs to avoid conflicts
RUN sed -i '/^user /d' /etc/nginx/nginx.conf
RUN sed -i '/^user = /d' /usr/local/etc/php-fpm.d/www.conf
RUN sed -i '/^group = /d' /usr/local/etc/php-fpm.d/www.conf
# # Clear any cached configurations
RUN php artisan config:clear
# RUN php artisan cache:clear
RUN php artisan route:clear
RUN php artisan view:clear
# Set permissions for nginx directories
RUN mkdir -p /var/lib/nginx/tmp/client_body /var/log/nginx \
&& chown -R www:www-data /var/lib/nginx /var/log/nginx \
&& chmod -R 755 /var/lib/nginx /var/log/nginx \
&& touch /run/nginx/nginx.pid \
&& chown www:www-data /run/nginx/nginx.pid
# # Build optimizations
RUN php artisan config:cache
RUN php artisan event:cache
RUN php artisan view:cache
# Copy application code (includes database/migrations/) and excluding
# files in .dockerignore
COPY --chown=www:www-data . /var/www
RUN chown -R www:www-data /var/www
RUN chown -R www:www-data /var/log/supervisor
# # RUN php artisan route:cache
# # Run Laravel artisan command
RUN php artisan storage:link
# # RUN composer install --optimize-autoloader --no-dev
# # Change current user to www
# Switch to www user
USER www
# # Expose port 9000 and start php-fpm server
# EXPOSE 9000
# Install app dependencies
RUN composer install --optimize-autoloader --no-dev
RUN npm ci
RUN npm run build
# run laravel cache optimization
RUN php artisan optimize
EXPOSE 8889
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]

View file

@ -1,82 +0,0 @@
FROM php:8.4-fpm-alpine3.23
ENV APP_ENV=production
ENV APP_DEBUG=false
WORKDIR /var/www
RUN apk update && apk add --no-cache \
build-base \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
zip \
jpegoptim optipng pngquant gifsicle \
vim \
unzip \
git \
curl \
libzip-dev \
oniguruma-dev \
nodejs \
npm \
icu-dev \
sqlite-dev \
sqlite-libs \
nginx \
supervisor \
su-exec \
tini
RUN rm -rf /var/cache/apk/*
RUN docker-php-ext-install mbstring zip exif pcntl intl gd pdo pdo_sqlite bcmath
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# RUN addgroup -g 1000 www
# RUN adduser -u 1000 -G www -s /bin/sh -D www
# # Configure PHP-FPM to run as www user
# RUN sed -i 's/user = www-data/user = www/g' /usr/local/etc/php-fpm.d/www.conf && \
# sed -i 's/group = www-data/group = www/g' /usr/local/etc/php-fpm.d/www.conf
# Copy application code (includes database/migrations/)
COPY . /var/www
# DEBUG - SHOW ME WHAT WAS COPIED
# RUN echo "===== CONTENTS OF /var/www/database =====" && ls -la /var/www/database/
# RUN echo "===== CONTENTS OF /var/www/database/migrations =====" && ls -la /var/www/database/migrations/
# Install dependencies
RUN composer install --optimize-autoloader --no-dev
RUN npm install
RUN npm run build
# Copy entrypoint script
COPY cmd/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
COPY ./docker/supervisord.conf /etc/supervisord.conf
RUN mkdir -p /var/log/supervisor \
&& mkdir -p /run/nginx /var/cache/nginx /var/lib/nginx /var/tmp/nginx \
&& chown -R root:root /run/nginx /var/cache/nginx /var/lib/nginx /var/tmp/nginx
# Test nginx config at build time
RUN nginx -t
# keep running as root so supervisord starts nginx/php-fpm as root (nginx needs root for master process)
# we will use su-exec in entrypoint to run maintenance steps as www, preserving previous behaviour
EXPOSE 8889
# Keep entrypoint script as before; entrypoint runs startup tasks then supervisord becomes PID 1
# ENTRYPOINT ["docker-entrypoint.sh"]
# CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]

View file

@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Assets;
use App\Filament\Resources\Assets\Pages\CreateAsset;
use App\Filament\Resources\Assets\Pages\EditAsset;
use App\Filament\Resources\Assets\Pages\ListAssets;
use App\Filament\Resources\Assets\Schemas\AssetForm;
use App\Filament\Resources\Assets\Tables\AssetsTable;
use App\Models\Asset;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class AssetResource extends Resource
{
protected static bool $shouldRegisterNavigation = false;
protected static ?string $model = Asset::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::ArrowUpTray;
public static function form(Schema $schema): Schema
{
return AssetForm::configure($schema);
}
public static function table(Table $table): Table
{
return AssetsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListAssets::route('/'),
'create' => CreateAsset::route('/create'),
'edit' => EditAsset::route('/{record}/edit'),
];
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Assets\Pages;
use App\Filament\Resources\Assets\AssetResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAsset extends CreateRecord
{
protected static string $resource = AssetResource::class;
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Assets\Pages;
use App\Filament\Resources\Assets\AssetResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditAsset extends EditRecord
{
protected static string $resource = AssetResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Assets\Pages;
use App\Filament\Resources\Assets\AssetResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListAssets extends ListRecords
{
protected static string $resource = AssetResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Filament\Resources\Assets\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Schemas\Schema;
class AssetForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('alt_text'),
SpatieMediaLibraryFileUpload::make('image')
->collection('default')
->image()
->disk('s3')
->visibility('public')
->label('Upload Image'),
]);
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Filament\Resources\Assets\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class AssetsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('alt_text')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View file

@ -52,6 +52,7 @@ class EntryForm
->visible(fn($get) => $get('type') === 'article')
->columnSpanFull(),
SpatieMediaLibraryFileUpload::make('featured_image')
->multiple() // <- force array handling for Filament v4 bug
->visible(
fn($get) =>
$get('type') === 'article' || $get('type') === 'image'
@ -64,9 +65,63 @@ class EntryForm
->columnSpanFull()
->dehydrated(false)
->saveUploadedFileUsing(function ($file, $record) {
$diskName = config('media-library.disk_name', 'public');
if (is_array($file)) {
$file = reset($file);
}
if (config('app.env') === 'local') {
// Validate upload object early
if (
! is_object($file) ||
! (method_exists($file, 'getRealPath') || method_exists($file, 'getPathname') || method_exists($file, 'getStream') || method_exists($file, 'store'))
) {
Log::error('Invalid upload object', ['type' => gettype($file)]);
throw new \Exception('Invalid upload object provided to saveUploadedFileUsing');
}
// Use safe variables for further calls
$realPath = method_exists($file, 'getRealPath') ? $file->getRealPath() : null;
$exists = $realPath ? file_exists($realPath) : false;
$name = method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : null;
Log::info('TemporaryUploadedFile Debug', [
'path' => $file->getRealPath(),
'exists' => file_exists($file->getRealPath()),
'name' => $file->getClientOriginalName(),
'temp_dir' => sys_get_temp_dir(),
'disk_root' => config('filesystems.disks.local.root'),
'is_readable' => is_readable($file->getRealPath()),
'is_writable' => is_writable($file->getRealPath()),
]);
// Additional debug: Check if the file is being moved to livewire-tmp
$livewireTmpPath = storage_path('framework/livewire-tmp');
Log::info('Livewire Temp Directory Debug', [
'livewire_tmp_path' => $livewireTmpPath,
'exists' => file_exists($livewireTmpPath),
'is_writable' => is_writable($livewireTmpPath),
]);
// Check if the file is being moved
$tempFilePath = $file->getRealPath();
$newFilePath = $livewireTmpPath . '/' . $file->getClientOriginalName();
if (file_exists($tempFilePath)) {
Log::info('File exists in temp directory', ['temp_file_path' => $tempFilePath]);
} else {
Log::error('File does not exist in temp directory', ['temp_file_path' => $tempFilePath]);
}
// $diskName = config('media-library.disk_name', 'public');
$diskName = config('media-library.disk_name');
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Featured Image Upload Debug', [
'disk' => $diskName,
'file_name' => $file->getClientOriginalName(),
@ -97,7 +152,7 @@ class EntryForm
$disk->put($testFile, 'test content');
$disk->delete($testFile);
if (config('app.env') === 'local') {
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('S3 connectivity test passed');
}
}
@ -112,12 +167,114 @@ class EntryForm
$encodedName = base64_encode($originalName);
$secureFileName = Str::random(32) . '-meta' . $encodedName . '-.' . $extension;
$media = $record->addMedia($file->getRealPath())
->usingName($baseName) // Keep original name for display/search
->usingFileName($secureFileName) // Use secure filename for storage
// Resolve possibly-relative Livewire temp path + safe fallbacks
$realPath = $realPath ?: (method_exists($file, 'getRealPath') ? $file->getRealPath() : null);
$candidates = [];
if ($realPath && str_starts_with($realPath, '/')) {
$candidates[] = $realPath;
} else {
$candidates[] = sys_get_temp_dir() . '/' . ltrim((string)$realPath, '/');
$candidates[] = storage_path('framework/' . ltrim((string)$realPath, '/'));
$candidates[] = storage_path(ltrim((string)$realPath, '/'));
$candidates[] = base_path(ltrim((string)$realPath, '/'));
}
if ($realPath) {
$candidates[] = storage_path('framework/livewire-tmp/' . basename($realPath));
$candidates[] = sys_get_temp_dir() . '/' . basename($realPath);
}
// 1) Try storing to local disk (creates an absolute path we control)
$stored = null;
if (method_exists($file, 'store')) {
try {
$stored = $file->store('livewire-temp', 'local'); // storage/app/livewire-temp/...
if ($stored && \Storage::disk('local')->exists($stored)) {
$resolved = \Storage::disk('local')->path($stored);
}
} catch (\Throwable $e) {
Log::debug('store() fallback failed', ['err' => $e->getMessage()]);
}
}
// 2) If not resolved, check candidates
if (! isset($resolved)) {
foreach ($candidates as $p) {
if ($p && file_exists($p)) {
$resolved = $p;
break;
}
}
}
// 3) If still not resolved, try stream -> temp file copy
$is_tmp_copy = false;
if (! isset($resolved)) {
try {
$stream = null;
if (method_exists($file, 'getStream')) {
$stream = $file->getStream();
} elseif (method_exists($file, 'getRealPath') && is_readable($file->getRealPath())) {
$stream = fopen($file->getRealPath(), 'r');
}
if ($stream) {
$tmpPath = tempnam(sys_get_temp_dir(), 'filament-upload-');
$out = fopen($tmpPath, 'w');
stream_copy_to_stream($stream, $out);
fclose($out);
if (is_resource($stream)) {
@fclose($stream);
}
$resolved = $tmpPath;
$is_tmp_copy = true;
}
} catch (\Throwable $e) {
Log::debug('stream fallback failed', ['err' => $e->getMessage()]);
}
}
// 4) Still nothing -> error
if (empty($resolved)) {
Log::error('Featured Image Upload: could not resolve temp path', [
'original' => $realPath,
'checked_candidates' => $candidates,
'stored' => $stored,
]);
throw new \Exception("File `{$realPath}` does not exist");
}
// 5) Use resolved absolute path
$media = $record->addMedia($resolved)
->usingName($baseName)
->usingFileName($secureFileName)
->toMediaCollection('featured-image', $diskName);
if (config('app.env') === 'local') {
// 6) Cleanup short-lived artifacts
if (! empty($is_tmp_copy) && file_exists($resolved)) {
@unlink($resolved);
}
if (! empty($stored) && \Storage::disk('local')->exists($stored)) {
\Storage::disk('local')->delete($stored);
}
Log::info('Featured image resolved', ['resolved' => $resolved, 'media_id' => $media->id ?? null]);
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Featured Image Upload Success', [
'media_id' => $media->id,
'media_url' => $media->getUrl(),
@ -189,7 +346,7 @@ class EntryForm
$record = $component->getRecord();
$diskName = config('media-library.disk_name', 'public');
if (config('app.env') === 'local') {
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Featured Image Picker Action Debug', [
'disk' => $diskName,
'record_id' => $record?->id,
@ -223,7 +380,7 @@ class EntryForm
try {
// For S3, we need to handle file copying differently
if ($sourceMedia->disk === 's3') {
if (config('app.env') === 'local') {
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Copying S3 media to new collection', [
'source_disk' => $sourceMedia->disk,
'source_path' => $sourceMedia->getPathRelativeToRoot(),
@ -267,7 +424,7 @@ class EntryForm
->usingFileName($sourceMedia->file_name)
->toMediaCollection('featured-image', $diskName);
if (config('app.env') === 'local') {
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Featured Image Picker Success', [
'new_media_id' => $newMedia->id,
'new_media_disk' => $newMedia->disk,

View file

@ -13,7 +13,7 @@ class ListMedia extends ListRecords
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
// CreateAction::make(),
];
}
}

View file

@ -7,6 +7,8 @@ use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
use Spatie\MediaLibrary\MediaCollections\Models\Media as SpatieMedia;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class MediaForm
{
@ -24,6 +26,7 @@ class MediaForm
Hidden::make('disk')
->default('public'),
FileUpload::make('file')
->multiple() // workaround for Filament v4 single-file bug
->label('File')
->imageEditor()
->imageEditorAspectRatios([
@ -38,22 +41,50 @@ class MediaForm
->acceptedFileTypes(['image/*', 'application/pdf'])
->maxSize(10240)
->required(fn($context) => $context === 'create')
->afterStateHydrated(function (FileUpload $component, $state, $record): void {
if (! $record) {
return;
Log::info('MediaForm afterStateHydrated invoked', ['record_id' => $record?->id, 'state' => $state]);
try {
if (! $record) {
return;
}
$media = $record;
if (! $media instanceof SpatieMedia) {
return;
}
// Construct the correct path: {media_id}/{filename}
$path = $media->id . '/' . $media->file_name;
try {
$disk = $media->disk ?? 'public';
if (Storage::disk($disk)->exists($path)) {
$component->state($path);
}
} catch (\Throwable $e) {
Log::error('MediaForm afterStateHydrated storage check failed', [
'err' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'disk' => $media->disk ?? null,
'path' => $path,
]);
}
} catch (\Throwable $e) {
Log::error('MediaForm afterStateHydrated unhandled', [
'err' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'record' => $record?->id,
'state' => $state,
]);
throw $e;
}
$media = $record;
if (! $media instanceof SpatieMedia) {
return;
}
// Construct the correct path: {media_id}/{filename}
$path = $media->id . '/' . $media->file_name;
$component->state($path);
}),
]);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Filament\Resources\Media\Tables;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
@ -19,11 +20,12 @@ class MediaTable
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn ($query) => $query->where('collection_name', '!=', 'avatars'))
->modifyQueryUsing(fn($query) => $query->where('collection_name', '!=', 'avatars'))
->columns([
ImageColumn::make('url')
->label('Preview')
->getStateUsing(fn ($record) =>
->getStateUsing(
fn($record) =>
// Prefer the stored path produced by Filament's FileUpload (saved in custom_properties),
// fall back to Spatie's getUrl() when no stored_path exists.
($record->getCustomProperty('stored_path'))
@ -40,7 +42,7 @@ class MediaTable
->badge(),
TextColumn::make('mime_type'),
TextColumn::make('size')
->formatStateUsing(fn ($state) => number_format($state / 1024, 2).' KB'),
->formatStateUsing(fn($state) => number_format($state / 1024, 2) . ' KB'),
TextColumn::make('created_at')
->dateTime(),
])
@ -52,7 +54,7 @@ class MediaTable
]),
])
->recordActions([
EditAction::make(),
// EditAction::make(),
DeleteAction::make()
->action(function (Media $record) {
// Delete the actual stored file path if we saved one, otherwise fall back to the Spatie path.
@ -67,6 +69,13 @@ class MediaTable
}),
])
->toolbarActions([
Action::make('add')
->label('Add')
->icon('heroicon-o-plus')
->url(fn() => url('admin/assets/create'))
->color('primary'),
BulkActionGroup::make([
DeleteBulkAction::make()
->action(function (Collection $records) {

View file

@ -16,6 +16,9 @@ use Filament\Tables\Table;
class TextWidgetResource extends Resource
{
protected static bool $shouldRegisterNavigation = false;
protected static ?string $model = TextWidget::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::DocumentText;

View file

@ -0,0 +1,98 @@
<?php
namespace App\Jobs;
use App\Models\Entry;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Console\Exception\RuntimeException;
class ProcessEntryUpdate implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
public int $entryId,
public string $action
) {
//
}
/**
* Execute the job.
*/
public function handle(): void
{
$incoming = [
'id' => $this->entryId,
'action' => $this->action,
];
$jsonData = json_encode($incoming, JSON_THROW_ON_ERROR);
$tempFile = tempnam(sys_get_temp_dir(), 'entry_update_');
file_put_contents($tempFile, $jsonData);
$scriptPath = env('HANDLE_ENTRY_UPDATES_SCRIPT', base_path('cmd/handle_cms_updates.sh'));
try {
// Log what we're about to execute
Log::info("Executing script: {$scriptPath} with action: {$this->action}", [
'entry_id' => $this->entryId,
'temp_file' => $tempFile,
'script_exists' => file_exists($scriptPath),
'script_executable' => is_executable($scriptPath),
]);
$result = Process::run([
'bash',
$scriptPath,
$this->action,
$tempFile
]);
if ($result->failed()) {
$errorDetails = [
'exit_code' => $result->exitCode(),
'stdout' => $result->output(),
'stderr' => $result->errorOutput(),
'command' => ['bash', $scriptPath, $this->action, $tempFile],
'script_path' => $scriptPath,
'script_exists' => file_exists($scriptPath),
'temp_file_exists' => file_exists($tempFile),
'temp_file_contents' => file_exists($tempFile) ? file_get_contents($tempFile) : 'N/A',
];
Log::error("Script execution failed", $errorDetails);
throw new RuntimeException(
"Script execution failed with exit code {$result->exitCode()}. " .
"STDOUT: " . ($result->output() ?: 'empty') . " " .
"STDERR: " . ($result->errorOutput() ?: 'empty')
);
}
Log::info("Script executed successfully", [
'stdout' => $result->output(),
'entry_id' => $this->entryId,
'action' => $this->action,
]);
} finally {
// Clean up temp file
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
}
}

16
app/Models/Asset.php Normal file
View file

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Asset extends Model implements HasMedia
{
use InteractsWithMedia;
protected $fillable = [
'alt_text',
];
}

View file

@ -64,7 +64,7 @@ class User extends Authenticatable implements FilamentUser
return Str::of($this->name)
->explode(' ')
->take(2)
->map(fn ($word) => Str::substr($word, 0, 1))
->map(fn($word) => Str::substr($word, 0, 1))
->implode('');
}
@ -73,6 +73,6 @@ class User extends Authenticatable implements FilamentUser
*/
public function canAccessPanel(Panel $panel): bool
{
return $this->email === config('app.admin_email');
return $this->email === config('app.admin_email') || $this->role === 'admin';
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Observers;
use App\Jobs\ProcessEntryUpdate;
use App\Models\Entry;
use Illuminate\Support\Facades\Log;
class EntryObserver
{
/**
* Handle the Entry "created" event.
*/
public function created(Entry $entry): void
{
ProcessEntryUpdate::dispatch($entry->id, 'created');
}
/**
* Handle the Entry "updated" event.
*/
public function updated(Entry $entry): void
{
ProcessEntryUpdate::dispatch($entry->id, 'updated');
}
/**
* Handle the Entry "deleted" event.
*/
public function deleted(Entry $entry): void
{
ProcessEntryUpdate::dispatch($entry->id, 'deleted');
}
/**
* Handle the Entry "restored" event.
*/
public function restored(Entry $entry): void
{
//
}
/**
* Handle the Entry "force deleted" event.
*/
public function forceDeleted(Entry $entry): void
{
// ProcessEntryUpdate::dispatch($entry, 'force deleted');
}
}

View file

@ -2,7 +2,10 @@
namespace App\Providers;
use App\Models\Entry;
use App\Observers\EntryObserver;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\File;
class AppServiceProvider extends ServiceProvider
{
@ -19,6 +22,12 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
// Ensure the livewire-tmp directory exists
$livewireTmpPath = storage_path('framework/livewire-tmp');
if (!File::exists($livewireTmpPath)) {
File::makeDirectory($livewireTmpPath, 0755, true);
}
Entry::observe(EntryObserver::class);
}
}

View file

@ -36,10 +36,10 @@ class AdminPanelProvider extends PanelProvider
])
->resources([
\App\Filament\Resources\Entries\EntryResource::class,
\App\Filament\Resources\TextWidgets\TextWidgetResource::class,
\App\Filament\Resources\Media\MediaResource::class,
\App\Filament\Resources\Categroys\CategroyResource::class,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
Dashboard::class,

View file

@ -1,11 +1,10 @@
#!/usr/bin/env bash
LARAVEL_CONTAINER_NAME="quay.io/marshyon/share-lt"
CONTAINER_LABEL="0.0.3"
CONTAINER_LABEL="v0.0.5"
CACHE="--no-cache"
CACHE=""
# CACHE=""
docker build \
$CACHE \
-t ${LARAVEL_CONTAINER_NAME}:${CONTAINER_LABEL} \
-f Dockerfile.phpfpm .
-t ${LARAVEL_CONTAINER_NAME}:${CONTAINER_LABEL} .

View file

@ -6,8 +6,7 @@
# tokens need to be created with tinker or similar method
URL='http://127.0.0.1:8000/api/entries'
URL="${ADDRESS:-http://127.0.0.1:8000/api/entries}"
curl -s -X GET \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \

View file

@ -5,8 +5,7 @@
# granted access to view entries by being given
# a token
URL='http://127.0.0.1:8000/api/entries'
URL="${ADDRESS:-http://127.0.0.1:8000/api/entries}"
curl -s -X GET \
-H "Accept: application/json" \
$URL

View file

@ -1,36 +1,37 @@
#!/bin/sh
set -e
# run_as_www() {
# # prefer su-exec (alpine), fallback to runuser if available, otherwise run directly
# if command -v su-exec >/dev/null 2>&1; then
# su-exec www "$@"
# elif command -v runuser >/dev/null 2>&1; then
# runuser -u www -- "$@"
# else
# "$@"
# fi
# }
# Ensure APP_KEY is set and persisted
PERSISTED_KEY="/var/www/storage/.app_key"
# Build front-end assets if Vite manifest is missing
if [ ! -f /var/www/public/build/manifest.json ]; then
echo "Building front-end assets (vite)..."
run_as_www npm ci
run_as_www npm run build
if [ -z "$APP_KEY" ]; then
if [ -f "$PERSISTED_KEY" ]; then
echo "Using persisted APP_KEY from: $PERSISTED_KEY"
export APP_KEY=$(cat "$PERSISTED_KEY")
else
# Generate key, strip "base64:", and save
NEW_KEY=$(php artisan key:generate --show --no-interaction)
echo "Generated new APP_KEY to: $PERSISTED_KEY"
echo "$NEW_KEY" > "$PERSISTED_KEY"
export APP_KEY="$NEW_KEY"
fi
fi
# Wait for database directory to be mounted
# if [ ! -f /var/www/database/database.sqlite ]; then
# echo "Creating database..."
# # create the sqlite file as the www user so ownership matches app files
# run_as_www sh -c 'touch /var/www/database/database.sqlite'
# run_as_www php artisan migrate --force
# fi
# check to see if /var/www/database/database.sqlite exists
# if not, run migrations
if [ ! -f /var/www/database/database.sqlite ]; then
php artisan migrate --force
fi
# Fix storage permissions
# echo "Fixing storage permissions..."
# chown -R www:www /var/www/storage /var/www/bootstrap/cache
# chmod -R 775 /var/www/storage /var/www/bootstrap/cache
# check to see if /var/www/public/storage exists
# if not, run storage:link
if [ ! -d /var/www/public/storage ]; then
php artisan storage:link
fi
# Execute the main command
php artisan config:clear
# Start supervisord directly
exec "$@"

33
cmd/handle_cms_updates.sh Executable file
View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
LOG_FILE="/tmp/logfile.log"
# Redirect all output to both stdout and the log file
exec > >(tee -a "$LOG_FILE") 2>&1
# Read the first two command line arguments
ACTION=$1
FILENAME=$2
# Check if the file exists and echo its contents
if [[ -f "$FILENAME" ]]; then
echo "Contents of the file $FILENAME:"
cat "$FILENAME" | jq
else
echo "Error: File $FILENAME does not exist."
fi
echo
# Read and print command line arguments
echo "=============================="
echo "ACTION: $ACTION"
echo "FILENAME: $FILENAME"
echo "=============================="
echo
# Publish message and check return code
if nats pub $NATS_SUBJECT "$(cat "$FILENAME")" --server $NATS_URL --user $NATS_USERNAME --password $NATS_PASSWORD; then
echo "Success: Message published to NATS successfully."
else
echo "Error: Failed to publish message to NATS."
fi

View file

@ -0,0 +1,28 @@
<?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('assets', function (Blueprint $table) {
$table->id();
$table->string('alt_text')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('assets');
}
};

View file

@ -0,0 +1,28 @@
<?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::table('users', function (Blueprint $table) {
$table->string('role')->default('user')->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};

20
docker/php/php.ini Normal file
View file

@ -0,0 +1,20 @@
; PHP Configuration File
; Maximum allowed size for uploaded files.
upload_max_filesize = 10M
; Maximum size of POST data that PHP will accept.
post_max_size = 12M
; Maximum execution time of each script, in seconds.
max_execution_time = 120
; Maximum amount of memory a script may consume.
memory_limit = 256M
; Maximum number of files that can be uploaded via a single request.
max_file_uploads = 20
; File upload temporary directory (optional, defaults to system temp directory).
; upload_tmp_dir = /tmp

View file

@ -24,7 +24,7 @@ stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:queue-worker]
command=su-exec www /usr/local/bin/php /var/www/artisan queue:work --sleep=3 --tries=3 --max-time=3600
command=/usr/local/bin/php /var/www/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
startretries=3

View file

@ -0,0 +1,17 @@
The ENTRYPOINT script (/usr/local/bin/docker-entrypoint.sh) is executed first.
The CMD (/usr/bin/supervisord -n -c /etc/supervisord.conf) is passed as arguments to the ENTRYPOINT script.
The ENTRYPOINT script can choose to execute the CMD or perform other tasks before running the CMD.
for now, supervisord is used in its current form, that of a python based application
this is ok for now but other options could be considered, such as replacing it with a [go binary port of supervisord](https://github.com/ochinchina/supervisord), using [s6-overlay](https://github.com/just-containers/s6-overlay), [dumb-init](https://github.com/Yelp/dumb-init) or similar process management tools designed for containers.
In the dockerfile, a user directive is used to lower from default root to that of a user that is created - 'www' and which is, for now, given a shell by which it is possible to log into this container for debugging and maintenance.
further on, this can be removed somehow - likely a case for k8s as there are patterns for this that are perhaps not so much for docker alone.

View file

@ -0,0 +1,103 @@
services:
nats:
image: nats:2.9.19-alpine
restart: unless-stopped
#ports:
# - 4222:4222
# - 8222:8222
volumes:
- ./nats/nats-server.conf:/nats-server.conf
- nats-data:/opt/storage
command: ["-c", "/nats-server.conf"]
networks:
- app-network
- nats
# nats-cli:
# image: natsio/nats-box
# container_name: nats-cli
# depends_on:
# - nats
# command: sleep infinity # Keeps container running
# networks:
# - app-network
# - nats
app:
image: quay.io/marshyon/share-lt:v0.0.5
restart: unless-stopped
tty: false
working_dir: /var/www
networks:
- app-network
- nats
ports:
- 8889:8889
volumes:
- storage-data:/var/www/storage
- database-data:/var/www/database
- ./nginx/conf.d/app.conf:/etc/nginx/http.d/app.conf
- ./docker/php/php.ini:/usr/local/etc/php/php.ini
environment:
- "LIVEWIRE_TEMPORARY_FILE_UPLOAD_MAX_FILE_UPLOAD_TIME=5"
- "ADMIN_EMAIL=${ADMIN_EMAIL}"
- "APP_NAME=${APP_NAME}"
- "APP_ENV=production"
- "APP_KEY=${APP_KEY}"
- "APP_DEBUG=false"
- "APP_URL=${APP_URL}"
- "APP_LOCALE=en"
- "APP_FALLBACK_LOCALE=en"
- "APP_MAINTENANCE_DRIVER=file"
- "PHP_CLI_SERVER_WORKERS=4"
- "BCRYPT_ROUNDS=12"
- "LOG_CHANNEL=stack"
- "LOG_STACK=single"
- "LOG_DEPRECATIONS_CHANNEL=null"
- "LOG_LEVEL=${LOG_LEVEL}"
- "DB_CONNECTION=sqlite"
- "SESSION_DRIVER=database"
- "SESSION_LIFETIME=120"
- "SESSION_ENCRYPT=false"
- "SESSION_PATH=/"
- "SESSION_DOMAIN=null"
- "BROADCAST_CONNECTION=log"
- "FILESYSTEM_DISK=s3"
- "QUEUE_CONNECTION=database"
- "CACHE_STORE=database"
- "CACHE_PREFIX=laravel_"
- "MAIL_MAILER=smtp"
- "MAIL_SCHEME=smtp"
- "MAIL_HOST=${MAIL_HOST}"
- "MAIL_PORT=${MAIL_PORT}"
- "MAIL_USERNAME=${MAIL_USERNAME}"
- "MAIL_PASSWORD=${MAIL_PASSWORD}"
- "MAIL_FROM_ADDRESS=${MAIL_FROM_ADDRESS}"
- "MAIL_FROM_NAME=${APP_NAME}"
- "AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}"
- "AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}"
- "AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION}"
- "AWS_BUCKET=${AWS_BUCKET}"
- "AWS_USE_PATH_STYLE_ENDPOINT=${AWS_USE_PATH_STYLE_ENDPOINT}"
- "AWS_DIRECTORY=${AWS_DIRECTORY}"
- "MEDIA_DISK=${MEDIA_DISK}"
- "VITE_APP_NAME=${APP_NAME}"
- "NATS_URL=${NATS_URL}"
- "NATS_USERNAME=${NATS_USERNAME}"
- "NATS_PASSWORD=${NATS_PASSWORD}"
- "NATS_STREAM=${NATS_STREAM}"
- "NATS_SUBJECT=${NATS_SUBJECT}"
volumes:
storage-data:
database-data:
nats-data:
networks:
app-network:
nats:
external: true

View file

@ -0,0 +1,84 @@
services:
app:
image: quay.io/marshyon/share-lt:v0.0.5
restart: unless-stopped
tty: false
working_dir: /var/www
networks:
- app-network
- traefik-net9
volumes:
- storage-data:/var/www/storage
- database-data:/var/www/database
- ./nginx/conf.d/app.conf:/etc/nginx/http.d/app.conf
- ./docker/php/php.ini:/usr/local/etc/php/php.ini
environment:
- "LIVEWIRE_TEMPORARY_FILE_UPLOAD_MAX_FILE_UPLOAD_TIME=5"
- "ADMIN_EMAIL=${ADMIN_EMAIL}"
- "APP_NAME=${APP_NAME}"
- "APP_ENV=production"
- "APP_KEY=${APP_KEY}"
- "APP_DEBUG=false"
- "APP_URL=${APP_URL}"
- "APP_LOCALE=en"
- "APP_FALLBACK_LOCALE=en"
- "APP_MAINTENANCE_DRIVER=file"
- "PHP_CLI_SERVER_WORKERS=4"
- "BCRYPT_ROUNDS=12"
- "LOG_CHANNEL=stack"
- "LOG_STACK=single"
- "LOG_DEPRECATIONS_CHANNEL=null"
- "LOG_LEVEL=${LOG_LEVEL}"
- "DB_CONNECTION=sqlite"
- "SESSION_DRIVER=database"
- "SESSION_LIFETIME=120"
- "SESSION_ENCRYPT=false"
- "SESSION_PATH=/"
- "SESSION_DOMAIN=null"
- "BROADCAST_CONNECTION=log"
- "FILESYSTEM_DISK=s3"
- "QUEUE_CONNECTION=database"
- "CACHE_STORE=database"
- "CACHE_PREFIX=laravel_"
- "MAIL_MAILER=smtp"
- "MAIL_SCHEME=smtp"
- "MAIL_HOST=${MAIL_HOST}"
- "MAIL_PORT=${MAIL_PORT}"
- "MAIL_USERNAME=${MAIL_USERNAME}"
- "MAIL_PASSWORD=${MAIL_PASSWORD}"
- "MAIL_FROM_ADDRESS=${MAIL_FROM_ADDRESS}"
- "MAIL_FROM_NAME=${APP_NAME}"
- "AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}"
- "AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}"
- "AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION}"
- "AWS_BUCKET=${AWS_BUCKET}"
- "AWS_USE_PATH_STYLE_ENDPOINT=${AWS_USE_PATH_STYLE_ENDPOINT}"
- "AWS_DIRECTORY=${AWS_DIRECTORY}"
- "MEDIA_DISK=${MEDIA_DISK}"
- "VITE_APP_NAME=${APP_NAME}"
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net9"
- "traefik.http.services.${APP_SERVICE_NAME}.loadbalancer.server.port=${APP_PORT}"
- "traefik.http.services.${APP_SERVICE_NAME}.loadbalancer.server.scheme=http"
- "traefik.http.routers.${APP_SERVICE_NAME}.service=${APP_SERVICE_NAME}"
- "traefik.http.routers.${APP_SERVICE_NAME}.tls.certresolver=le"
- "traefik.http.routers.${APP_SERVICE_NAME}.rule=Host(`${APP_DOMAIN}`)"
- "traefik.http.routers.${APP_SERVICE_NAME}.entrypoints=websecure"
- "traefik.http.routers.${APP_SERVICE_NAME}.tls=true"
volumes:
storage-data:
database-data:
networks:
app-network:
traefik-net9:
external: true

13
nats/nats-server.conf Normal file
View file

@ -0,0 +1,13 @@
# Client port of 4222 on all interfaces
port: 4222
# HTTP monitoring port
monitor_port: 8222
# Enable JetStream and specify the storage directory
jetstream: {
store_dir: "/opt/storage"
}
# Set a unique server name for this instance
server_name: "nats-server-1"

31
nginx/conf.d/app.conf Normal file
View file

@ -0,0 +1,31 @@
server {
listen 8889;
# Redirect all HTTP requests to HTTPS
# return 301 https://$host$request_uri;
index index.php index.html;
# error_log /var/log/nginx/error.log;
error_log /dev/stderr debug;
# access_log /var/log/nginx/access.log;
access_log /dev/stdout;
root /var/www/public;
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTPS on;
fastcgi_param HTTP_X_FORWARDED_PROTO https;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
gzip_static on;
}
location /storage/ {
alias /var/www/storage/app/public/;
autoindex on;
}
}

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
(()=>{var n=({livewireId:e})=>({actionNestingIndex:null,init(){window.addEventListener("sync-action-modals",t=>{t.detail.id===e&&this.syncActionModals(t.detail.newActionNestingIndex)})},syncActionModals(t){if(this.actionNestingIndex===t){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}if(this.actionNestingIndex!==null&&this.closeModal(),this.actionNestingIndex=t,this.actionNestingIndex!==null){if(!this.$el.querySelector(`#${this.generateModalId(t)}`)){this.$nextTick(()=>this.openModal());return}this.openModal()}},generateModalId(t){return`fi-${e}-action-`+t},openModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:t}}))},closeModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:t}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",n)});})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))}}}export{c as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
function h({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{h as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
function s({state:n,splitKeys:a}){return{newTag:"",state:n,createTag(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag(t){this.state=this.state.filter(e=>e!==t)},reorderTags(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...a].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(a.length===0){this.createTag();return}let t=a.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{s as default};

View file

@ -1 +0,0 @@
function r({initialHeight:i,shouldAutosize:s,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),s?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=i+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let e=this.$el.style.height;this.$el.style.height="0px";let t=this.$el.scrollHeight+"px";this.$el.style.height=e,this.wrapperEl.style.height!==t&&(this.wrapperEl.style.height=t)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default};

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
var i=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let e=this.$el.parentElement;e&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(e),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let e=this.$el.parentElement;if(!e)return;let t=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=e.offsetWidth+parseInt(t.marginInlineStart,10)*-1+parseInt(t.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});export{i as default};

View file

@ -1 +0,0 @@
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),Livewire.hook("commit",({component:n,commit:d,succeed:r,fail:h,respond:u})=>{r(({snapshot:p,effect:i})=>{this.$nextTick(()=>{if(n.id!==g)return;let s=this.getTabs();s.includes(this.tab)||(this.tab=s[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,d,r,h){let u=t.map(i=>Math.ceil(i.clientWidth)),p=t.map(i=>{let s=i.querySelector(".fi-tabs-item-label"),a=i.querySelector(".fi-badge"),o=Math.ceil(s.clientWidth),l=a?Math.ceil(a.clientWidth):0;return{label:o,badge:l,total:o+(l>0?d+l:0)}});for(let i=0;i<t.length;i++){let s=u.slice(0,i+1).reduce((b,y)=>b+y,0),a=i*n,o=p.slice(i+1),l=o.length>0,W=l?Math.max(...o.map(b=>b.total)):0,D=l?r+W+d+h+n:0;if(s+a+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(c,this.tab),history.replaceState(null,document.title,t.toString())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),d=n.map(a=>a.style.display);n.forEach(a=>a.style.display=""),t.offsetHeight;let r=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),p=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),s=this.findOverflowIndex(n,r,h,p,i,u);n.forEach((a,o)=>a.style.display=d[o]),s!==-1&&(this.withinDropdownIndex=s),this.withinDropdownMounted=!0},destroy(){this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};

View file

@ -1 +0,0 @@
function o({isSkippable:s,isStepPersistedInQueryString:i,key:r,startStep:h,stepQueryStringKey:n}){return{step:null,init(){this.$watch("step",()=>this.updateQueryString()),this.step=this.getSteps().at(h-1),this.autofocusFields()},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(){this.$nextTick(()=>this.$refs[`step-${this.step}`].querySelector("[autofocus]")?.focus())},getStepIndex(t){let e=this.getSteps().findIndex(p=>p===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return s||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!i)return;let t=new URL(window.location.href);t.searchParams.set(n,this.step),history.replaceState(null,document.title,t.toString())}}}export{o as default};

View file

@ -1 +0,0 @@
(()=>{var d=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let t=this.$el.parentElement;t&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(t),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let t=this.$el.parentElement;if(!t)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=t.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var u=function(t,e,n){let i=t;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)i=i.includes(".")?i.slice(0,i.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(i)?e:["",null,void 0].includes(e)?i:`${i}.${e}`},c=t=>{let e=Alpine.findClosest(t,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:t})=>({handleFormValidationError(e){e.detail.livewireId===t&&this.$nextTick(()=>{let n=this.$el.querySelector("[data-validation-error]");if(!n)return;let i=n;for(;i;)i.dispatchEvent(new CustomEvent("expand")),i=i.parentNode;setTimeout(()=>n.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},isStateChanged(e,n){if(e===void 0)return!1;try{return JSON.stringify(e)!==JSON.stringify(n)}catch{return e!==n}}})),window.Alpine.data("filamentSchemaComponent",({path:t,containerPath:e,$wire:n})=>({$statePath:t,$get:(i,s)=>n.$get(u(e,i,s)),$set:(i,s,a,o=!1)=>n.$set(u(e,i,a),s,o),get $state(){return n.$get(t)}})),window.Alpine.data("filamentActionsSchemaComponent",d),Livewire.hook("commit",({component:t,commit:e,respond:n,succeed:i,fail:s})=>{i(({snapshot:a,effects:o})=>{o.dispatches?.forEach(r=>{if(!r.params?.awaitSchemaComponent)return;let l=Array.from(t.el.querySelectorAll(`[wire\\:partial="schema-component::${r.params.awaitSchemaComponent}"]`)).filter(h=>c(h)===t);if(l.length!==1){if(l.length>1)throw`Multiple schema components found with key [${r.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${t.id}-${r.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(r.name,{detail:r.params}))},{once:!0})}})})})});})();

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:h,respond:u})=>{n(({snapshot:f,effect:d})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{o as default};

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:d,respond:u})=>{n(({snapshot:f,effect:h})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||this.getNormalizedState()===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e}}}export{o as default};

View file

@ -1 +0,0 @@
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:h,respond:u})=>{n(({snapshot:f,effect:d})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{o as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -20,7 +20,7 @@ class UploadImageAdminTest extends DuskTestCase
$filePath = base_path('tests/Browser/fixtures/robot.webp');
$this->browse(function (Browser $browser ) use ($user, $filePath) {
$this->browse(function (Browser $browser) use ($user, $filePath) {
$this->loginUser($browser, $user);
$this->assertWithDebugPause(
$browser,
@ -29,16 +29,15 @@ class UploadImageAdminTest extends DuskTestCase
->waitForLocation('/admin/media')
->assertPathIs('/admin/media')
->assertTitleContains('Media')
->clickLink('New media')
->waitForText('Create Media')
->type('#form\\.name', 'test image')
->clickLink('Add')
->waitForText('Create Asset')
->type('#form\\.alt_text', 'test image')
->assertVisible('.filepond--drop-label')
->attach('.filepond--browser', $filePath)
->pause(7000)
->waitForText('Create')
->waitFor('#key-bindings-1:not([disabled])')
->click('#key-bindings-1')
->assertSee('Collection name'),
->click('#key-bindings-1'),
1000 // Custom pause time for this test
);
});

View file

@ -27,17 +27,16 @@ class CreateEntryAdminTest extends DuskTestCase
->waitForLocation('/admin/media')
->assertPathIs('/admin/media')
->assertTitleContains('Media')
->clickLink('New media')
->waitForText('Create Media')
->type('#form\\.name', 'test image')
->clickLink('Add')
->waitForText('Create Asset')
->type('#form\\.alt_text', 'test image')
->assertVisible('.filepond--drop-label')
->attach('.filepond--browser', $filePath)
->pause(7000)
->waitForText('Create')
->waitForText('Create Asset')
->waitFor('#key-bindings-1:not([disabled])')
->click('#key-bindings-1')
->assertSee('Collection name')
->pause(5000)
->pause(1000)
->visit('/admin/entries')
->waitForLocation('/admin/entries')
@ -54,19 +53,12 @@ class CreateEntryAdminTest extends DuskTestCase
->assertSee('Updated at')
->visit('/admin/entries/1/edit')
->waitForText('Edit TEST ENTRY')
->pause(2000)
->waitForText('Featured Image')
->click('#featured-picker-button')
->pause(5000)
->waitForText('Select an existing image')
->click('.fi-select-input-btn')
->pause(2000)
->click('li:first-child')
->waitForText('Submit')
->clickAtXPath('//button[contains(., "Submit")]')
->waitForText('Edit TEST ENTRY')
->click('#key-bindings-1'),
// ->pause(20000),
->pause(3000),
// TODO: find a way to select the image
1000 // Custom pause time for this test
);
});