diff --git a/.env.example b/.env.example index c0660ea..87928d4 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,11 @@ BROADCAST_CONNECTION=log FILESYSTEM_DISK=local QUEUE_CONNECTION=database +TSE_ATTACHMENT_DISK=public +TSE_ATTACHMENT_ROOT=attachments +TSE_ATTACHMENT_MAX_KB=10240 +TSE_ATTACHMENT_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/zip + CACHE_STORE=database # CACHE_PREFIX= diff --git a/CHANGELOG.md b/CHANGELOG.md index a2fd6b9..09f831f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - Documented `pdo_sqlite` and `sqlite3` as required PHP extensions for SQLite-backed local tests and first-pass deployments. - Documented the preferred local-to-GitHub-to-remote-update workflow and the ChristIT conversion from rsync test deploy to Git checkout. - Added Phase 2 directory quality improvements with advanced provider/buyer filters, sort controls, profile badges, rating-aware cards, result counts, and empty states. +- Added Phase 3 asset and storage management with configurable attachment disk/root/size/MIME policy, image previews, file metadata display, attachment deletion, update-script storage linking, and upload policy tests. - Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts. - Added provider and buyer profile management with service areas, profile details, and external profile snapshot records. - Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance. diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php index 8fb14f5..50af7ad 100644 --- a/app/Http/Controllers/AttachmentController.php +++ b/app/Http/Controllers/AttachmentController.php @@ -8,32 +8,46 @@ use App\Models\JobPost; use App\Models\ProviderProfile; use App\Models\SocialPost; use App\Models\WorkOrder; +use App\Models\Attachment; +use App\Models\BuyerProfile; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; class AttachmentController extends Controller { public function store(Request $request): RedirectResponse { + $maxKb = (int) config('provider-exchange.attachments.max_kb'); + $mimeTypes = (array) config('provider-exchange.attachments.allowed_mime_types'); + $data = $request->validate([ 'attachable_type' => ['required', 'string', 'in:provider_profile,buyer_profile,social_post,job_post,work_order,dispute,external_profile_import'], 'attachable_id' => ['required', 'integer'], 'kind' => ['nullable', 'string', 'max:255'], 'caption' => ['nullable', 'string', 'max:1000'], - 'file' => ['required', 'file', 'max:10240'], + 'file' => ['required', 'file', 'max:'.$maxKb, 'mimetypes:'.implode(',', $mimeTypes)], ]); $attachable = $this->resolveAttachable($data['attachable_type'], (int) $data['attachable_id']); $this->authorizeAttachment($request, $attachable); $file = $request->file('file'); - $path = $file->store('attachments/'.Str::slug($data['attachable_type']), 'public'); + $disk = (string) config('provider-exchange.attachments.disk'); + $root = trim((string) config('provider-exchange.attachments.root'), '/'); + $directory = implode('/', array_filter([ + $root ?: 'attachments', + Str::slug($data['attachable_type']), + now()->format('Y'), + now()->format('m'), + ])); + $path = $file->store($directory, $disk); $attachable->attachments()->create([ 'user_id' => $request->user()->id, 'kind' => $data['kind'] ?? 'general', - 'disk' => 'public', + 'disk' => $disk, 'path' => $path, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getClientMimeType(), @@ -44,11 +58,35 @@ class AttachmentController extends Controller return back()->with('status', 'File uploaded.'); } + public function destroy(Request $request, Attachment $attachment): RedirectResponse + { + $this->authorizeAttachment($request, $attachment->attachable); + + if ( + $attachment->user_id === $request->user()->id + || $request->user()->hasRole('admin') + || $this->ownsAttachable($request, $attachment->attachable) + ) { + $disk = $attachment->disk; + $path = $attachment->path; + $attachment->delete(); + + $stillReferenced = Attachment::where('disk', $disk)->where('path', $path)->exists(); + if (! $stillReferenced) { + Storage::disk($disk)->delete($path); + } + + return back()->with('status', 'File deleted.'); + } + + abort(403); + } + private function resolveAttachable(string $type, int $id): mixed { return match ($type) { 'provider_profile' => ProviderProfile::findOrFail($id), - 'buyer_profile' => \App\Models\BuyerProfile::findOrFail($id), + 'buyer_profile' => BuyerProfile::findOrFail($id), 'social_post' => SocialPost::findOrFail($id), 'job_post' => JobPost::findOrFail($id), 'work_order' => WorkOrder::findOrFail($id), @@ -63,7 +101,7 @@ class AttachmentController extends Controller if ($attachable instanceof ProviderProfile) { abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403); - } elseif ($attachable instanceof \App\Models\BuyerProfile) { + } elseif ($attachable instanceof BuyerProfile) { abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403); } elseif ($attachable instanceof SocialPost) { abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403); @@ -77,4 +115,20 @@ class AttachmentController extends Controller abort_unless($attachable->providerProfile->user_id === $user->id || $user->hasRole('admin'), 403); } } + + private function ownsAttachable(Request $request, mixed $attachable): bool + { + $userId = $request->user()->id; + + return match (true) { + $attachable instanceof ProviderProfile => $attachable->user_id === $userId, + $attachable instanceof BuyerProfile => $attachable->user_id === $userId, + $attachable instanceof SocialPost => $attachable->user_id === $userId, + $attachable instanceof JobPost => $attachable->buyer_id === $userId, + $attachable instanceof WorkOrder => in_array($userId, [$attachable->buyer_id, $attachable->provider_id], true), + $attachable instanceof Dispute => in_array($userId, [$attachable->workOrder->buyer_id, $attachable->workOrder->provider_id], true), + $attachable instanceof ExternalProfileImport => $attachable->providerProfile->user_id === $userId, + default => false, + }; + } } diff --git a/app/Models/Attachment.php b/app/Models/Attachment.php index 993b495..adfb4c7 100644 --- a/app/Models/Attachment.php +++ b/app/Models/Attachment.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Storage; #[Fillable(['user_id', 'kind', 'disk', 'path', 'original_name', 'mime_type', 'size', 'caption'])] class Attachment extends Model @@ -19,4 +20,33 @@ class Attachment extends Model { return $this->belongsTo(User::class); } + + public function publicUrl(): string + { + if ($this->disk === 'public') { + return asset('storage/'.$this->path); + } + + return Storage::disk($this->disk)->url($this->path); + } + + public function formattedSize(): string + { + $bytes = max(0, (int) $this->size); + + if ($bytes >= 1048576) { + return round($bytes / 1048576, 1).' MB'; + } + + if ($bytes >= 1024) { + return round($bytes / 1024, 1).' KB'; + } + + return $bytes.' B'; + } + + public function isImage(): bool + { + return str_starts_with((string) $this->mime_type, 'image/'); + } } diff --git a/config/provider-exchange.php b/config/provider-exchange.php new file mode 100644 index 0000000..4584bed --- /dev/null +++ b/config/provider-exchange.php @@ -0,0 +1,13 @@ + [ + 'disk' => env('TSE_ATTACHMENT_DISK', 'public'), + 'root' => trim(env('TSE_ATTACHMENT_ROOT', 'attachments'), '/'), + 'max_kb' => (int) env('TSE_ATTACHMENT_MAX_KB', 10240), + 'allowed_mime_types' => array_values(array_filter(array_map( + 'trim', + explode(',', env('TSE_ATTACHMENT_MIME_TYPES', 'image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/zip')) + ))), + ], +]; diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index eb24acb..a5f1c81 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -27,6 +27,12 @@ This document records the explicit runtime requirements and server changes for P - `storage/` - `bootstrap/cache/` - the SQLite database file for SQLite test deployments. +- Public attachment URLs require `php artisan storage:link`; the update script now runs it idempotently. +- Attachment policy environment variables: + - `TSE_ATTACHMENT_DISK`, default `public`. + - `TSE_ATTACHMENT_ROOT`, default `attachments`. + - `TSE_ATTACHMENT_MAX_KB`, default `10240`. + - `TSE_ATTACHMENT_MIME_TYPES`, comma-separated allowlist. ## Local Test Requirements @@ -71,6 +77,9 @@ Initial test data/runtime: - `CACHE_STORE=database`. - `QUEUE_CONNECTION=database`. - `MAIL_MAILER=log`. +- `TSE_ATTACHMENT_DISK=public`. +- `TSE_ATTACHMENT_ROOT=attachments`. +- `TSE_ATTACHMENT_MAX_KB=10240`. ## ChristIT Deployment Log diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 2f835da..25762f4 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -39,6 +39,8 @@ The preferred working loop for this project is: - The installer is a first-pass Ubuntu/Nginx helper, not a universal hosting panel installer. - Local and CI test runners need PHP SQLite support: `pdo_sqlite` and `sqlite3`. +- Public attachment serving needs `php artisan storage:link`; the checked-in update script runs this automatically. +- Tune upload policy with `TSE_ATTACHMENT_DISK`, `TSE_ATTACHMENT_ROOT`, `TSE_ATTACHMENT_MAX_KB`, and `TSE_ATTACHMENT_MIME_TYPES`. - Review `.env` before production use. - Configure real mail before inviting users. - Move sessions, cache, and queues to Redis before serious traffic. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0a6a91b..95ec5dc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,6 +13,7 @@ Provider Exchange is a Laravel application for a provider-centered field-service - Quote revisions, quote decline flow, and quote acceptance. - Work-order creation from accepted quotes. - Work-order status transitions, messages, attachments, deliverables, and completion notes. +- Configurable attachment storage policy with upload root, disk, size cap, MIME allowlist, image previews, file metadata, and deletion. - Mutual reviews with category metrics. - Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns. - Universal community ratings for buyers, providers, job posts, work orders, and disputes. @@ -35,15 +36,17 @@ Provider Exchange is a Laravel application for a provider-centered field-service 1. Profile depth: richer galleries, equipment lists, certifications, insurance fields, service coverage maps, and imported review summaries. 2. Directory quality: saved searches, better filtering, profile badges, availability signals, and stronger buyer/provider discovery pages. -3. Work-order depth: checklist templates, required evidence rules, appointment windows, onsite timestamps, change-request records, and print/PDF export for work-order packets. -4. Reputation refinement: rating category definitions, anti-abuse controls, edit windows, report flows, and moderation views. -5. Dispute workflow: better evidence timelines, reason-coded votes, quorum/visibility rules, and admin/community moderation tools. -6. Notifications: channel preferences, email templates, and event-specific subscription controls. -7. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, and mobile-safe work-order actions. -8. Import tooling: guided manual imports for Field Nation, WorkMarket, and similar profile/review history. -9. Admin operations: audit logs, queue health, system status, content moderation, and backup/export workflows. -10. Deployment hardening: production environment docs, queue worker setup, scheduler setup, storage policy, and server provisioning notes. -11. License and runtime audit: generate and review PHP and JavaScript dependency license reports before adding external theme kits, map any risky package to a replacement, and keep required local/runtime extensions such as `pdo_sqlite` documented for testability. +3. Asset and storage management: configurable upload roots, file policy, attachment previews, deletion, and update-path storage link handling. +4. Work-order depth: checklist templates, required evidence rules, appointment windows, onsite timestamps, change-request records, and print/PDF export for work-order packets. +5. Reputation refinement: rating category definitions, anti-abuse controls, edit windows, report flows, and moderation views. +6. Dispute workflow: better evidence timelines, reason-coded votes, quorum/visibility rules, and admin/community moderation tools. +7. Notifications: channel preferences, email templates, and event-specific subscription controls. +8. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, and mobile-safe work-order actions. +9. Import tooling: guided manual imports for Field Nation, WorkMarket, and similar profile/review history. +10. Competency tags and levels: provider/buyer tag taxonomies, smart-hands entry lane, specialty tags such as network, POTS, POS, AV, cabling, installer, and troubleshooter, plus earned level badges based on completed work and reputation signals. +11. Admin operations: audit logs, queue health, system status, content moderation, and backup/export workflows. +12. Deployment hardening: production environment docs, queue worker setup, scheduler setup, storage policy, and server provisioning notes. +13. License and runtime audit: generate and review PHP and JavaScript dependency license reports before adding external theme kits, map any risky package to a replacement, and keep required local/runtime extensions such as `pdo_sqlite` documented for testability. ## Scaling Risks To Track @@ -52,6 +55,7 @@ Provider Exchange is a Laravel application for a provider-centered field-service - Notifications should move from the database queue to Redis-backed queues before high-volume alerting or email delivery. - Sessions, cache, and queues should move to Redis before running more than one web node. - Uploaded evidence, profile images, and deliverable files should move to shared or object storage before multi-server deployment. +- Attachment records already track disk/path metadata, but production scaling still needs object-storage lifecycle rules, upload malware scanning, quota policy, and thumbnail generation. - Work-order timelines, dispute activity, messages, and notification tables need careful indexes before large production use. - Realtime chat, websocket presence, mobile push, and geolocation check-ins should be added as deliberate service layers instead of bolted onto normal page requests. - Background imports, image processing, moderation summaries, and reputation recalculation should run through queue workers rather than web requests. @@ -65,3 +69,4 @@ Provider Exchange is a Laravel application for a provider-centered field-service - Real-time chat/websocket presence. - Marketplace-wide dispute binding beyond voluntary community reputation. - Automated scraping of third-party platforms where authentication, terms, or privacy boundaries are unclear. +- Automated competency promotion rules beyond transparent, reviewable tag and level signals. diff --git a/resources/views/components/attachment-form.blade.php b/resources/views/components/attachment-form.blade.php index 9040d7a..cbcfa9a 100644 --- a/resources/views/components/attachment-form.blade.php +++ b/resources/views/components/attachment-form.blade.php @@ -1,11 +1,21 @@ @props(['type', 'id', 'kind' => 'general']) -
diff --git a/resources/views/components/attachments.blade.php b/resources/views/components/attachments.blade.php index 36da22a..f8c3401 100644 --- a/resources/views/components/attachments.blade.php +++ b/resources/views/components/attachments.blade.php @@ -3,10 +3,33 @@ @if ($attachments->count())+ {{ $attachment->kind }} | {{ $attachment->mime_type ?: 'unknown type' }} | {{ $attachment->formattedSize() }} +
+ @if ($attachment->caption) +{{ $attachment->caption }}
+ @endif +