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 @@
+