diff --git a/app/Platform/Mail/Http/Admin/MailConfigurationController.php b/app/Platform/Mail/Http/Admin/MailConfigurationController.php new file mode 100755 index 00000000..c7078f2f --- /dev/null +++ b/app/Platform/Mail/Http/Admin/MailConfigurationController.php @@ -0,0 +1,99 @@ +authorize(self::ABILITY); + + $profileState = Setting::getSetting('profile_complete'); + + $this->mailConfigurationService->saveGlobalConfig( + $request->validated() + ); + + if ($profileState !== 'COMPLETED') { + Setting::setSetting('profile_complete', self::WIZARD_STEP); + } + + return response()->json(['success' => 'mail_variables_save_successfully']); + } + + /** + * Read back the stored installation-wide transport settings. + */ + public function getMailEnvironment(): JsonResponse + { + $this->authorize(self::ABILITY); + + return response()->json( + $this->mailConfigurationService->getGlobalConfig() + ); + } + + /** + * List the transports this installation is actually able to send through. + */ + public function getMailDrivers(): JsonResponse + { + $this->authorize(self::ABILITY); + + return response()->json( + $this->mailConfigurationService->getAvailableDrivers() + ); + } + + /** + * Deliver a one-off message through the active transport so an admin can + * confirm the credentials they just saved really work. + */ + public function testEmailConfig(Request $request): JsonResponse + { + $this->authorize(self::ABILITY); + + $this->validate($request, [ + 'to' => 'required|email', + 'subject' => 'required', + 'message' => 'required', + ]); + + $probe = new TestMail($request->subject, $request->message); + + Mail::to($request->to)->send($probe); + + return response()->json(['success' => true]); + } +} diff --git a/app/Platform/Mail/Http/Requests/MailEnvironmentRequest.php b/app/Platform/Mail/Http/Requests/MailEnvironmentRequest.php new file mode 100644 index 00000000..b55908c7 --- /dev/null +++ b/app/Platform/Mail/Http/Requests/MailEnvironmentRequest.php @@ -0,0 +1,32 @@ +string('mail_driver')->toString(); + + return app(MailConfigurationService::class)->validationRules($driver); + } +} diff --git a/app/Platform/Mail/Mailables/TestMail.php b/app/Platform/Mail/Mailables/TestMail.php new file mode 100644 index 00000000..9d1830d3 --- /dev/null +++ b/app/Platform/Mail/Mailables/TestMail.php @@ -0,0 +1,51 @@ +subject = $subject; + $this->message = $message; + } + + /** + * @return $this + */ + public function build() + { + return $this->subject($this->subject) + ->markdown('emails.test') + ->with(['my_message' => $this->message]); + } +} diff --git a/app/Platform/Mail/Models/EmailLog.php b/app/Platform/Mail/Models/EmailLog.php new file mode 100644 index 00000000..fe304326 --- /dev/null +++ b/app/Platform/Mail/Models/EmailLog.php @@ -0,0 +1,58 @@ +morphTo(); + } + + /** + * Decide whether the public link backed by this row still works. + * + * A link dies with its document: once the referenced record is gone the + * link is treated as expired rather than as an error. Otherwise expiry is + * opt-in per company — with `automatically_expire_public_links` switched on, + * the link lasts `link_expiry_days` days from the moment this row was + * written, compared at whole-day granularity. + */ + public function isExpired(): bool + { + $document = $this->mailable; + + if (! $document) { + return true; + } + + $lifespanInDays = (int) CompanySetting::getSetting('link_expiry_days', $document->company_id); + $expiryEnabled = CompanySetting::getSetting('automatically_expire_public_links', $document->company_id); + + $lastValidDay = $this->created_at->addDays($lifespanInDays); + + return $expiryEnabled == 'YES' + && Carbon::now()->format('Y-m-d') > $lastValidDay->format('Y-m-d'); + } +} diff --git a/app/Platform/Pdf/Concerns/GeneratesPdf.php b/app/Platform/Pdf/Concerns/GeneratesPdf.php new file mode 100644 index 00000000..d6fd88ae --- /dev/null +++ b/app/Platform/Pdf/Concerns/GeneratesPdf.php @@ -0,0 +1,254 @@ +_number" + * attribute. Everything here is written against that contract. + */ +trait GeneratesPdf +{ + /** + * Answer with the archived PDF when one is reachable, otherwise render a + * fresh copy. Either way the document is served inline, so a browser shows + * it rather than downloading it. + */ + public function getGeneratedPDFOrStream($collection_name) + { + $archived = $this->getGeneratedPDF($collection_name); + + // file_exists() only answers for real filesystem paths, so an archive + // held on a remote disk — for which getGeneratedPDF hands back a signed + // URL — never satisfies this check and is rendered again instead. + if ($archived && file_exists($archived['path'])) { + $body = file_get_contents($archived['path']); + $file_name = $archived['file_name']; + } else { + $language = CompanySetting::getSetting('language', $this->company_id); + + App::setLocale($language); + app(FontService::class)->ensureFontsForLocale($language); + + // output(), never stream(): the drivers' stream() already hands back + // a Response, and nesting one response inside another casts it to a + // string, gluing the "HTTP/1.0 200 OK" preamble in front of the + // file. Viewers only sniff the opening kilobyte for %PDF so this + // looked healthy, while anything that validates the bytes (PDF/A + // conformance, text extraction) choked on them. + $body = $this->getPDFData()->output(); + $file_name = $this->{$collection_name.'_number'}.'.pdf'; + } + + return response($body, 200) + ->header('Content-Type', 'application/pdf') + ->header('Content-Disposition', sprintf('inline; filename="%s"', $file_name)); + } + + /** + * Locate the archived PDF filed under a media collection. + * + * A local disk yields a filesystem path; every other driver yields a URL + * that stays valid for five minutes. Anything that goes wrong on the way — + * a media row whose disk record has since been deleted included — is + * reported as "nothing archived" rather than raised. + * + * @return Collection|false + */ + public function getGeneratedPDF($collection_name) + { + try { + $archive = $this->getMedia($collection_name)->first(); + + if (! $archive) { + return false; + } + + $file_disk = FileDisk::find($archive->custom_properties['file_disk_id']); + + if (! $file_disk) { + return false; + } + + $file_disk->setConfig(); + + return collect([ + 'path' => $file_disk->driver == 'local' + ? $archive->getPath() + : $archive->getTemporaryUrl(Carbon::now()->addMinutes(5)), + 'file_name' => $archive->file_name, + ]); + } catch (\Exception) { + return false; + } + } + + /** + * Render the document and file the result under a media collection. + * + * Archiving is opt-in installation-wide; switched off, this is a no-op + * reporting zero. Otherwise the render lands in the local temp area first, + * is handed to the media library on the default disk, and the temp copy is + * swept up afterwards. + * + * @return true|int|string true once filed, 0 when archiving is off, or the + * failure message when the media library refused it + */ + public function generatePDF($collection_name, $file_name, $deleteExistingFile = false) + { + if ((Setting::getSetting('save_pdf_to_disk') ?? 'NO') == 'NO') { + return 0; + } + + $language = CompanySetting::getSetting('language', $this->company_id); + + App::setLocale($language); + app(FontService::class)->ensureFontsForLocale($language); + + $pdf = $this->getPDFData(); + + $temp_directory = 'temp/'.$collection_name.'/'.$this->id; + $temp_file = $temp_directory.'/temp.pdf'; + + Storage::disk('local')->put($temp_file, $pdf->output()); + + if ($deleteExistingFile) { + // Note: the document id is handed over where a collection name + // belongs, so this empties a collection nobody files anything + // under, and the superseded archive survives. Left as it stands. + $this->clearMediaCollection($this->id); + } + + $default_disk = FileDisk::whereSetAsDefault(true)->first(); + + if ($default_disk) { + $default_disk->setConfig(); + } + + $temp_path = Storage::disk('local')->path($temp_file); + + try { + $this->addMedia($temp_path) + ->withCustomProperties(['file_disk_id' => $default_disk->id]) + ->usingFileName($file_name.'.pdf') + ->toMediaCollection($collection_name, config('filesystems.default')); + + Storage::disk('local')->deleteDirectory($temp_directory); + + return true; + } catch (\Exception $e) { + return $e->getMessage(); + } + } + + /** + * The placeholder map every document shares: the customer's two addresses, + * the company and its address, the contact details, and one token per + * custom-field slug carried by the document and by its customer. + * + * Where a document and its customer answer to the same slug, the customer's + * answer is the one that survives. Every value is HTML-escaped, since the + * result is substituted straight into markup. + */ + public function getFieldsArray() + { + $customer = $this->customer; + $shipping_address = $customer->shippingAddress ?? new Address; + $billing_address = $customer->billingAddress ?? new Address; + $company_address = $this->company->address ?? new Address; + + // Token suffix => the address attribute answering it. Shared by all + // three address blocks; the name line is asked of the customer's only. + $address_tokens = [ + 'COUNTRY' => 'country_name', + 'STATE' => 'state', + 'CITY' => 'city', + 'ADDRESS_STREET_1' => 'address_street_1', + 'ADDRESS_STREET_2' => 'address_street_2', + 'PHONE' => 'phone', + 'ZIP_CODE' => 'zip', + ]; + + $fields = []; + + foreach (['SHIPPING' => $shipping_address, 'BILLING' => $billing_address] as $prefix => $address) { + $fields['{'.$prefix.'_ADDRESS_NAME}'] = $address->name; + + foreach ($address_tokens as $token => $attribute) { + $fields['{'.$prefix.'_'.$token.'}'] = $address->{$attribute}; + } + } + + $fields['{COMPANY_NAME}'] = $this->company->name; + + foreach ($address_tokens as $token => $attribute) { + $fields['{COMPANY_'.$token.'}'] = $company_address->{$attribute}; + } + + $fields['{COMPANY_VAT}'] = $this->company->vat_id; + $fields['{COMPANY_TAX}'] = $this->company->tax_id; + $fields['{CONTACT_DISPLAY_NAME}'] = $customer->name; + $fields['{PRIMARY_CONTACT_NAME}'] = $customer->contact_name; + $fields['{CONTACT_EMAIL}'] = $customer->email; + $fields['{CONTACT_PHONE}'] = $customer->phone; + $fields['{CONTACT_WEBSITE}'] = $customer->website; + + // The tax id token carries its own label, so a template printing it + // does not have to translate one. + $fields['{CONTACT_TAX_ID}'] = __('pdf_tax_id').': '.$customer->tax_id; + + foreach ([$this->fields, $customer->fields] as $answers) { + foreach ($answers as $answer) { + $fields['{'.$answer->customField->slug.'}'] = $answer->defaultAnswer; + } + } + + // The cast keeps a never-filled address line, custom field or tax id — + // which arrives as null — out of htmlspecialchars(), where null is + // deprecated on PHP 8.4 and fatal on 9. Every render used to emit those. + return array_map( + fn ($value) => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'), + $fields + ); + } + + /** + * Resolve the placeholders in a stored format string — a note, an address + * layout, an email body — and hand back PDF-ready markup. + * + * Tokens nothing answered to are dropped, along with the now-empty element + * pairs they leave behind, and paragraphs are flattened to line breaks. The + * result is sanitised, which also covers notes, since they arrive here too. + */ + public function getFormattedString($format) + { + $placeholders = array_merge($this->getFieldsArray(), $this->getExtraFields()); + + $markup = nl2br(strtr((string) $format, $placeholders)); + + $markup = preg_replace('/{(.*?)}/', '', $markup); + + $markup = preg_replace("/<[^\/>]*>([\s]?)*<\/[^>]*>/", '', $markup); + + $markup = str_replace(['

