mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 21:00:58 +00:00
335 lines
12 KiB
PHP
335 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Platform\Storage\Http;
|
|
|
|
use App\Platform\Http\Controller;
|
|
use App\Platform\Operations\Models\Setting;
|
|
use App\Platform\Storage\Application\FileDiskService;
|
|
use App\Platform\Storage\Http\Requests\DiskEnvironmentRequest;
|
|
use App\Platform\Storage\Http\Resources\FileDiskResource;
|
|
use App\Platform\Storage\Models\FileDisk;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* The registry of storage targets, and the three jobs they can be put to.
|
|
*
|
|
* A disk is a name, a driver, and a credential blob. Two of them are seeded and
|
|
* marked as system disks -- the public and the private local trees -- and are
|
|
* treated as furniture: their credentials are never revalidated and never
|
|
* rewritten, and they cannot be deleted. Everything else is something an
|
|
* operator added.
|
|
*
|
|
* Credentials are not taken on trust. Before a disk is written or rewritten the
|
|
* blob is exercised against the driver for real -- a temporary disk is built
|
|
* from it, a marker file written, read back and removed -- and a blob that
|
|
* fails that round trip is refused with `invalid_credentials`. A custom
|
|
* endpoint is checked for public reachability earlier still, in the form
|
|
* request, so an endpoint aimed at the loopback interface or a metadata service
|
|
* never reaches the point where the server would sign a request to it.
|
|
*
|
|
* Every endpoint here is gated on the same ability.
|
|
*/
|
|
class DiskController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly FileDiskService $fileDiskService,
|
|
) {}
|
|
|
|
/**
|
|
* A page of registered disks, newest first.
|
|
*
|
|
* The raw input goes to the filter scope untouched, so `search` (matched
|
|
* against both the name and the driver), the date range and the order pair
|
|
* are all read there. A `limit` of "all" makes the pagination scope hand
|
|
* back the whole collection instead of a paginator; with no `limit` a page
|
|
* holds five.
|
|
*
|
|
* KNOWN DEFECT, same shape as the tax-type listing: asking for an order
|
|
* without naming a field falls back to a `sequence_number` column this
|
|
* table does not have. The safe-ordering helper drops the clause instead of
|
|
* failing, so the request succeeds in whatever order the database chose.
|
|
*/
|
|
public function index(Request $request): AnonymousResourceCollection
|
|
{
|
|
$this->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();
|
|
}
|
|
}
|