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(); } }