', '

'], ['', '
'], $markup); + + // Sanitising here strips the SSRF vectors that can ride in on + // user-supplied address fields, customer names and custom-field + // answers, without every caller needing a wrapper of its own. + return PdfHtmlSanitizer::sanitize($markup); + } +} diff --git a/app/Platform/Pdf/Http/Middleware/PdfMiddleware.php b/app/Platform/Pdf/Http/Middleware/PdfMiddleware.php new file mode 100644 index 00000000..9063b445 --- /dev/null +++ b/app/Platform/Pdf/Http/Middleware/PdfMiddleware.php @@ -0,0 +1,35 @@ +check()) { + return $next($request); + } + } + + return redirect('/login'); + } +} diff --git a/app/Platform/Storage/Http/BackupsController.php b/app/Platform/Storage/Http/BackupsController.php new file mode 100644 index 00000000..94291ff6 --- /dev/null +++ b/app/Platform/Storage/Http/BackupsController.php @@ -0,0 +1,175 @@ +authorize('manage backups'); + + try { + $target = $this->backupService->getDestination($request->file_disk_id); + + $archives = $target + ->backups() + ->map(function (Backup $archive) { + return [ + 'path' => $archive->path(), + 'created_at' => $archive->date()->format('Y-m-d H:i:s'), + 'size' => Format::humanReadableSize($archive->sizeInBytes()), + ]; + }) + ->toArray(); + + return response()->json([ + 'backups' => $archives, + ]); + } catch (\Exception $failure) { + return response()->json([ + 'backups' => [], + 'error' => 'invalid_disk_credentials', + 'error_message' => $failure->getMessage(), + ]); + } + } + + /** + * Queue an archive run and answer immediately. + * + * The whole body is handed to the job untouched -- `option` (everything, + * database only, or files only) and the disk selection are read there, and + * the reply says nothing about whether the run later succeeded. Nothing is + * validated at this end, so an unrecognised option is a job-time problem, + * not a request-time one. + */ + public function store(Request $request): JsonResponse + { + $this->authorize('manage backups'); + + $payload = $request->all(); + + dispatch(new CreateBackupJob($payload))->onQueue(config('backup.queue.name')); + + return response()->json(['success' => true]); + } + + /** + * Remove one archive from the selected disk. + * + * The route carries a `{backup}` segment because the endpoint is registered + * as part of a resource, but nothing reads it -- the archive is identified + * by the `path` in the body, and the segment can be any value at all. + * + * KNOWN DEFECT: a path that names no archive on the disk leaves the search + * empty and the delete is attempted on nothing, which is a 500 rather than + * a 404. Reproduced as found. + */ + public function destroy($disk, Request $request): JsonResponse + { + $this->authorize('manage backups'); + + $path = $this->requireArchivePath($request); + + $target = $this->backupService->getDestination($request->file_disk_id); + + $target + ->backups() + ->first(function (Backup $archive) use ($path) { + return $archive->path() === $path; + }) + ->delete(); + + return response()->json(['success' => true]); + } + + /** + * Stream one archive back as a download. + * + * Sent as a stream rather than a file response because the archive may live + * on a remote disk and is never staged locally. The length is taken from + * the destination's own metadata, so the browser gets a progress bar; the + * no-cache headers keep an intermediary from holding on to a database dump. + * + * A path that names nothing is 422 with a bare sentence -- plain text, not + * the JSON envelope the rest of the controller answers with. Preserved. + */ + public function download(Request $request): Response|StreamedResponse + { + $this->authorize('manage backups'); + + $path = $this->requireArchivePath($request); + + $target = $this->backupService->getDestination($request->file_disk_id); + + $archive = $target->backups()->first(function (Backup $candidate) use ($path) { + return $candidate->path() === $path; + }); + + if (! $archive) { + return response('Backup not found', 422); + } + + $name = pathinfo($archive->path(), PATHINFO_BASENAME); + + return response()->stream(function () use ($archive) { + $stream = $archive->stream(); + fpassthru($stream); + if (is_resource($stream)) { + fclose($stream); + } + }, 200, [ + 'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0', + 'Content-Type' => 'application/zip', + 'Content-Length' => $archive->sizeInBytes(), + 'Content-Disposition' => 'attachment; filename="'.$name.'"', + 'Pragma' => 'public', + ]); + } + + /** + * The archive path both write endpoints work from: required, and a zip. + */ + private function requireArchivePath(Request $request): string + { + return $request->validate(['path' => ['required', new PathToZip]])['path']; + } +} diff --git a/app/Platform/Storage/Http/DiskController.php b/app/Platform/Storage/Http/DiskController.php new file mode 100644 index 00000000..04a2dc64 --- /dev/null +++ b/app/Platform/Storage/Http/DiskController.php @@ -0,0 +1,334 @@ +authorize('manage file disk'); + + $perPage = $request->has('limit') ? $request->limit : 5; + + $page = FileDisk::applyFilters($request->all()) + ->latest() + ->paginateData($perPage); + + return FileDiskResource::collection($page); + } + + /** + * Register a new disk, once its credentials have been proven to work. + * + * The live check runs before anything is written, so a disk row only ever + * exists for credentials that succeeded at least once. Asking for the new + * disk to be the default clears the flag everywhere else first -- exactly + * one row carries it at any moment. + * + * The reply is 201 with the row as it was created, which means `type` reads + * as null: the column's default is filled in by the database and the + * in-memory model is not refreshed to see it. + */ + public function store(DiskEnvironmentRequest $request): JsonResponse|FileDiskResource + { + $this->authorize('manage file disk'); + + if (! $this->fileDiskService->validateCredentials($request->credentials, $request->driver)) { + return respondJson('invalid_credentials', 'Invalid Credentials.'); + } + + $disk = $this->fileDiskService->create($request); + + return new FileDiskResource($disk); + } + + /** + * Rewrite a disk, or just hand it the default flag. + * + * Which of those happens is decided by what the body carries. Credentials + * and a driver together mean a rewrite, and the new blob is proven live + * first. Without both, the only thing this endpoint will do is move the + * default flag -- and it does that only when the body asks for it. + * + * System disks never take the first branch whatever the body says: their + * credentials point at the two built-in local trees and rewriting them + * would strand every file already stored there. They fall through to the + * flag, which is the one thing about them that is allowed to change. + * + * The payload is built from the model as it stands in memory. On the + * set-default branch that is the saved state; on a no-op call it is simply + * the row as loaded. This is not validated input -- no form request stands + * in front of this route -- so a rewrite accepts whatever name, driver and + * credential shape the caller sent, and the live check is the only filter. + */ + public function update(FileDisk $disk, Request $request): JsonResponse|FileDiskResource + { + $this->authorize('manage file disk'); + + $credentials = $request->credentials; + $driver = $request->driver; + + if ($credentials && $driver && ! $disk->isSystem()) { + if (! $this->fileDiskService->validateCredentials($credentials, $driver)) { + return respondJson('invalid_credentials', 'Invalid Credentials.'); + } + + $this->fileDiskService->update($disk, $request); + } elseif ($request->set_as_default) { + $this->fileDiskService->setAsDefault($disk); + } + + return new FileDiskResource($disk); + } + + /** + * The empty credential form for a driver -- field names in the order the + * edit screen should draw them, every value blank. + * + * The route segment is the driver name, not a disk id: nothing is loaded + * and nothing existing is read. A driver nobody wrote a form for answers + * with an empty JSON array rather than an empty object. + * + * KNOWN DEFECT: the S3-compatible arm has no break, so a request for it + * falls through and is answered with the DigitalOcean Spaces form. The two + * carry the same field names in a different order, so the screen still + * works and the endpoint-first ordering written for the compatible driver + * never reaches a client. Reproduced as found. + */ + public function show($disk): JsonResponse + { + $this->authorize('manage file disk'); + + $template = []; + + switch ($disk) { + case 'local': + // Relative to storage/app -- "backups" lands in + // storage/app/backups once the disk is registered at runtime. + $template = [ + 'root' => '', + ]; + + break; + + case 's3': + $template = [ + 'key' => '', + 'secret' => '', + 'region' => '', + 'bucket' => '', + 'root' => '', + ]; + + break; + + case 's3compat': + $template = [ + 'endpoint' => '', + 'key' => '', + 'secret' => '', + 'region' => '', + 'bucket' => '', + 'root' => '', + ]; + + // Falls through -- see the note above. + + case 'doSpaces': + $template = [ + 'key' => '', + 'secret' => '', + 'region' => '', + 'bucket' => '', + 'endpoint' => '', + 'root' => '', + ]; + + break; + + case 'dropbox': + $template = [ + 'token' => '', + 'key' => '', + 'secret' => '', + 'app' => '', + 'root' => '', + ]; + + break; + } + + return response()->json(array_merge($template)); + } + + /** + * Unregister a disk, if nothing depends on it. + * + * Three refusals, in order: the two seeded system disks are permanent; the + * disk new uploads currently land on has to be replaced before it can go; + * and a disk that still holds media would leave those files unreachable, so + * the count is reported back and the operator is sent to migrate them + * first. + * + * The media check looks under two names, because files were written under + * the dynamic-prefix scheme in one era and under the bare driver name in + * another. Note what that means: it counts by *driver*, not by disk, so two + * local disks are indistinguishable here and files on either one block + * deleting the other. + */ + public function destroy(FileDisk $disk): JsonResponse + { + $this->authorize('manage file disk'); + + if ($disk->isSystem()) { + return respondJson('not_allowed', 'System disks cannot be deleted.'); + } + + // Reads the flag despite the name -- it is an accessor, not a setter. + if ($disk->setAsDefault()) { + return respondJson('not_allowed', 'The default disk cannot be deleted.'); + } + + $dynamicName = env('DYNAMIC_DISK_PREFIX', 'temp_').$disk->driver; + + $fileCount = DB::table('media')->where('disk', $dynamicName)->orWhere('disk', $disk->driver)->count(); + + if ($fileCount > 0) { + return respondJson('disk_has_files', 'Cannot delete this disk — it contains '.$fileCount.' file(s). Migrate files first.'); + } + + $disk->delete(); + + return response()->json([ + 'success' => true, + ]); + } + + /** + * The drivers the disk form offers, plus which one the current default disk + * uses so the form can preselect it. Falls back to the local driver when no + * disk is flagged as default. + */ + public function getDiskDrivers(): JsonResponse + { + $this->authorize('manage file disk'); + + $drivers = [ + ['name' => 'Local', 'value' => 'local'], + ['name' => 'Amazon S3', 'value' => 's3'], + ['name' => 'S3 Compatible Storage', 'value' => 's3compat'], + ['name' => 'Digital Ocean Spaces', 'value' => 'doSpaces'], + ['name' => 'Dropbox', 'value' => 'dropbox'], + ]; + + return response()->json([ + 'drivers' => $drivers, + 'default' => $this->defaultDisk()?->driver ?? 'local', + ]); + } + + /** + * Which disk each of the three jobs currently uses: new media, generated + * PDFs, and backups. + * + * A job with no setting of its own reads as the default disk, so the screen + * shows what would actually be used rather than a blank. A stored setting + * comes back as the string it was saved as, while the fallback is the + * numeric id off the model -- so the three keys are not uniformly typed. + */ + public function getDiskPurposes(): JsonResponse + { + $this->authorize('manage file disk'); + + $fallback = $this->defaultDisk()?->id; + + return response()->json([ + 'media_disk_id' => Setting::getSetting('media_disk_id') ?? $fallback, + 'pdf_disk_id' => Setting::getSetting('pdf_disk_id') ?? $fallback, + 'backup_disk_id' => Setting::getSetting('backup_disk_id') ?? $fallback, + ]); + } + + /** + * Point one or more of those jobs at a different disk. + * + * Each key is written only if the body mentions it, so a caller may move + * one job without disturbing the others; a key sent as null clears the + * setting, which puts that job back on the default disk. Ids are checked + * against the table, but nothing checks that the chosen disk is a sensible + * home for the job -- pointing backups at a disk the backup subsystem does + * not know surfaces only when a run is attempted. + */ + public function updateDiskPurposes(Request $request): JsonResponse + { + $this->authorize('manage file disk'); + + $purposes = ['media_disk_id', 'pdf_disk_id', 'backup_disk_id']; + + $request->validate(array_fill_keys($purposes, ['nullable', 'exists:file_disks,id'])); + + foreach ($purposes as $purpose) { + if ($request->has($purpose)) { + Setting::setSetting($purpose, $request->input($purpose)); + } + } + + return response()->json(['success' => true]); + } + + /** + * The single disk carrying the default flag, if there is one. + */ + private function defaultDisk(): ?FileDisk + { + return FileDisk::query()->where('set_as_default', true)->first(); + } +} diff --git a/app/Platform/Storage/Http/Middleware/ConfigMiddleware.php b/app/Platform/Storage/Http/Middleware/ConfigMiddleware.php new file mode 100644 index 00000000..f2e68f38 --- /dev/null +++ b/app/Platform/Storage/Http/Middleware/ConfigMiddleware.php @@ -0,0 +1,42 @@ +has('file_disk_id')) { + $requested = FileDisk::find($request->file_disk_id); + + $requested?->setConfig(); + } + + return $next($request); + } +} diff --git a/app/Platform/Storage/Http/Requests/DiskEnvironmentRequest.php b/app/Platform/Storage/Http/Requests/DiskEnvironmentRequest.php new file mode 100644 index 00000000..5977763f --- /dev/null +++ b/app/Platform/Storage/Http/Requests/DiskEnvironmentRequest.php @@ -0,0 +1,111 @@ +get('driver')) { + case 's3': + $credentials = $this->amazonRules(); + + break; + + case 'doSpaces': + $credentials = $this->spacesRules(); + + break; + + case 'dropbox': + $credentials = $this->dropboxRules(); + + break; + } + + return array_merge($credentials, [ + 'name' => ['required'], + 'driver' => ['required'], + ]); + } + + /** + * Amazon S3 proper. The endpoint is optional here -- leaving it out means + * the region alone decides which host the SDK talks to. + */ + private function amazonRules(): array + { + return [ + 'credentials.key' => ['required', 'string'], + 'credentials.secret' => ['required', 'string'], + 'credentials.region' => ['required', 'string'], + 'credentials.bucket' => ['required', 'string'], + 'credentials.endpoint' => ['nullable', 'string', 'url', new PublicHttpUrl], + 'credentials.root' => ['required', 'string'], + ]; + } + + /** + * DigitalOcean Spaces speaks the S3 protocol but always against its own + * host, so the endpoint is mandatory rather than optional. + */ + private function spacesRules(): array + { + return [ + 'credentials.key' => ['required', 'string'], + 'credentials.secret' => ['required', 'string'], + 'credentials.region' => ['required', 'string'], + 'credentials.bucket' => ['required', 'string'], + 'credentials.endpoint' => ['required', 'string', 'url', new PublicHttpUrl], + 'credentials.root' => ['required', 'string'], + ]; + } + + /** + * Dropbox: an access token plus the app identity it was issued for. No + * endpoint, so nothing for the reachability rule to inspect. + */ + private function dropboxRules(): array + { + return [ + 'credentials.token' => ['required', 'string'], + 'credentials.key' => ['required', 'string'], + 'credentials.secret' => ['required', 'string'], + 'credentials.app' => ['required', 'string'], + 'credentials.root' => ['required', 'string'], + ]; + } +} diff --git a/app/Platform/Storage/Http/Resources/FileDiskCollection.php b/app/Platform/Storage/Http/Resources/FileDiskCollection.php new file mode 100644 index 00000000..4a39e637 --- /dev/null +++ b/app/Platform/Storage/Http/Resources/FileDiskCollection.php @@ -0,0 +1,27 @@ +resource; + + return [ + 'id' => $fileDisk->id, + 'name' => $fileDisk->name, + 'type' => $fileDisk->type, + 'driver' => $fileDisk->driver, + 'set_as_default' => $fileDisk->set_as_default, + 'credentials' => $fileDisk->credentials, + 'company_id' => $fileDisk->company_id, + ]; + } +} diff --git a/app/Platform/Storage/Jobs/CreateBackupJob.php b/app/Platform/Storage/Jobs/CreateBackupJob.php new file mode 100644 index 00000000..7735f8bd --- /dev/null +++ b/app/Platform/Storage/Jobs/CreateBackupJob.php @@ -0,0 +1,59 @@ +data = $data; + } + + /** + * Assemble the backup task, narrow it to the requested option and run it. + */ + public function handle(): void + { + $job = BackupJobFactory::createFromConfig( + BackupConfigurationFactory::make($this->data) + ); + + if (! defined('SIGINT')) { + $job->disableSignals(); + } + + $option = $this->data['option']; + + if ($option === 'only-db') { + $job->dontBackupFilesystem(); + } + + if ($option === 'only-files') { + $job->dontBackupDatabases(); + } + + if (! empty($option)) { + $job->setFilename(str_replace('_', '-', $option).'-'.date('Y-m-d-H-i-s').'.zip'); + } + + $job->run(); + } +} diff --git a/app/Platform/Storage/Models/FileDisk.php b/app/Platform/Storage/Models/FileDisk.php new file mode 100644 index 00000000..c6e7b099 --- /dev/null +++ b/app/Platform/Storage/Models/FileDisk.php @@ -0,0 +1,168 @@ + 'boolean']; + } + + public function setCredentialsAttribute(mixed $value): void + { + $this->attributes['credentials'] = json_encode($value); + } + + /** + * Read the credential blob back as a collection. + * + * Rows written by older releases hold a JSON string *inside* the JSON + * column, so a first decode that yields a string is decoded once more. + */ + public function getDecodedCredentials(): Collection + { + $payload = json_decode($this->credentials, true); + + if (is_string($payload)) { + $payload = json_decode($payload, true); + } + + return collect($payload ?? []); + } + + public function scopeWhereOrder($query, $orderByField, $orderBy) + { + SafeOrderBy::apply($query, $orderByField, $orderBy); + } + + public function scopeFileDisksBetween($query, $start, $end) + { + return $query->whereBetween('file_disks.created_at', [ + $start->format('Y-m-d'), + $end->format('Y-m-d'), + ]); + } + + public function scopeWhereSearch($query, $search) + { + foreach (explode(' ', $search) as $keyword) { + $like = '%'.$keyword.'%'; + + $query->where('name', 'LIKE', $like)->orWhere('driver', 'LIKE', $like); + } + } + + public function scopePaginateData($query, $limit) + { + return $limit == 'all' + ? $query->get() + : $query->paginate($limit); + } + + public function scopeApplyFilters($query, array $filters) + { + $criteria = collect($filters); + + if ($criteria->get('search')) { + $query->whereSearch($criteria->get('search')); + } + + if ($criteria->get('from_date') && $criteria->get('to_date')) { + $query->fileDisksBetween( + Carbon::createFromFormat('Y-m-d', $criteria->get('from_date')), + Carbon::createFromFormat('Y-m-d', $criteria->get('to_date')) + ); + } + + if ($criteria->get('orderByField') || $criteria->get('orderBy')) { + $query->whereOrder( + $criteria->get('orderByField') ?: 'sequence_number', + $criteria->get('orderBy') ?: 'asc' + ); + } + } + + /** + * Register this disk and point the runtime filesystem default at it. + * + * @deprecated Reach for FileDiskService::registerDisk(); this variant also + * rewrites filesystems.default, which leaks into unrelated storage calls. + */ + public function setConfig(): void + { + $registered = app(FileDiskService::class)->registerDisk($this); + + config(['filesystems.default' => $registered]); + } + + /** + * Whether this row is flagged as the installation-wide default disk. + */ + public function setAsDefault(): bool + { + return $this->set_as_default; + } + + /** + * Publish a throwaway disk built from the driver's base config overlaid + * with the supplied credentials, and select it as the runtime default. + * + * @deprecated Register a persisted row through FileDiskService instead. + */ + public static function setFilesystem(Collection $credentials, string $driver): void + { + $target = env('DYNAMIC_DISK_PREFIX', 'temp_').$driver; + + config(['filesystems.default' => $target]); + + $settings = config('filesystems.disks.'.$driver); + + foreach ($settings as $field => $current) { + if ($credentials->has($field)) { + $settings[$field] = $credentials[$field]; + } + } + + if ($driver === 'local' && isset($settings['root']) && ! str_starts_with($settings['root'], '/')) { + $settings['root'] = storage_path('app/'.$settings['root']); + } + + config(['filesystems.disks.'.$target => $settings]); + } + + public function isSystem(): bool + { + return $this->hasType(self::DISK_TYPE_SYSTEM); + } + + public function isRemote(): bool + { + return $this->hasType(self::DISK_TYPE_REMOTE); + } + + private function hasType(string $expected): bool + { + return $this->type === $expected; + } +} diff --git a/app/Platform/Storage/Rules/BackupDisk.php b/app/Platform/Storage/Rules/BackupDisk.php new file mode 100644 index 00000000..5f925ad9 --- /dev/null +++ b/app/Platform/Storage/Rules/BackupDisk.php @@ -0,0 +1,26 @@ +