From 4303e9896b67f2751e3ee9c5a32a45d70211d58c Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 8 Sep 2026 15:55:33 +0200 Subject: [PATCH 1/7] Add chunked asset uploads and direct S3 multipart uploads --- CHANGELOG.md | 1 + docs/uploads.md | 100 ++++++ .../craftcms-legacy/cp/src/js/AssetIndex.js | 4 + rector.php | 5 + .../asset-select-input/asset-select-input.ts | 6 + .../composables/useElementActionMenu.ts | 4 + .../modules/uploader/upload-notification.ts | 59 ++++ .../js/modules/uploader/uploader.test.ts | 84 +++++ resources/js/modules/uploader/uploader.ts | 136 +++++++- .../js/pages/assets/AssetUploadButton.vue | 4 + resources/js/upload-client.test.ts | 324 ++++++++++++++++++ resources/js/upload-client.ts | 309 +++++++++++++++++ resources/js/uploads.ts | 15 + routes/actions.php | 11 + src/Asset/AssetUploadHandler.php | 226 ++++++++++++ src/Asset/AssetUploaders.php | 48 +++ src/Asset/AssetUploads.php | 270 +++++++++++++++ src/Asset/Assets.php | 4 +- src/Asset/AssetsHelper.php | 8 + src/Asset/Contracts/AssetUploader.php | 22 ++ src/Asset/Data/UploadPartRequest.php | 23 ++ src/Asset/Data/UploadResult.php | 74 ++++ src/Asset/Data/UploadSessionData.php | 24 ++ src/Asset/Data/UploadedAssetFile.php | 152 ++++++++ src/Asset/Elements/Asset.php | 96 +++--- src/Asset/Models/UploadSession.php | 68 ++++ src/Asset/Uploaders/ChunkedUploader.php | 125 +++++++ src/Asset/Uploaders/S3Uploader.php | 142 ++++++++ src/Asset/Validation/AssetRules.php | 2 +- src/Config/GeneralConfig.php | 52 +++ src/Cp/Cp.php | 1 + ...230_create_asset_upload_sessions_table.php | 39 +++ src/Database/Migrations/Install.php | 17 + src/Database/Table.php | 2 + .../Actions/RemoveExpiredUploads.php | 32 ++ src/GarbageCollection/GarbageCollection.php | 2 + .../Controllers/Assets/UploadController.php | 166 ++------- .../Assets/UploadSessionController.php | 62 ++++ src/Http/Requests/UploadRequest.php | 29 ++ tests/Feature/Asset/AssetsTest.php | 92 +++++ .../Assets/UploadControllerTest.php | 48 ++- .../Assets/UploadSessionControllerTest.php | 277 +++++++++++++++ tests/Unit/Asset/AssetUploadersTest.php | 52 +++ tests/Unit/Asset/S3UploaderTest.php | 124 +++++++ vite.config.js | 1 + .../TypeScriptTransformerServiceProvider.php | 6 + workbench/config/craft/general.php | 8 + 47 files changed, 3158 insertions(+), 198 deletions(-) create mode 100644 docs/uploads.md create mode 100644 resources/js/modules/uploader/upload-notification.ts create mode 100644 resources/js/modules/uploader/uploader.test.ts create mode 100644 resources/js/upload-client.test.ts create mode 100644 resources/js/upload-client.ts create mode 100644 resources/js/uploads.ts create mode 100644 src/Asset/AssetUploadHandler.php create mode 100644 src/Asset/AssetUploaders.php create mode 100644 src/Asset/AssetUploads.php create mode 100644 src/Asset/Contracts/AssetUploader.php create mode 100644 src/Asset/Data/UploadPartRequest.php create mode 100644 src/Asset/Data/UploadResult.php create mode 100644 src/Asset/Data/UploadSessionData.php create mode 100644 src/Asset/Data/UploadedAssetFile.php create mode 100644 src/Asset/Models/UploadSession.php create mode 100644 src/Asset/Uploaders/ChunkedUploader.php create mode 100644 src/Asset/Uploaders/S3Uploader.php create mode 100644 src/Database/Migrations/2026_09_08_101230_create_asset_upload_sessions_table.php create mode 100644 src/GarbageCollection/Actions/RemoveExpiredUploads.php create mode 100644 src/Http/Controllers/Assets/UploadSessionController.php create mode 100644 src/Http/Requests/UploadRequest.php create mode 100644 tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php create mode 100644 tests/Unit/Asset/AssetUploadersTest.php create mode 100644 tests/Unit/Asset/S3UploaderTest.php create mode 100644 workbench/config/craft/general.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8e8117136..458ce2f0992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Moved legacy relation-field settings HTML and entry-title input HTML into the Yii adapter. ([#19591](https://github.com/craftcms/cms/pull/19591)) - Migrated the reassign entries, replace relations, and replace references modals to the Form API. ([#19589](https://github.com/craftcms/cms/pull/19589)) +- Added chunked asset uploads, direct S3 multipart uploads, upload retry and cancellation controls, and an extensible upload-session API. - Added support for refreshable standard plugin settings forms and conditional configuration of core form nodes. ([#19545](https://github.com/craftcms/cms/pull/19545)) - Added support for sending queued Laravel notifications to `CraftCms\Cms\User\Elements\User` elements. ([#19541](https://github.com/craftcms/cms/pull/19541)) - Added Markdown comments to element activity timelines, with support for editing, removing, structured user mentions, and email notifications. diff --git a/docs/uploads.md b/docs/uploads.md new file mode 100644 index 00000000000..e3a2aef30aa --- /dev/null +++ b/docs/uploads.md @@ -0,0 +1,100 @@ +# Asset uploads + +Craft uploads files in parts, then validates and saves the asset. The control panel supports progress, cancellation, and retry for asset uploads and replacements. Existing multipart form uploads remain supported. + +## Configuration + +Configure uploads in `config/craft/general.php`: + +```php +use CraftCms\Cms\Config\GeneralConfig; + +return GeneralConfig::create() + ->maxUploadFileSize(200 * 1024 * 1024) + ->tempAssetUploadFs('disk:uploads'); +``` + +- `maxUploadFileSize` limits the complete file, independently of PHP’s per-request limit. +- `tempAssetUploadFs` selects the temporary filesystem by handle or `disk:name`. Laravel S3 disks receive bytes directly from the browser; other disks receive chunks through PHP. +- `uploadChunkSize` defaults to 8 MiB for PHP uploads, capped by `post_max_size`. Lower it if your proxy or web server has a smaller request limit. S3 part sizes are selected separately. +- `uploadSessionDuration` defaults to 24 hours of inactivity. Craft garbage collection removes expired uploads. + +Asset processing can still require PHP memory, local temporary space, and time to finish saving. Direct S3 uploads do not eliminate those requirements. Transfers between separate disks stream through PHP. + +### S3 setup + +Install `league/flysystem-aws-s3-v3` and configure a Laravel S3 disk. Allow browser `PUT` requests from your application origin and the signed request headers in bucket CORS. Exposing `ETag` is unnecessary. + +The application needs permissions to create, list, complete, and abort multipart uploads, read and delete temporary objects, and write to the destination. Configure a lifecycle rule to abort abandoned multipart uploads. + +## Frontend uploads + +Load the `resources/js/uploads.ts` Vite entry through your application’s asset integration to expose `CraftUploads.AssetUpload`. Supply the URL generated by `route('craft.actions.craft.uploads.store')`, the current CSRF token, and destination parameters: + +```js +const task = new CraftUploads.AssetUpload(fileInput.files[0], { + url: fileInput.dataset.uploadUrl, + csrfToken: document.querySelector('meta[name="csrf-token"]').content, + parameters: {folderId: Number(fileInput.dataset.folderId)}, + onProgress: (loaded, total) => { + progress.max = total; + progress.value = loaded; + }, +}); + +const result = await task.upload(); +``` + +Uploads may instead use the existing `fieldId`, `elementId`, and `siteId` destination parameters. For replacement, pass `operation: 'replace'` and `assetId`. + +- Await `upload()` to confirm saving finished. Transfer progress reaching 100% is not completion. Handle filename `conflict` responses before selecting the returned `assetId`. +- Call `cancel()` to stop and clean up. Cancellation is unavailable during saving. +- Transient failures retry up to three times. After failure, call `upload()` again to retry. Reloading the page loses client progress. +- `onStateChange` receives `ready`, `uploading`, `completing`, `completed`, `failed`, or `canceled`. +- `UploadError` exposes `message`, `status`, and response `data`. The `timeout` option defaults to 120,000 milliseconds per request; use `0` to disable it. + +### Anonymous uploads + +Guests cannot upload by default. Register an authorization callback in a service provider: + +```php +use CraftCms\Cms\Asset\AssetUploads; +use Illuminate\Http\Request; + +public function boot(AssetUploads $uploads): void +{ + $uploads->allowGuestUploadsUsing(function (Request $request, array $file): bool { + return $file['folderId'] === 123 + && $file['size'] <= 10 * 1024 * 1024; + }); +} +``` + +Replace `123` with the destination folder ID. This example allows guests to upload files up to 10 MiB to that folder. The callback receives `folderId`, `filename`, `size`, and client-supplied `context`; add any access or file restrictions your application needs. Craft rechecks authorization throughout the upload using stored file details. + +Guests must supply a `folderId` and cannot replace assets or use field destinations. Craft still enforces its file limits and sanitizes guest images. + +## Plugin uploaders + +Implement `CraftCms\Cms\Asset\Contracts\AssetUploader`, register it during service-provider boot, and select it with `GeneralConfig::assetUploader('cloud')`: + +```php +use CraftCms\Cms\Asset\AssetUploaders; +use Illuminate\Contracts\Container\Container; + +public function boot(AssetUploaders $uploaders): void +{ + $uploaders->extend('cloud', fn (Container $app) => $app->make(CloudUploader::class)); +} +``` + +The contract has four methods: + +- `start()` sets the session’s `chunkSize` and serializable provider `state`. +- `partRequest()` returns an `UploadPartRequest` with the URL, method, and headers for a raw file slice. Use one part for whole-file transfers. +- `complete()` verifies the stored bytes and returns an `UploadedAssetFile` on the temporary disk. It must tolerate retries after storage completion. +- `abort()` removes incomplete transfers and completed temporary objects. It must tolerate retries. + +Uploaders are cached by name. Keep upload-specific state on the session and resolve its disk through `Filesystems::disk($session->disk)`. Keep provider credentials on the server. + +Craft handles authorization, expiry, asset validation and creation, browser retries, and progress. The control panel’s `Craft.createUploader()` factory still supports custom JavaScript uploader classes registered for a filesystem type. Custom legacy uploaders can opt out of upload sessions with `uploadSessions: false`. diff --git a/packages/craftcms-legacy/cp/src/js/AssetIndex.js b/packages/craftcms-legacy/cp/src/js/AssetIndex.js index b34d4c8d25a..2a84b918f8e 100644 --- a/packages/craftcms-legacy/cp/src/js/AssetIndex.js +++ b/packages/craftcms-legacy/cp/src/js/AssetIndex.js @@ -538,6 +538,10 @@ Craft.AssetIndex = Craft.BaseElementIndex.extend( * On Upload Failure. */ _onUploadFailure: function (event, data = null) { + if (data?.errorThrown === 'abort') { + return; + } + const response = event instanceof CustomEvent ? event.detail : data?.jqXHR?.responseJSON; diff --git a/rector.php b/rector.php index 558f8821c38..2f3b6c90f77 100644 --- a/rector.php +++ b/rector.php @@ -8,6 +8,7 @@ use Rector\DeadCode\Rector\Property\RemoveDefaultValueFromAssignedPropertyRector; use Rector\DeadCode\Rector\PropertyProperty\RemoveNullPropertyInitializationRector; use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; +use Rector\Php74\Rector\Property\RestoreDefaultNullToNullableTypePropertyRector; use Rector\Php81\Rector\Array_\ArrayToFirstClassCallableRector; use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector; use RectorLaravel\Rector\ArrayDimFetch\EnvVariableToEnvHelperRector; @@ -29,6 +30,10 @@ cacheClass: FileCacheStorage::class ) ->withSkip([ + RestoreDefaultNullToNullableTypePropertyRector::class => [ + // Uninitialized response properties represent omitted fields rather than explicit nulls. + __DIR__.'/src/Asset/Data/UploadResult.php', + ], AnonymousMigrationsRector::class => [ __DIR__.'/src/Database/Migrations/BaseContentRefactorMigration.php', __DIR__.'/src/Database/Migrations/BaseEntryTypeMergeMigration.php', diff --git a/resources/js/modules/asset-select-input/asset-select-input.ts b/resources/js/modules/asset-select-input/asset-select-input.ts index 7d81c604145..d60648913c8 100644 --- a/resources/js/modules/asset-select-input/asset-select-input.ts +++ b/resources/js/modules/asset-select-input/asset-select-input.ts @@ -256,6 +256,12 @@ export class AssetSelectInput extends BaseElementSelectInput { } _onUploadFailure(event: any, data: any = null): void { + if (data?.errorThrown === 'abort') { + this.progressBar.hideProgressBar(); + this.$container.removeClass('uploading'); + return; + } + const response = event instanceof CustomEvent ? event.detail : data?.jqXHR?.responseJSON; diff --git a/resources/js/modules/elements/composables/useElementActionMenu.ts b/resources/js/modules/elements/composables/useElementActionMenu.ts index fcb7a45ca29..f506290b3b5 100644 --- a/resources/js/modules/elements/composables/useElementActionMenu.ts +++ b/resources/js/modules/elements/composables/useElementActionMenu.ts @@ -293,6 +293,10 @@ export function createElementActionMenu({currentEntryTypeId}: Options = {}) { router.reload(); }, fileuploadfail: (event: any, data: any) => { + if (data?.errorThrown === 'abort') { + return; + } + const response = event instanceof CustomEvent ? event.detail diff --git a/resources/js/modules/uploader/upload-notification.ts b/resources/js/modules/uploader/upload-notification.ts new file mode 100644 index 00000000000..d0be18e18c3 --- /dev/null +++ b/resources/js/modules/uploader/upload-notification.ts @@ -0,0 +1,59 @@ +import {t} from '@craftcms/ui'; +import type {UploadState} from '@/upload-client'; + +declare const Craft: any; + +/** Uses the existing CP notification for per-file retry and cancellation controls. */ +export class UploadNotification { + private notification: any; + private shown = false; + private finished = false; + private retryButton = document.createElement('craft-button'); + private cancelButton = document.createElement('craft-button'); + private progress = document.createElement('progress'); + + constructor(file: File, retry: () => void, cancel: () => void) { + const details = document.createElement('div'); + details.className = 'flex items-center gap-2'; + this.progress.max = file.size; + this.progress.value = 0; + this.progress.setAttribute('aria-label', t('Upload progress')); + this.retryButton.textContent = t('Retry'); + this.retryButton.hidden = true; + this.retryButton.addEventListener('click', retry); + this.cancelButton.textContent = t('Cancel'); + this.cancelButton.addEventListener('click', cancel); + details.append(this.progress, this.retryButton, this.cancelButton); + this.notification = Craft.cp.displayNotice(file.name, { + details, + persist: true, + }); + this.notification.on('show', () => { + this.notification.$closeBtn.hide(); + this.shown = true; + if (this.finished) { + this.notification.close(); + } + }); + } + + updateProgress(loaded: number): void { + this.progress.value = loaded; + } + + updateState(state: UploadState): void { + this.retryButton.hidden = state !== 'failed'; + this.cancelButton.toggleAttribute('disabled', state === 'completing'); + + if (state === 'completed' || state === 'canceled') { + this.close(); + } + } + + close(): void { + this.finished = true; + if (this.shown) { + this.notification.close(); + } + } +} diff --git a/resources/js/modules/uploader/uploader.test.ts b/resources/js/modules/uploader/uploader.test.ts new file mode 100644 index 00000000000..89c2c2ce8bb --- /dev/null +++ b/resources/js/modules/uploader/uploader.test.ts @@ -0,0 +1,84 @@ +import {afterEach, beforeEach, expect, it, vi} from 'vitest'; +import {Uploader} from './uploader'; +import {AssetUpload, UploadError} from '@/upload-client'; + +vi.mock('./base-uploader', () => ({BaseUploader: class {}})); +vi.mock('./upload-notification', () => ({ + UploadNotification: class { + updateState() {} + close() {} + }, +})); + +let uploader: Uploader; +const trigger = vi.fn(); + +beforeEach(() => { + class TestUploader extends Uploader {} + uploader = new TestUploader(); + trigger.mockReset(); + vi.stubGlobal('Craft', { + csrfTokenValue: 'token', + cp: {displayError: vi.fn()}, + }); + uploader.$element = {trigger, off: vi.fn()}; + uploader.uploader = {fileupload: vi.fn()}; + uploader.events = {}; + uploader.settings = {}; + uploader._inProgressCounter = 0; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +it('does not cancel a successfully canceled upload again during teardown', async () => { + vi.spyOn(AssetUpload.prototype, 'upload').mockRejectedValue( + new UploadError('Upload failed.', 500) + ); + const cancel = vi.spyOn(AssetUpload.prototype, 'cancel').mockResolvedValue(); + const file = new File(['abc'], 'document.txt'); + const data = {files: [file], abort: async () => {}}; + + uploader['uploadFile'](file, data); + await vi.waitFor(() => + expect(trigger).toHaveBeenCalledWith('fileuploadstop') + ); + await data.abort(); + uploader.destroy(); + + expect(cancel).toHaveBeenCalledTimes(1); +}); + +it('allows retry after a replacement failure handler throws', async () => { + const handlerError = new Error('Replacement handler failed'); + const reportError = vi.fn(); + vi.stubGlobal('reportError', reportError); + trigger.mockImplementation((name: string) => { + if (name === 'fileuploadfail') { + throw handlerError; + } + }); + vi.spyOn(AssetUpload.prototype, 'upload') + .mockRejectedValueOnce(new UploadError('Upload failed.', 500)) + .mockResolvedValueOnce({assetId: 123}); + const file = new File(['abc'], 'replacement.txt'); + const data = {files: [file], submit: () => {}}; + + uploader['uploadFile'](file, data); + await vi.waitFor(() => + expect(reportError).toHaveBeenCalledWith(handlerError) + ); + data.submit(); + + await vi.waitFor(() => + expect(trigger).toHaveBeenCalledWith( + 'fileuploaddone', + expect.objectContaining({result: {assetId: 123}}) + ) + ); + expect( + trigger.mock.calls.filter(([name]) => name === 'fileuploadstop') + ).toHaveLength(2); +}); diff --git a/resources/js/modules/uploader/uploader.ts b/resources/js/modules/uploader/uploader.ts index cc0680f1ece..a22f79b22ec 100644 --- a/resources/js/modules/uploader/uploader.ts +++ b/resources/js/modules/uploader/uploader.ts @@ -1,4 +1,8 @@ import {BaseUploader} from './base-uploader'; +import {UploadNotification} from './upload-notification'; +import {AssetUpload, UploadError} from '@/upload-client'; +import {store} from '@/routes/craft/actions/craft/cp/uploads'; +import {upload, replaceFile} from '@actions/Assets/UploadController'; // blueimp jQuery File Upload plugin seam — see base-uploader.ts. declare const $: any; @@ -27,6 +31,11 @@ export class Uploader extends BaseUploader { _totalFileCounter = 0; _validFileCounter = 0; _onFileAdd: any = null; + private sessionUploads = false; + private destroyed = false; + private queue: Promise = Promise.resolve(); + private queued = new Set(); + private uploads = new Map(); static override get defaults(): any { return {...DEFAULTS, maxFileSize: Craft.maxUploadSize}; @@ -40,10 +49,26 @@ export class Uploader extends BaseUploader { } override init($element: any, settings: any): void { + const customMaxFileSize = settings?.maxFileSize; settings = $.extend({}, Uploader.defaults, settings); super.init($element, settings); delete this.settings.events; + const target = new URL(this.settings.url, location.href); + const expected = new URL( + this.settings.replace ? replaceFile.url() : upload.url(), + location.href + ); + this.sessionUploads = + this.settings.uploadSessions !== false && + (target.pathname === expected.pathname || + target.searchParams.get('action') === + (this.settings.replace ? 'assets/replace-file' : 'assets/upload')); + + if (this.sessionUploads && customMaxFileSize == null) { + this.settings.maxFileSize = Craft.maxAssetUploadSize; + } + this.uploader = this.$element.fileupload(this.settings); Object.entries(this.events).forEach(([name, handler]) => { @@ -71,7 +96,9 @@ export class Uploader extends BaseUploader { * Get the number of uploads in progress. */ override getInProgress(): number { - return this.uploader.fileupload('active'); + return this.sessionUploads + ? this._inProgressCounter + : this.uploader.fileupload('active'); } /** @@ -96,7 +123,7 @@ export class Uploader extends BaseUploader { let pass = true; if (validateExtension) { const matches = file.name.match(/\.([a-z0-4_]+)$/i); - const fileExtension = matches[1]; + const fileExtension = matches?.[1] ?? ''; if ( $.inArray(fileExtension.toLowerCase(), this._extensionList) === -1 ) { @@ -122,7 +149,11 @@ export class Uploader extends BaseUploader { if (pass) { this._validFileCounter++; - data.submit(); + if (this.sessionUploads) { + this.uploadFile(file, data); + } else { + data.submit(); + } } if (++this._totalFileCounter === data.originalFiles.length) { @@ -135,7 +166,106 @@ export class Uploader extends BaseUploader { return true; } + private uploadFile(file: File, data: any): void { + const task = new AssetUpload(file, { + url: store.url(), + parameters: { + ...this.formData, + operation: this.settings.replace ? 'replace' : 'upload', + }, + csrfToken: Craft.csrfTokenValue, + onProgress: (loaded, total) => { + this.uploads.get(task)?.updateProgress(loaded); + this.$element.trigger('fileuploadprogressall', {loaded, total}); + }, + onStateChange: (state) => this.uploads.get(task)?.updateState(state), + }); + + const cancel = async () => { + await task.cancel(); + this.uploads.delete(task); + }; + + const notification = new UploadNotification( + file, + () => this.enqueue(task, data), + () => { + cancel().catch((error: Error) => Craft.cp.displayError(error.message)); + } + ); + this.uploads.set(task, notification); + data.abort = cancel; + data.submit = () => this.enqueue(task, data); + this.enqueue(task, data); + } + + private enqueue(task: AssetUpload, data: any): void { + if ( + this.destroyed || + this.queued.has(task) || + ['uploading', 'completing', 'completed', 'canceled'].includes(task.state) + ) { + return; + } + + this.queued.add(task); + this._inProgressCounter++; + if (this._inProgressCounter === 1) { + this.$element.trigger('fileuploadstart'); + } + + this.queue = this.queue + .then(async () => { + try { + if (this.destroyed) { + return; + } + + data.result = await task.upload(); + this.uploads.delete(task); + this.$element.trigger('fileuploaddone', data); + } catch (error) { + if (!this.destroyed) { + data.errorThrown = task.state === 'canceled' ? 'abort' : 'error'; + data.jqXHR = { + responseJSON: + error instanceof UploadError + ? {message: error.message, ...error.data} + : { + message: + error instanceof Error + ? error.message + : Craft.t('app', 'Upload failed.'), + }, + }; + this.$element.trigger('fileuploadfail', data); + } + } finally { + this.queued.delete(task); + if (!this.destroyed) { + this.$element.trigger('fileuploadalways', data); + } + this._inProgressCounter--; + if (!this.destroyed && this._inProgressCounter === 0) { + this.$element.trigger('fileuploadstop'); + } + } + }) + .catch((error) => reportError(error)); + } + override destroy(): void { + this.destroyed = true; + for (const [task, notification] of this.uploads) { + if (task.state !== 'completing') { + task + .cancel() + .catch((error: Error) => Craft.cp.displayError(error.message)); + } + notification.close(); + } + this.uploads.clear(); + if (this.uploader.fileupload('instance')) { this.uploader.fileupload('destroy'); } diff --git a/resources/js/pages/assets/AssetUploadButton.vue b/resources/js/pages/assets/AssetUploadButton.vue index 484da03815d..c882764019f 100644 --- a/resources/js/pages/assets/AssetUploadButton.vue +++ b/resources/js/pages/assets/AssetUploadButton.vue @@ -90,6 +90,10 @@ } }, fileuploadfail: (event: Event, data: any = null) => { + if (data?.errorThrown === 'abort') { + return; + } + const response = event instanceof CustomEvent && event.detail ? event.detail diff --git a/resources/js/upload-client.test.ts b/resources/js/upload-client.test.ts new file mode 100644 index 00000000000..d83dc39d8c6 --- /dev/null +++ b/resources/js/upload-client.test.ts @@ -0,0 +1,324 @@ +import {Blob as NodeBlob, File as NodeFile} from 'node:buffer'; +import {afterEach, beforeEach, expect, it, vi} from 'vitest'; +import {AssetUpload, UploadError} from './upload-client'; + +const control = vi.fn(); +const sent: Blob[] = []; +const transfers: { + url: string; + method: string; + headers: Record; +}[] = []; +let statuses: number[]; +let holdTransfers: boolean; +let partRequest: {url: string; method: string; headers: Record}; + +class Transfer { + upload = new EventTarget(); + status = 200; + statusText = ''; + readyState = 0; + response: unknown = null; + responseText = ''; + onload = () => {}; + onloadend = () => {}; + onabort = () => {}; + onerror = () => {}; + ontimeout = () => {}; + private url = ''; + private method = ''; + private headers: Record = {}; + private aborted = false; + open(method: string, url: string) { + this.method = method; + this.url = url; + this.headers = {}; + } + setRequestHeader(name: string, value: string) { + this.headers[name] = value; + } + getAllResponseHeaders = () => ''; + abort() { + this.aborted = true; + this.onabort(); + this.onloadend(); + } + send(bytes: Blob | string | null) { + const respond = async () => { + if (bytes instanceof Blob) { + sent.push(bytes); + transfers.push({ + url: this.url, + method: this.method, + headers: this.headers, + }); + if (holdTransfers) { + return; + } + this.upload.dispatchEvent( + new ProgressEvent('progress', { + lengthComputable: true, + loaded: 1, + total: bytes.size, + }) + ); + this.status = statuses.shift() ?? 200; + if (this.status === 0) { + this.onerror(); + return; + } + if (this.status === -1) { + this.ontimeout(); + return; + } + } else { + const response = await control(this.url, { + method: this.method, + headers: this.headers, + body: bytes ?? undefined, + }); + this.status = response.status; + this.responseText = await response.text(); + } + this.readyState = 4; + if (!this.aborted) { + this.onload(); + this.onloadend(); + } + }; + void respond(); + } +} + +beforeEach(() => { + sent.length = 0; + transfers.length = 0; + holdTransfers = false; + statuses = []; + partRequest = { + url: 'https://storage.example/part', + method: 'PUT', + headers: {}, + }; + control.mockReset(); + control.mockImplementation(async (url: string) => { + const body = + url === '/start' + ? { + id: 'one', + chunkSize: 3, + partCount: 2, + urls: {part: '/part', complete: '/complete', cancel: '/cancel'}, + } + : url === '/part' + ? partRequest + : {assetId: 42}; + return new Response(JSON.stringify(body), {status: 200}); + }); + vi.stubGlobal('Blob', NodeBlob); + vi.stubGlobal('File', NodeFile); + vi.stubGlobal('XMLHttpRequest', Transfer); +}); + +afterEach(() => { + document.cookie = 'XSRF-TOKEN=; Max-Age=0; path=/'; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +it.each([ + { + url: 'https://storage.example/part', + method: 'PUT', + headers: {'X-Storage': 'signed'}, + }, + {url: '/chunk', method: 'POST', headers: {'X-CSRF-TOKEN': 'local-token'}}, +])( + 'uploads bytes via $method and reports the saved asset', + async (destination) => { + partRequest = destination; + const progress = vi.fn(); + const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + url: '/start', + csrfToken: 'token', + onProgress: progress, + }); + + await expect(upload.upload()).resolves.toEqual({assetId: 42}); + expect(await Promise.all(sent.map((part) => part.text()))).toEqual([ + 'abc', + 'def', + ]); + expect(control).toHaveBeenCalledWith( + '/start', + expect.objectContaining({ + headers: expect.objectContaining({'X-CSRF-TOKEN': 'token'}), + }) + ); + expect(transfers).toEqual([destination, destination]); + expect(progress).toHaveBeenCalledWith(1, 6); + expect(progress).toHaveBeenCalledWith(4, 6); + expect(progress).toHaveBeenLastCalledWith(6, 6); + await expect(upload.upload()).resolves.toEqual({assetId: 42}); + expect(sent).toHaveLength(2); + } +); + +it('retains acknowledged parts for manual retry', async () => { + statuses = [200, 500]; + const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + url: '/start', + retries: 0, + }); + await expect(upload.upload()).rejects.toBeInstanceOf(UploadError); + expect(upload.state).toBe('failed'); + + await expect(upload.upload()).resolves.toEqual({assetId: 42}); + expect(await Promise.all(sent.map((part) => part.text()))).toEqual([ + 'abc', + 'def', + 'def', + ]); + expect(control.mock.calls.filter(([url]) => url === '/start')).toHaveLength( + 1 + ); +}); + +it('stops after the configured number of automatic retries', async () => { + vi.useFakeTimers(); + statuses = [503, 503, 200]; + const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + url: '/start', + retries: 1, + }); + const result = expect(upload.upload()).rejects.toMatchObject({status: 503}); + await vi.runAllTimersAsync(); + await result; + + expect(sent).toHaveLength(2); + expect(upload.state).toBe('failed'); +}); + +it.each([403, 0, -1])( + 'refreshes signed URLs after transfer failure %s without leaking application headers', + async (status) => { + vi.useFakeTimers(); + statuses = [status, 200, 200]; + document.cookie = 'XSRF-TOKEN=application-csrf; path=/'; + const implementation = control.getMockImplementation()!; + let signature = 0; + control.mockImplementation(async (url: string) => { + if (url === '/part') { + return new Response( + JSON.stringify({ + url: `https://storage.example/part?signature=${++signature}`, + method: 'PUT', + headers: {'X-Storage': 'signed'}, + }) + ); + } + return implementation(url); + }); + const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + url: '/start', + headers: {Authorization: 'application-token'}, + }); + const result = upload.upload(); + await vi.runAllTimersAsync(); + await expect(result).resolves.toEqual({assetId: 42}); + expect(transfers.map(({url}) => url)).toEqual([ + 'https://storage.example/part?signature=1', + 'https://storage.example/part?signature=2', + 'https://storage.example/part?signature=3', + ]); + for (const transfer of transfers) { + expect(transfer.method).toBe('PUT'); + expect(transfer.headers).toEqual({'X-Storage': 'signed'}); + } + } +); + +it('stops retrying when permission to sign another part is revoked', async () => { + statuses = [403]; + const implementation = control.getMockImplementation()!; + let requests = 0; + control.mockImplementation(async (url: string) => { + if (url === '/part' && ++requests > 1) { + return new Response(JSON.stringify({message: 'Permission revoked'}), { + status: 403, + }); + } + return implementation(url); + }); + const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + url: '/start', + }); + + await expect(upload.upload()).rejects.toThrow('Permission revoked'); + expect(sent).toHaveLength(1); + expect(upload.state).toBe('failed'); +}); + +it.each(['transfer', 'retry delay'])( + 'cancels during %s and cleans up its session', + async (phase) => { + vi.useFakeTimers(); + holdTransfers = phase === 'transfer'; + statuses = [503]; + const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + url: '/start', + }); + const result = expect(upload.upload()).rejects.toMatchObject({ + name: 'AbortError', + }); + await vi.waitFor(() => expect(sent).toHaveLength(1)); + + await upload.cancel(); + await result; + await vi.runAllTimersAsync(); + expect(control).toHaveBeenCalledWith( + '/cancel', + expect.objectContaining({method: 'DELETE'}) + ); + expect(upload.state).toBe('canceled'); + expect(sent).toHaveLength(1); + } +); + +it('cancels a failed session and prevents another transfer', async () => { + statuses = [500]; + const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + url: '/start', + retries: 0, + }); + await expect(upload.upload()).rejects.toBeInstanceOf(UploadError); + await upload.cancel(); + expect(control).toHaveBeenCalledWith( + '/cancel', + expect.objectContaining({method: 'DELETE'}) + ); + await expect(upload.upload()).rejects.toThrow(); + expect(upload.state).toBe('canceled'); + expect(sent).toHaveLength(1); +}); + +it('retries finalization without sending the file again', async () => { + const implementation = control.getMockImplementation()!; + let fail = true; + control.mockImplementation(async (url: string) => { + if (url === '/complete' && fail) { + fail = false; + return new Response(JSON.stringify({message: 'Unavailable'}), { + status: 503, + }); + } + return implementation(url); + }); + const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + url: '/start', + retries: 0, + }); + await expect(upload.upload()).rejects.toThrow('Unavailable'); + await expect(upload.upload()).resolves.toEqual({assetId: 42}); + expect(sent).toHaveLength(2); +}); diff --git a/resources/js/upload-client.ts b/resources/js/upload-client.ts new file mode 100644 index 00000000000..130eda82b4a --- /dev/null +++ b/resources/js/upload-client.ts @@ -0,0 +1,309 @@ +import axios, {type AxiosRequestConfig, type AxiosResponse} from 'axios'; + +const client = axios.create({adapter: 'xhr'}); + +export type UploadSession = CraftCms.Cms.Asset.Data.UploadSessionData; +export type UploadPartRequest = CraftCms.Cms.Asset.Data.UploadPartRequest; +export type UploadResult = Omit; + +export type UploadState = + | 'ready' + | 'uploading' + | 'completing' + | 'completed' + | 'failed' + | 'canceled'; + +export interface UploadOptions { + url: string; + parameters?: Record; + csrfToken?: string; + headers?: Record; + retries?: 0 | 1 | 2 | 3; + timeout?: number; + onProgress?: (loaded: number, total: number) => void; + onStateChange?: (state: UploadState) => void; +} + +export class UploadError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly data: Record = {} + ) { + super(message); + this.name = 'UploadError'; + } +} + +/** Uploads Craft asset sessions, retaining completed parts for same-page retries. */ +export class AssetUpload { + state: UploadState = 'ready'; + private session: UploadSession | null = null; + private completedParts = 0; + private result: UploadResult | null = null; + private running: Promise | null = null; + private controller = new AbortController(); + + constructor( + public readonly file: File, + private readonly options: UploadOptions + ) {} + + upload(): Promise { + if (this.running) { + return this.running; + } + + this.running = this.run().finally(() => { + this.running = null; + }); + + return this.running; + } + + async cancel(): Promise { + if (this.state === 'completed') { + return; + } + + if (this.state === 'completing') { + throw new UploadError( + 'The asset is being saved and can no longer be canceled.', + 409 + ); + } + + this.setState('canceled'); + this.controller.abort(); + + if (this.session) { + await this.json(this.session.urls.cancel, 'DELETE', undefined, { + signal: null, + }); + } + } + + private async run(): Promise { + this.controller.signal.throwIfAborted(); + + if (this.result) { + return this.result; + } + + this.setState('uploading'); + + try { + if (!this.session) { + this.session = await this.json( + this.options.url, + 'POST', + { + ...this.options.parameters, + filename: this.file.name, + size: this.file.size, + }, + {retry: false} + ); + + if ( + !Number.isSafeInteger(this.session.chunkSize) || + this.session.chunkSize < 1 || + this.session.partCount !== + Math.max(1, Math.ceil(this.file.size / this.session.chunkSize)) + ) { + throw new UploadError( + 'The server returned an invalid upload session.', + 502 + ); + } + } + + const session = this.session; + + for ( + let part = this.completedParts + 1; + part <= session.partCount; + part++ + ) { + const offset = (part - 1) * session.chunkSize; + const bytes = this.file.slice(offset, offset + session.chunkSize); + await this.request( + async () => { + const request = await this.json( + session.urls.part, + 'POST', + {part} + ); + + return { + ...request, + data: bytes, + headers: { + Accept: false, + 'Content-Type': false, + ...request.headers, + }, + withXSRFToken: false, + withCredentials: false, + onUploadProgress: ({loaded}) => { + this.options.onProgress?.( + offset + Math.min(loaded, bytes.size), + this.file.size + ); + }, + }; + }, + {retryForbidden: true} + ); + + this.completedParts = part; + this.options.onProgress?.(offset + bytes.size, this.file.size); + } + + this.setState('completing'); + this.result = await this.json( + session.urls.complete, + 'POST' + ); + this.setState('completed'); + + return this.result; + } catch (error) { + if (!this.controller.signal.aborted) { + this.setState('failed'); + } + + throw uploadError(error); + } + } + + private async request( + prepare: () => AxiosRequestConfig | Promise, + { + retry = true, + retryForbidden = false, + signal = this.controller.signal, + }: { + retry?: boolean; + retryForbidden?: boolean; + signal?: AbortSignal | null; + } = {} + ): Promise> { + const retries = retry ? (this.options.retries ?? 3) : 0; + + for (let attempt = 0; ; attempt++) { + signal?.throwIfAborted(); + const config = await prepare(); + + try { + return await client.request({ + ...config, + signal: signal ?? undefined, + timeout: this.options.timeout ?? 120_000, + }); + } catch (error) { + const failure = uploadError(error); + if ( + attempt >= retries || + !(failure instanceof UploadError) || + !( + retryableStatus(failure.status) || + (retryForbidden && failure.status === 403) + ) + ) { + throw failure; + } + + await delay(300 * 2 ** attempt, signal); + } + } + } + + private async json( + url: string, + method: string, + data?: unknown, + { + retry = true, + signal = this.controller.signal, + }: {retry?: boolean; signal?: AbortSignal | null} = {} + ): Promise { + if (new URL(url, location.href).origin !== location.origin) { + throw new UploadError( + 'Upload control requests must use the current origin.', + 400 + ); + } + + const response = await this.request( + () => ({ + url, + method, + data, + responseType: 'json', + headers: { + Accept: 'application/json', + ...this.options.headers, + ...(this.options.csrfToken + ? {'X-CSRF-TOKEN': this.options.csrfToken} + : {}), + }, + }), + {retry, signal} + ); + + return response.data; + } + + private setState(state: UploadState): void { + this.state = state; + this.options.onStateChange?.(state); + } +} + +function retryableStatus(status: number): boolean { + return [0, 408, 429].includes(status) || status >= 500; +} + +function uploadError(error: unknown): unknown { + if (axios.isCancel(error)) { + return new DOMException('Aborted', 'AbortError'); + } + + if (axios.isAxiosError(error)) { + const response = error.response?.data; + const data = response && typeof response === 'object' ? response : {}; + return new UploadError( + data.message ?? + (error.code === 'ECONNABORTED' + ? 'The upload request timed out.' + : 'The upload request failed. Check the connection and storage configuration.'), + error.response?.status ?? 0, + data + ); + } + + return error; +} + +function delay( + milliseconds: number, + signal: AbortSignal | null +): Promise { + signal?.throwIfAborted(); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', abort); + resolve(); + }, milliseconds); + + function abort() { + clearTimeout(timer); + reject(signal?.reason); + } + + signal?.addEventListener('abort', abort, {once: true}); + }); +} diff --git a/resources/js/uploads.ts b/resources/js/uploads.ts new file mode 100644 index 00000000000..3aeef66001d --- /dev/null +++ b/resources/js/uploads.ts @@ -0,0 +1,15 @@ +import {AssetUpload, UploadError} from './upload-client'; + +export {AssetUpload, UploadError}; +export type {UploadOptions, UploadResult, UploadState} from './upload-client'; + +declare global { + interface Window { + CraftUploads: { + AssetUpload: typeof AssetUpload; + UploadError: typeof UploadError; + }; + } +} + +window.CraftUploads = {AssetUpload, UploadError}; diff --git a/routes/actions.php b/routes/actions.php index c7fdc5b3da8..9feeae4a558 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -19,6 +19,7 @@ use CraftCms\Cms\Http\Controllers\Assets\PreviewController as AssetsPreviewController; use CraftCms\Cms\Http\Controllers\Assets\TransformController; use CraftCms\Cms\Http\Controllers\Assets\UploadController as AssetsUploadController; +use CraftCms\Cms\Http\Controllers\Assets\UploadSessionController; use CraftCms\Cms\Http\Controllers\Auth\LoginController; use CraftCms\Cms\Http\Controllers\Auth\PasskeyController; use CraftCms\Cms\Http\Controllers\Auth\SessionInfoController; @@ -112,6 +113,16 @@ */ foreach ($sharedActionRouteGroups as [$prefix, $middleware]) { Route::prefix($prefix)->middleware($middleware)->group(function () use ($middleware) { + Route::prefix('assets/uploads') + ->name(in_array('craft.cp', $middleware, true) ? 'craft.cp.uploads.' : 'craft.uploads.') + ->group(function () { + Route::post('', [UploadSessionController::class, 'store'])->middleware('throttle:60,1')->name('store'); + Route::post('{upload}/parts', [UploadSessionController::class, 'part'])->whereUuid('upload')->name('part'); + Route::post('{upload}/parts/{part}', [UploadSessionController::class, 'chunk'])->whereUuid('upload')->whereNumber('part')->name('chunk'); + Route::post('{upload}/complete', [UploadSessionController::class, 'complete'])->whereUuid('upload')->name('complete'); + Route::delete('{upload}', [UploadSessionController::class, 'destroy'])->whereUuid('upload')->name('destroy'); + }); + // App Route::allowDuringMaintenance()->get('app/health-check', HealthCheckController::class); diff --git a/src/Asset/AssetUploadHandler.php b/src/Asset/AssetUploadHandler.php new file mode 100644 index 00000000000..599d221dca6 --- /dev/null +++ b/src/Asset/AssetUploadHandler.php @@ -0,0 +1,226 @@ + $parameters + * @return array{VolumeFolder, ElementConditionInterface|null} + */ + public function resolveTarget(array $parameters, bool $authorizedGuest = false): array + { + $folderId = (int) ($parameters['folderId'] ?? 0) ?: null; + $fieldId = (int) ($parameters['fieldId'] ?? 0) ?: null; + + abort_if(! $folderId && ! $fieldId, 400, 'No target destination provided for uploading'); + + if (empty($folderId)) { + /** @var AssetsField|null $field */ + $field = $this->fields->getFieldById($fieldId); + + abort_if(! $field instanceof AssetsField, 400, 'The field provided is not an Assets field'); + + if ($elementId = (int) ($parameters['elementId'] ?? 0)) { + $siteId = (int) ($parameters['siteId'] ?? 0) ?: null; + $element = $this->elements->getElementById($elementId, null, $siteId); + } else { + $element = null; + } + $folderId = $field->resolveDynamicPathToFolderId($element); + + $selectionCondition = $field->getSelectionCondition(); + if ($selectionCondition instanceof ElementCondition) { + $selectionCondition->referenceElement = $element; + } + } else { + $selectionCondition = null; + } + + abort_if(empty($folderId), 400, 'The target destination provided for uploading is not valid'); + + $folder = $this->folders->findFolder(['id' => $folderId]); + + abort_if(! $folder, 400, 'The target folder provided for uploading is not valid'); + + if (! $authorizedGuest) { + Gate::authorize('uploadAsset', $folder); + } + + return [$folder, $selectionCondition]; + } + + /** @param array $parameters */ + public function store( + array $parameters, + string $originalName, + string $mimeType, + ?string $tempPath = null, + ?UploadedAssetFile $source = null, + bool $authorizedGuest = false, + ?int $uploaderId = null, + ): UploadResult { + [$folder, $selectionCondition] = $this->resolveTarget($parameters, $authorizedGuest); + + $filename = AssetsHelper::prepareAssetName($originalName); + + if ($selectionCondition) { + $tempFolder = $this->assets->getUserTemporaryUploadFolder(); + + if ($folder->id !== $tempFolder->id) { + // upload to the user's temp folder initially, with a temp name + $originalFolder = $folder; + $originalFilename = $filename; + $folder = $tempFolder; + $filename = uniqid('asset', true).'.'.pathinfo($filename, PATHINFO_EXTENSION); + } + } + + $asset = new Asset; + $asset->tempFilePath = $tempPath; + $asset->uploadSource = $source; + if ($authorizedGuest) { + $asset->sanitizeOnUpload = true; + } + $asset->setFilename($filename); + $asset->setMimeType($tempPath ? (File::getMimeType($tempPath, checkExtension: false) ?? $mimeType) : $mimeType); + $asset->newFolderId = $folder->id; + $asset->setVolumeId($folder->volumeId); + $asset->uploaderId = $uploaderId; + $asset->avoidFilenameConflicts = true; + + if (isset($originalFilename)) { + $asset->title = AssetsHelper::filename2Title(pathinfo($originalFilename, PATHINFO_FILENAME)); + } + + $asset->ruleset->useScenario(AssetRules::SCENARIO_CREATE); + $result = $this->elements->saveElement($asset); + + // In case of error, let user know about it. + if (! $result) { + return $this->failure($asset); + } + + if ($selectionCondition) { + if (! $selectionCondition->matchElement($asset)) { + // delete and reject it + $this->elements->deleteElement($asset, true); + + return new UploadResult(['message' => t('{filename} isn’t selectable for this field.', [ + 'filename' => $originalName, + ])], 400); + } + + if (isset($originalFilename, $originalFolder)) { + // move it into the original target destination + $asset->newFilename = $originalFilename; + $asset->newFolderId = $originalFolder->id; + $asset->ruleset->useScenario(AssetRules::SCENARIO_MOVE); + + if (! $this->elements->saveElement($asset)) { + return $this->failure($asset); + } + } + } + + // try to get uploaded asset's URL + $url = null; + try { + $url = $asset->getUrl(); + } catch (Throwable) { + // do nothing + } + + if ($asset->conflictingFilename !== null) { + $conflictingAsset = Asset::findOne(['folderId' => $folder->id, 'filename' => $asset->conflictingFilename]); + + return new UploadResult([ + 'conflict' => t('A file with the name “{filename}” already exists.', ['filename' => $asset->conflictingFilename]), + 'assetId' => $asset->id, + 'filename' => $asset->conflictingFilename, + 'conflictingAssetId' => $conflictingAsset->id ?? null, + 'suggestedFilename' => $asset->suggestedFilename, + 'conflictingAssetUrl' => ($conflictingAsset && $conflictingAsset->getVolume()->sourceHasUrls()) ? $conflictingAsset->getUrl() : null, + 'url' => $url, + ]); + } + + return new UploadResult([ + 'filename' => $asset->getFilename(), + 'assetId' => $asset->id, + 'url' => $url, + ]); + } + + public function replace(int $assetId, UploadedAssetFile $file): UploadResult + { + $asset = $this->assets->getAssetById($assetId); + abort_unless($asset !== null, 404, 'Asset not found.'); + Gate::authorize('replaceFile', $asset); + + $asset->uploadSource = $file; + $this->assets->replaceAssetFile($asset, $file->localPath(), $file->filename, $file->mimeType()); + + if ($asset->errors()->isNotEmpty()) { + return $this->failure($asset); + } + + return $this->replacementResult($asset, $asset->id); + } + + public function replacementResult(Asset $resultingAsset, int $assetId): UploadResult + { + return new UploadResult([ + 'assetId' => $assetId, + 'filename' => $resultingAsset->getFilename(), + 'formattedSize' => $resultingAsset->getFormattedSize(0), + 'formattedSizeInBytes' => $resultingAsset->getFormattedSizeInBytes(false), + 'formattedDateUpdated' => I18N::getFormatter()->asDatetime( + $resultingAsset->dateUpdated, + Formatter::FORMAT_WIDTH_SHORT, + true, + ), + 'dimensions' => $resultingAsset->getDimensions(), + 'updatedTimestamp' => $resultingAsset->dateUpdated->getTimestamp(), + 'resultingUrl' => $resultingAsset->getUrl(), + ]); + } + + private function failure(Asset $asset): UploadResult + { + return new UploadResult([ + 'modelName' => 'model', + 'modelClass' => $asset::class, + 'model' => Arr::toArray($asset), + 'errors' => $asset->errors()->getMessages(), + ], 400); + } +} diff --git a/src/Asset/AssetUploaders.php b/src/Asset/AssetUploaders.php new file mode 100644 index 00000000000..17fc133f24d --- /dev/null +++ b/src/Asset/AssetUploaders.php @@ -0,0 +1,48 @@ +assetUploader !== null) { + return Cms::config()->assetUploader; + } + + $disk = $this->container->make(Filesystems::class)->disk( + Cms::config()->getTempAssetUploadFs(), + ); + + return $disk instanceof AwsS3V3Adapter ? 's3' : 'chunked'; + } + + #[Override] + public function driver($driver = null): AssetUploader + { + return parent::driver($driver); + } + + protected function createChunkedDriver(): AssetUploader + { + return $this->container->make(ChunkedUploader::class); + } + + protected function createS3Driver(): AssetUploader + { + return $this->container->make(S3Uploader::class); + } +} diff --git a/src/Asset/AssetUploads.php b/src/Asset/AssetUploads.php new file mode 100644 index 00000000000..d249f8cbb24 --- /dev/null +++ b/src/Asset/AssetUploads.php @@ -0,0 +1,270 @@ +}): bool|null */ + private ?Closure $guestAuthorizer = null; + + public function __construct( + private readonly AssetUploaders $uploaders, + private readonly Assets $assets, + private readonly AssetUploadHandler $uploads, + ) {} + + /** @param Closure(Request, array{folderId: int|null, filename: string, size: int, context: array}): bool $authorizer */ + public function allowGuestUploadsUsing(Closure $authorizer): void + { + $this->guestAuthorizer = $authorizer; + } + + public function start(UploadRequest $request): UploadSession + { + abort_if(! $request->user() && $this->guestAuthorizer === null, 401); + + $data = $request->validated(); + + $parameters = Arr::except($data, ['filename', 'size']); + $parameters['operation'] ??= 'upload'; + $parameters = $this->authorize($request, $parameters, $data['filename'], (int) $data['size']); + + if (Cms::config()->uploadSessionDuration < 1) { + throw new RuntimeException('uploadSessionDuration must be greater than zero.'); + } + + $diskReference = Cms::config()->getTempAssetUploadFs(); + $name = $this->uploaders->getDefaultDriver(); + $uploader = $this->uploaders->driver($name); + + $session = UploadSession::create([ + 'id' => (string) Str::uuid(), + 'owner' => $this->owner($request), + 'uploader' => $name, + 'disk' => $diskReference, + 'filename' => $data['filename'], + 'size' => (int) $data['size'], + 'parameters' => $parameters, + 'state' => [], + 'expiresAt' => now()->addSeconds(Cms::config()->uploadSessionDuration), + ]); + + $uploader->start($session); + abort_unless($session->chunkSize > 0, 500, 'The uploader returned an invalid chunk size.'); + $session->save(); + + return $session; + } + + /** @return array */ + public function part(Request $request, string $id, int $part): array + { + return $this->withSession($request, $id, function (UploadSession $session, AssetUploader $uploader) use ($part) { + abort_if($session->result !== null, 409, 'This upload has already completed.'); + $session->partSize($part); + + return $uploader->partRequest($session, $part)->toArray(); + }); + } + + public function receive(Request $request, string $id, int $part): void + { + $this->withSession($request, $id, function (UploadSession $session, AssetUploader $uploader) use ($request, $part) { + abort_unless($uploader instanceof ChunkedUploader, 409, 'This uploader does not receive bytes through PHP.'); + abort_if($session->result !== null, 409, 'This upload has already completed.'); + + $stream = $request->getContent(asResource: true); + + try { + $uploader->receive($session, $part, $stream); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + }); + } + + public function complete(Request $request, string $id): UploadResult + { + $result = $this->withSession($request, $id, function (UploadSession $session, AssetUploader $uploader) use ($request) { + if ($session->result !== null) { + return new UploadResult(...$session->result); + } + + $file = $uploader->complete($session); + + try { + abort_unless($file->size() === $session->size, 422, 'The uploaded file has an incorrect size.'); + $result = $session->parameters['operation'] === 'replace' + ? $this->uploads->replace((int) $session->parameters['assetId'], $file) + : $this->uploads->store( + $session->parameters, + $file->filename, + $file->mimeType(), + source: $file, + authorizedGuest: ! $request->user(), + uploaderId: $request->craftUser()?->getCraftUserId(), + ); + + $session->result = $result->toArray(); + + return $result; + } finally { + $file->release(); + } + }); + + try { + $this->withSession($request, $id, function (UploadSession $session, AssetUploader $uploader) { + $this->clean($session, $uploader); + }, authorize: false); + } catch (Throwable $exception) { + report($exception); + } + + return $result; + } + + public function cancel(Request $request, string $id): void + { + $this->withSession($request, $id, function (UploadSession $session, AssetUploader $uploader) { + $this->clean($session, $uploader); + $session->delete(); + }, authorize: false, allowExpired: true); + } + + /** @return array{removed: int, failed: int} */ + public function cleanupExpired(): array + { + $removed = $failed = 0; + + foreach (UploadSession::query()->where('expiresAt', '<=', now())->lazyById(100) as $expired) { + try { + $removed += DB::transaction(function () use ($expired) { + $session = UploadSession::query()->lockForUpdate()->find($expired->id); + + if ($session === null || $session->expiresAt->isFuture()) { + return 0; + } + + $this->clean($session, $this->uploader($session)); + $session->delete(); + + return 1; + }); + } catch (Throwable $exception) { + report($exception); + $failed++; + } + } + + return ['removed' => $removed, 'failed' => $failed]; + } + + /** + * @template T + * + * @param Closure(UploadSession, AssetUploader): T $callback + * @return T + */ + private function withSession(Request $request, string $id, Closure $callback, bool $authorize = true, bool $allowExpired = false): mixed + { + return DB::transaction(function () use ($request, $id, $callback, $authorize, $allowExpired) { + $session = UploadSession::query()->lockForUpdate()->findOrFail($id); + abort_unless(hash_equals($session->owner, $this->owner($request)), 404); + abort_if(! $allowExpired && $session->expiresAt->isPast(), 410, 'The upload session has expired.'); + + if ($authorize) { + $parameters = $this->authorize($request, $session->parameters, $session->filename, $session->size); + abort_unless(Arr::sortRecursive($parameters) === Arr::sortRecursive($session->parameters), 403, 'The upload destination is no longer authorized.'); + } + + $result = $callback($session, $this->uploader($session)); + + if ($session->exists) { + $session->expiresAt = now()->addSeconds(Cms::config()->uploadSessionDuration); + $session->save(); + } + + return $result; + }); + } + + /** @param array $parameters + * @return array + */ + private function authorize(Request $request, array $parameters, string $filename, int $size): array + { + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + abort_unless(in_array($extension, Cms::config()->allowedFileExtensions, true), 422, 'This file extension is not allowed.'); + abort_if($size > AssetsHelper::getMaxAssetUploadSize(), 422, 'The file exceeds the maximum upload size.'); + + if (! $request->user()) { + abort_if($this->guestAuthorizer === null, 401); + abort_unless($parameters['operation'] === 'upload', 403, 'Guest uploads cannot replace assets.'); + + $parameters = [ + 'operation' => 'upload', + 'folderId' => isset($parameters['folderId']) ? (int) $parameters['folderId'] : null, + 'context' => $parameters['context'] ?? [], + ]; + + abort_unless(($this->guestAuthorizer)($request, [ + 'folderId' => $parameters['folderId'], + 'filename' => $filename, + 'size' => $size, + 'context' => $parameters['context'], + ]) === true, 403); + } + + if ($parameters['operation'] === 'replace') { + $asset = $this->assets->getAssetById((int) ($parameters['assetId'] ?? 0)); + abort_unless($asset !== null, 404, 'Asset not found.'); + Gate::authorize('replaceFile', $asset); + } else { + $this->uploads->resolveTarget($parameters, authorizedGuest: ! $request->user()); + } + + return $parameters; + } + + private function owner(Request $request): string + { + return $request->user() + ? 'user:'.$request->user()->getAuthIdentifier() + : 'guest:'.hash('sha256', $request->session()->getId()); + } + + private function uploader(UploadSession $session): AssetUploader + { + return $this->uploaders->driver($session->uploader); + } + + private function clean(UploadSession $session, AssetUploader $uploader): void + { + if (! ($session->state['cleaned'] ?? false)) { + $uploader->abort($session); + $session->state = [...$session->state, 'cleaned' => true]; + } + } +} diff --git a/src/Asset/Assets.php b/src/Asset/Assets.php index 4b1b659aa67..4b857601920 100644 --- a/src/Asset/Assets.php +++ b/src/Asset/Assets.php @@ -356,9 +356,7 @@ public function getTempAssetUploadFs(): FsInterface */ public function getTempAssetUploadDisk(): FilesystemAdapter { - $handle = Env::parse(Cms::config()->tempAssetUploadFs); - - return Filesystems::disk($handle ?: 'disk:'.FilesystemsService::TEMP_ASSET_DISK); + return Filesystems::disk(Cms::config()->getTempAssetUploadFs()); } public function createTempAssetQuery(): AssetQuery diff --git a/src/Asset/AssetsHelper.php b/src/Asset/AssetsHelper.php index f1bc1c6bd6a..94e3efc7130 100644 --- a/src/Asset/AssetsHelper.php +++ b/src/Asset/AssetsHelper.php @@ -399,6 +399,14 @@ public static function getMaxUploadSize(): float|int return $uploadInBytes; } + /** The asset limit, independent of the size of individual upload requests. */ + public static function getMaxAssetUploadSize(): int + { + $limit = PHP::sizeToBytes(Cms::config()->maxUploadFileSize); + + return $limit > 0 ? $limit : PHP_INT_MAX; + } + /** * Returns scaled width & height values for a maximum container size. * diff --git a/src/Asset/Contracts/AssetUploader.php b/src/Asset/Contracts/AssetUploader.php new file mode 100644 index 00000000000..8f42e012ddb --- /dev/null +++ b/src/Asset/Contracts/AssetUploader.php @@ -0,0 +1,22 @@ + */ +readonly class UploadPartRequest implements Arrayable +{ + /** @param array $headers */ + public function __construct( + public string $url, + public string $method = 'PUT', + public array $headers = [], + ) {} + + public function toArray(): array + { + return get_object_vars($this); + } +} diff --git a/src/Asset/Data/UploadResult.php b/src/Asset/Data/UploadResult.php new file mode 100644 index 00000000000..0c1c8cbe48b --- /dev/null +++ b/src/Asset/Data/UploadResult.php @@ -0,0 +1,74 @@ + */ +#[Optional] +class UploadResult implements Arrayable +{ + public private(set) ?int $assetId; + + public private(set) string $filename; + + public private(set) ?string $url; + + public private(set) string $conflict; + + public private(set) ?int $conflictingAssetId; + + public private(set) ?string $suggestedFilename; + + public private(set) ?string $conflictingAssetUrl; + + public private(set) string $formattedSize; + + public private(set) string $formattedSizeInBytes; + + public private(set) string $formattedDateUpdated; + + public private(set) ?string $dimensions; + + public private(set) int $updatedTimestamp; + + public private(set) ?string $resultingUrl; + + public private(set) string $message; + + public private(set) string $modelName; + + /** @var class-string */ + public private(set) string $modelClass; + + /** @var array */ + public private(set) array $model; + + /** @var array> */ + public private(set) array $errors; + + /** @param array $data */ + public function __construct(array $data, public private(set) int $status = 200) + { + foreach ($data as $name => $value) { + $this->{$name} = $value; + } + } + + /** @return array */ + public function payload(): array + { + $data = get_object_vars($this); + unset($data['status']); + + return $data; + } + + public function toArray(): array + { + return ['data' => $this->payload(), 'status' => $this->status]; + } +} diff --git a/src/Asset/Data/UploadSessionData.php b/src/Asset/Data/UploadSessionData.php new file mode 100644 index 00000000000..1cd3ef834ed --- /dev/null +++ b/src/Asset/Data/UploadSessionData.php @@ -0,0 +1,24 @@ + */ +readonly class UploadSessionData implements Arrayable +{ + /** @param array{part: string, complete: string, cancel: string} $urls */ + public function __construct( + public string $id, + public int $chunkSize, + public int $partCount, + public array $urls, + ) {} + + public function toArray(): array + { + return get_object_vars($this); + } +} diff --git a/src/Asset/Data/UploadedAssetFile.php b/src/Asset/Data/UploadedAssetFile.php new file mode 100644 index 00000000000..5e241d1317b --- /dev/null +++ b/src/Asset/Data/UploadedAssetFile.php @@ -0,0 +1,152 @@ +disk->size($this->path); + } + + public function mimeType(): string + { + if ($this->disk instanceof AwsS3V3Adapter) { + $client = $this->s3Client($this->disk); + $result = $client->getObject([ + 'Bucket' => $this->disk->getConfig()['bucket'], + 'Key' => $this->disk->path($this->path), + 'Range' => 'bytes=0-65535', + ]); + $sample = (string) $result['Body']; + } else { + $stream = $this->openStream(); + + try { + $sample = stream_get_contents($stream, 65536); + } finally { + fclose($stream); + } + } + + if ($sample === false) { + throw new RuntimeException('Unable to inspect the uploaded file.'); + } + + return new finfo(FILEINFO_MIME_TYPE)->buffer($sample) ?: 'application/octet-stream'; + } + + public function localPath(): string + { + if ($this->localPath !== null) { + return $this->localPath; + } + + $path = Path::temp(File::uniqueName($this->filename)); + $target = fopen($path, 'wb'); + + if ($target === false) { + throw new RuntimeException('Unable to create a temporary asset file.'); + } + + $this->localPath = $path; + + try { + $source = $this->openStream(); + + try { + if (stream_copy_to_stream($source, $target) !== $this->size()) { + throw new RuntimeException('Unable to read the complete uploaded file.'); + } + } finally { + fclose($source); + } + } finally { + fclose($target); + } + + $this->localHash = hash_file('sha256', $path) ?: throw new RuntimeException('Unable to checksum the uploaded file.'); + + return $path; + } + + public function storeAs(FilesystemAdapter $destination, string $path, string $mimeType, ?string $processedPath = null): void + { + if ($processedPath === null || ( + $processedPath === $this->localPath && + $this->localHash === hash_file('sha256', $processedPath) + )) { + new MountManager([ + 'source' => $this->disk->getDriver(), + 'destination' => $destination->getDriver(), + ])->copy("source://{$this->path}", "destination://$path", [ + 'mimetype' => $mimeType, + 'visibility' => $destination->getConfig()['visibility'] ?? 'private', + 'MetadataDirective' => 'REPLACE', + ]); + + return; + } + + $stream = fopen($processedPath, 'rb'); + + if (! is_resource($stream)) { + throw new RuntimeException('Unable to open the uploaded file.'); + } + + try { + if (! $destination->writeStream($path, $stream, ['mimetype' => $mimeType])) { + throw new RuntimeException('Unable to store the uploaded asset.'); + } + } finally { + fclose($stream); + } + } + + public function release(): void + { + if ($this->localPath !== null) { + File::delete($this->localPath); + $this->localPath = null; + $this->localHash = null; + } + } + + /** @return resource */ + private function openStream(): mixed + { + $stream = $this->disk->readStream($this->path); + + if (! is_resource($stream)) { + throw new RuntimeException('Unable to read the uploaded file.'); + } + + return $stream; + } + + /** The SDK is an optional dependency supplied by the S3 filesystem adapter. */ + private function s3Client(AwsS3V3Adapter $disk): mixed + { + return $disk->getClient(); + } +} diff --git a/src/Asset/Elements/Asset.php b/src/Asset/Elements/Asset.php index 26c056f1522..f19425ae72c 100644 --- a/src/Asset/Elements/Asset.php +++ b/src/Asset/Elements/Asset.php @@ -21,6 +21,7 @@ use CraftCms\Cms\Asset\Concerns\LegacyConstants; use CraftCms\Cms\Asset\Conditions\AssetCondition; use CraftCms\Cms\Asset\Data\AssetTransformResult; +use CraftCms\Cms\Asset\Data\UploadedAssetFile; use CraftCms\Cms\Asset\Data\Volume; use CraftCms\Cms\Asset\Data\VolumeFolder; use CraftCms\Cms\Asset\Enums\FileKind; @@ -112,6 +113,7 @@ use Illuminate\Support\Facades\Log; use Imagick; use InvalidArgumentException; +use League\Flysystem\MountManager; use League\Flysystem\UnableToDeleteFile; use Override; use RuntimeException; @@ -267,6 +269,9 @@ class Asset extends Element */ public ?string $tempFilePath = null; + /** The staged upload, before it has been promoted into its volume. */ + public ?UploadedAssetFile $uploadSource = null; + /** * @var bool Whether the asset should avoid filename conflicts when saved. */ @@ -1559,8 +1564,11 @@ protected function safeActionMenuItems(): array } }, fileuploadfail: (event, data) => { - const file = data.data.getAll('replaceFile'); - const backupFilename = file[0].name; + if (data?.errorThrown === 'abort') { + return; + } + + const backupFilename = data.files[0].name; const response = event instanceof Event ? event.detail @@ -2949,8 +2957,12 @@ public function beforeSave(bool $isNew): bool $folderId = $this->folderId; } + if ($this->uploadSource !== null && AssetsHelper::getFileKindByExtension($this->_filename) === FileKind::Image->value) { + $this->tempFilePath ??= $this->uploadSource->localPath(); + } + // Fire a 'beforeHandleFile' event if we're going to be doing any file operations in afterSave() - if (isset($this->newLocation) || isset($this->tempFilePath)) { + if (isset($this->newLocation) || isset($this->tempFilePath) || $this->uploadSource !== null) { event(new AssetFileHandling($this, isNew: ! $this->id)); } @@ -3033,7 +3045,7 @@ public function afterSave(bool $isNew): void } // Relocate the file? - if (isset($this->newLocation) || isset($this->tempFilePath)) { + if (isset($this->newLocation) || isset($this->tempFilePath) || $this->uploadSource !== null) { $this->_relocateFile(); } @@ -3287,7 +3299,7 @@ private function _relocateFile(): void $hasNewFolder = $folderId !== $this->folderId; - $tempPath = null; + $tempPath = $this->tempFilePath; $oldFolder = $this->folderId ? Folders::getFolderById($this->folderId) : null; $oldVolume = $oldFolder?->getVolume(); @@ -3298,39 +3310,27 @@ private function _relocateFile(): void $oldPath = $this->folderId ? $this->getPath() : null; $newPath = ($newFolder->path ? rtrim((string) $newFolder->path, '/').'/' : '').$filename; - // Is this just a simple move/rename within the same volume? - if (! isset($this->tempFilePath) && $oldFolder !== null && $oldFolder->volumeId === $newFolder->volumeId) { - if (! $oldVolume->sourceDisk()->move($oldPath, $newPath)) { - throw new FilesystemException("Unable to move $oldPath to $newPath"); - } - } else { - // Get the temp path - if (isset($this->tempFilePath)) { - if (! $this->_validateTempFilePath()) { - Log::info("Prevented saving $this->tempFilePath as an asset. It must be located within a temp directory or the project root (excluding system directories)."); - throw new FileException(t('There was an error relocating the file.')); - } - - $tempPath = $this->tempFilePath; - } else { - if ($oldVolume === null || $oldPath === null) { - throw new FileException(t('There was an error relocating the file.')); - } + $oldDisk = $oldVolume?->sourceDisk(); + $newDisk = $newVolume->sourceDisk(); + $sameFile = $oldDisk !== null && $oldPath !== null && + $oldVolume?->getResolvedFsTarget() === $newVolume->getResolvedFsTarget() && + $oldDisk->path($oldPath) === $newDisk->path($newPath); - $tempFilename = File::uniqueName($filename); - $tempPath = Path::temp($tempFilename); - AssetsHelper::downloadFile($oldVolume->sourceDisk(), $oldPath, $tempPath); - } + if ($tempPath !== null && ! $this->_validateTempFilePath()) { + Log::info("Prevented saving $tempPath as an asset. It must be located within a temp directory or the project root (excluding system directories)."); + throw new FileException(t('There was an error relocating the file.')); + } - // Try to open a file stream + if ($this->uploadSource !== null) { + $this->uploadSource->storeAs($newDisk, $newPath, $this->getMimeType() ?? 'application/octet-stream', $tempPath); + } elseif ($tempPath !== null) { if (($stream = fopen($tempPath, 'rb')) === false) { File::delete($tempPath); throw new FileException(t('Could not open file for streaming at {path}', ['path' => $tempPath])); } - // Upload the file to the new location try { - if (! $newVolume->sourceDisk()->writeStream($newPath, $stream, [ + if (! $newDisk->writeStream($newPath, $stream, [ Filesystem::CONFIG_MIMETYPE => File::getMimeType($tempPath), ])) { throw new FilesystemException("Unable to write stream to path: $newPath"); @@ -3339,20 +3339,31 @@ private function _relocateFile(): void report($exception); throw $exception; } finally { - // If the volume has not already disconnected the stream, clean it up. if (is_resource($stream)) { fclose($stream); } } - - // if we got this far without an exception, it's okay to delete the file from the old volume - if ( - $oldFolder && - ($oldFolder->id !== $newFolder->id || $oldPath !== $newPath) - ) { - // Delete the old file - $oldVolume->sourceDisk()->delete($oldPath); + } elseif (! $sameFile) { + if ($oldDisk === null || $oldPath === null) { + throw new FileException(t('There was an error relocating the file.')); } + + new MountManager([ + 'source' => $oldDisk->getDriver(), + 'destination' => $newDisk->getDriver(), + ])->move("source://$oldPath", "destination://$newPath", [ + 'visibility' => $oldVolume->id === $newVolume->id + ? $oldDisk->getVisibility($oldPath) + : ($newDisk->getConfig()['visibility'] ?? 'private'), + ]); + } + + if ( + ($this->uploadSource !== null || $tempPath !== null) && + $oldDisk !== null && + ! $sameFile + ) { + $oldDisk->delete($oldPath); } if ($this->folderId) { @@ -3367,6 +3378,12 @@ private function _relocateFile(): void $this->_filename = $filename; $this->_volume = $newVolume; + if ($this->uploadSource !== null && $tempPath === null) { + $this->size = $this->uploadSource->size(); + $this->dateModified = Date::createFromTimestampUTC($this->uploadSource->disk->lastModified($this->uploadSource->path)); + $this->_width = $this->_height = null; + } + // If there was a new file involved, update file data. if ($tempPath && file_exists($tempPath)) { $this->kind = AssetsHelper::getFileKindByExtension($filename); @@ -3389,6 +3406,7 @@ private function _relocateFile(): void // Clear out the temp location properties $this->newLocation = null; $this->tempFilePath = null; + $this->uploadSource = null; } /** diff --git a/src/Asset/Models/UploadSession.php b/src/Asset/Models/UploadSession.php new file mode 100644 index 00000000000..8d6f0fd530c --- /dev/null +++ b/src/Asset/Models/UploadSession.php @@ -0,0 +1,68 @@ + $parameters + * @property array $state + * @property array|null $result + * @property Carbon $expiresAt + */ +class UploadSession extends BaseModel +{ + #[\Override] + public $incrementing = false; + + #[\Override] + protected $keyType = 'string'; + + #[\Override] + protected $table = Table::ASSETUPLOADSESSIONS; + + protected function casts(): array + { + return [ + 'size' => 'integer', + 'chunkSize' => 'integer', + 'parameters' => 'array', + 'state' => 'array', + 'result' => 'array', + 'expiresAt' => 'datetime', + ]; + } + + public function prefix(): string + { + return "upload-sessions/{$this->id}"; + } + + public function path(): string + { + return $this->prefix().'/file'; + } + + public function partCount(): int + { + return max(1, (int) ceil($this->size / $this->chunkSize)); + } + + public function partSize(int $part): int + { + abort_unless($part >= 1 && $part <= $this->partCount(), 422, 'Invalid upload part.'); + + return min($this->chunkSize, $this->size - ($part - 1) * $this->chunkSize); + } +} diff --git a/src/Asset/Uploaders/ChunkedUploader.php b/src/Asset/Uploaders/ChunkedUploader.php new file mode 100644 index 00000000000..30efe5df22c --- /dev/null +++ b/src/Asset/Uploaders/ChunkedUploader.php @@ -0,0 +1,125 @@ +chunkSize = min( + Cms::config()->uploadChunkSize, + $requestLimit > 0 ? $requestLimit : PHP_INT_MAX, + ); + + if ($session->chunkSize < 1) { + throw new RuntimeException('uploadChunkSize must be greater than zero.'); + } + } + + public function partRequest(UploadSession $session, int $part): UploadPartRequest + { + $routeName = request()->isCpRequest() + ? 'craft.actions.craft.cp.uploads.chunk' + : 'craft.actions.craft.uploads.chunk'; + + return new UploadPartRequest( + url: route($routeName, ['upload' => $session->id, 'part' => $part]), + method: 'POST', + headers: ['X-CSRF-TOKEN' => csrf_token(), 'Content-Type' => 'application/octet-stream'], + ); + } + + /** @param resource $stream */ + public function receive(UploadSession $session, int $part, mixed $stream): void + { + $disk = $this->filesystems->disk($session->disk); + $expected = $session->partSize($part); + $buffer = tmpfile(); + + if ($buffer === false) { + throw new RuntimeException('Unable to create a temporary upload file.'); + } + + try { + $size = stream_copy_to_stream($stream, $buffer, $expected + 1); + abort_unless($size === $expected, 422, 'The upload part has an incorrect size.'); + rewind($buffer); + + if (! $disk->writeStream($this->partPath($session, $part), $buffer, ['visibility' => 'private'])) { + throw new RuntimeException('Unable to store the upload part.'); + } + } finally { + fclose($buffer); + } + } + + public function complete(UploadSession $session): UploadedAssetFile + { + $disk = $this->filesystems->disk($session->disk); + + if ($disk->exists($session->path()) && $disk->size($session->path()) === $session->size) { + return new UploadedAssetFile($disk, $session->path(), $session->filename); + } + + $buffer = tmpfile(); + + if ($buffer === false) { + throw new RuntimeException('Unable to assemble the uploaded file.'); + } + + try { + for ($part = 1; $part <= $session->partCount(); $part++) { + $path = $this->partPath($session, $part); + abort_unless($disk->exists($path), 422, 'The upload is missing a part.'); + $stream = $disk->readStream($path); + + if (! is_resource($stream)) { + throw new RuntimeException('Unable to read an upload part.'); + } + + try { + $size = stream_copy_to_stream($stream, $buffer, $session->partSize($part) + 1); + abort_unless($size === $session->partSize($part), 422, 'The upload part has an incorrect size.'); + } finally { + fclose($stream); + } + } + + rewind($buffer); + + if (! $disk->writeStream($session->path(), $buffer, ['visibility' => 'private'])) { + throw new RuntimeException('Unable to store the assembled upload.'); + } + } finally { + fclose($buffer); + } + + return new UploadedAssetFile($disk, $session->path(), $session->filename); + } + + public function abort(UploadSession $session): void + { + if (! $this->filesystems->disk($session->disk)->deleteDirectory($session->prefix())) { + throw new RuntimeException('Unable to remove the temporary upload.'); + } + } + + private function partPath(UploadSession $session, int $part): string + { + return $session->prefix()."/parts/$part"; + } +} diff --git a/src/Asset/Uploaders/S3Uploader.php b/src/Asset/Uploaders/S3Uploader.php new file mode 100644 index 00000000000..21d8018dca4 --- /dev/null +++ b/src/Asset/Uploaders/S3Uploader.php @@ -0,0 +1,142 @@ +chunkSize = max(8388608, (int) ceil($session->size / 10000)); + abort_if($session->chunkSize > 5368709120, 422, 'The file exceeds the S3 multipart upload limit.'); + + $options = $this->disk($session)->getConfig()['options'] ?? []; + $encryption = array_intersect_key($options, array_flip([ + 'ServerSideEncryption', 'SSEKMSKeyId', 'BucketKeyEnabled', 'StorageClass', + ])); + $result = $this->client($session)->createMultipartUpload([ + ...$encryption, + ...$this->location($session), + 'ContentType' => 'application/octet-stream', + ]); + + $session->state = ['uploadId' => (string) $result['UploadId']]; + } + + public function partRequest(UploadSession $session, int $part): UploadPartRequest + { + $command = $this->client($session)->getCommand('UploadPart', [ + ...$this->location($session), + 'UploadId' => $session->state['uploadId'], + 'PartNumber' => $part, + 'ContentLength' => $session->partSize($part), + ]); + $request = $this->client($session)->createPresignedRequest($command, '+15 minutes'); + $headers = []; + + foreach ($request->getHeaders() as $name => $values) { + if (! in_array(strtolower($name), ['host', 'content-length'], true)) { + $headers[$name] = implode(', ', $values); + } + } + + return new UploadPartRequest((string) $request->getUri(), 'PUT', $headers); + } + + public function complete(UploadSession $session): UploadedAssetFile + { + $disk = $this->disk($session); + + if (! $disk->exists($session->path())) { + $uploadedParts = []; + $marker = null; + + do { + $result = $this->client($session)->listParts([ + ...$this->location($session), + 'UploadId' => $session->state['uploadId'], + ...($marker === null ? [] : ['PartNumberMarker' => $marker]), + ]); + + foreach ($result['Parts'] ?? [] as $part) { + $number = (int) $part['PartNumber']; + abort_unless((int) $part['Size'] === $session->partSize($number), 422, 'An S3 upload part has an incorrect size.'); + $uploadedParts[] = ['PartNumber' => $number, 'ETag' => $part['ETag']]; + } + + $marker = $result['NextPartNumberMarker'] ?? null; + } while ($result['IsTruncated'] ?? false); + + abort_unless(count($uploadedParts) === $session->partCount(), 422, 'The S3 upload is missing a part.'); + + foreach ($uploadedParts as $index => $part) { + abort_unless($part['PartNumber'] === $index + 1, 422, 'The S3 upload is missing a part.'); + } + + $this->client($session)->completeMultipartUpload([ + ...$this->location($session), + 'UploadId' => $session->state['uploadId'], + 'MultipartUpload' => ['Parts' => $uploadedParts], + ]); + } + + return new UploadedAssetFile($disk, $session->path(), $session->filename); + } + + public function abort(UploadSession $session): void + { + if (isset($session->state['uploadId'])) { + try { + $this->client($session)->abortMultipartUpload([ + ...$this->location($session), + 'UploadId' => $session->state['uploadId'], + ]); + } catch (Throwable $exception) { + if (! method_exists($exception, 'getAwsErrorCode') || $exception->getAwsErrorCode() !== 'NoSuchUpload') { + throw $exception; + } + } + } + + if (! $this->disk($session)->deleteDirectory($session->prefix())) { + throw new \RuntimeException('Unable to remove the temporary S3 upload.'); + } + } + + /** @return array{Bucket: string, Key: string} */ + private function location(UploadSession $session): array + { + $disk = $this->disk($session); + + return ['Bucket' => $disk->getConfig()['bucket'], 'Key' => $disk->path($session->path())]; + } + + private function disk(UploadSession $session): AwsS3V3Adapter + { + $disk = $this->filesystems->disk($session->disk); + + if (! $disk instanceof AwsS3V3Adapter) { + throw new InvalidArgumentException('The S3 uploader requires a Laravel S3 disk and league/flysystem-aws-s3-v3.'); + } + + return $disk; + } + + /** The SDK is an optional dependency supplied by the S3 filesystem adapter. */ + private function client(UploadSession $session): mixed + { + return $this->disk($session)->getClient(); + } +} diff --git a/src/Asset/Validation/AssetRules.php b/src/Asset/Validation/AssetRules.php index 75236339e1f..fcd1cca1f0a 100644 --- a/src/Asset/Validation/AssetRules.php +++ b/src/Asset/Validation/AssetRules.php @@ -84,7 +84,7 @@ public function rules(): array $rules['tempFilePath'] = [ 'nullable', - Rule::requiredIf($this->inScenarios(self::SCENARIO_CREATE, self::SCENARIO_REPLACE)), + Rule::requiredIf($this->subject->uploadSource === null && $this->inScenarios(self::SCENARIO_CREATE, self::SCENARIO_REPLACE)), ]; return $rules; diff --git a/src/Config/GeneralConfig.php b/src/Config/GeneralConfig.php index 97885436a3a..44099dcdf59 100644 --- a/src/Config/GeneralConfig.php +++ b/src/Config/GeneralConfig.php @@ -6,6 +6,7 @@ use Closure; use CraftCms\Cms\Auth\Enums\CpAuthPath; +use CraftCms\Cms\Filesystem\Filesystems; use CraftCms\Cms\Support\Attributes\EnvName; use CraftCms\Cms\Support\Config as ConfigHelper; use CraftCms\Cms\Support\Env; @@ -1814,6 +1815,28 @@ class GeneralConfig extends BaseConfig */ public string|int $maxUploadFileSize = 16777216; + /** + * The registered asset uploader to use, or null to select one from the temporary disk. + * + * @group Assets + */ + public ?string $assetUploader = null; + + /** + * Maximum bytes per PHP upload request. Proxy request limits may require a smaller value. + * S3 multipart uploads use parts of at least 5 MiB. + * + * @group Assets + */ + public int $uploadChunkSize = 8 * 1024 * 1024; + + /** + * Seconds of inactivity before an upload session and its temporary files can be removed. + * + * @group Assets + */ + public int $uploadSessionDuration = 24 * 60 * 60; + /** * @var bool Whether Craft should favor reduced file sizes over lossless encoding where supported. * @@ -4974,6 +4997,27 @@ public function maxUploadFileSize(string|int $value): self return $this; } + public function assetUploader(?string $value): self + { + $this->assetUploader = $value; + + return $this; + } + + public function uploadChunkSize(int $value): self + { + $this->uploadChunkSize = $value; + + return $this; + } + + public function uploadSessionDuration(int $value): self + { + $this->uploadSessionDuration = $value; + + return $this; + } + /** * Whether Craft should favor reduced file sizes over lossless encoding where supported. * @@ -6185,6 +6229,14 @@ public function verifyEmailSuccessPath(mixed $value): self return $this; } + /** + * Returns the temporary asset upload filesystem handle or disk reference. + */ + public function getTempAssetUploadFs(): string + { + return Env::parse($this->tempAssetUploadFs) ?: 'disk:'.Filesystems::TEMP_ASSET_DISK; + } + /** * Returns the localized Activate Account Success Path value. * diff --git a/src/Cp/Cp.php b/src/Cp/Cp.php index 53bd23c6ebc..ab7ee3183c6 100644 --- a/src/Cp/Cp.php +++ b/src/Cp/Cp.php @@ -220,6 +220,7 @@ private static function craftData(): array 'isMultiSite' => Sites::isMultiSite(), 'limitAutoSlugsToAscii' => $generalConfig->limitAutoSlugsToAscii, 'maxUploadSize' => AssetsHelper::getMaxUploadSize(), + 'maxAssetUploadSize' => AssetsHelper::getMaxAssetUploadSize(), 'notificationDuration' => (int) ( $currentUser->getPreference('notificationDuration') ?? $generalConfig->accessibilityDefaults['notificationDuration'] diff --git a/src/Database/Migrations/2026_09_08_101230_create_asset_upload_sessions_table.php b/src/Database/Migrations/2026_09_08_101230_create_asset_upload_sessions_table.php new file mode 100644 index 00000000000..c15e5c13c41 --- /dev/null +++ b/src/Database/Migrations/2026_09_08_101230_create_asset_upload_sessions_table.php @@ -0,0 +1,39 @@ +uuid('id')->primary(); + $table->string('owner'); + $table->string('uploader'); + $table->string('disk'); + $table->string('filename'); + $table->unsignedBigInteger('size'); + $table->unsignedBigInteger('chunkSize')->default(1); + $table->jsonb('parameters'); + $table->jsonb('state'); + $table->jsonb('result')->nullable(); + $table->dateTime('expiresAt')->index(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + }); + } + + public function down(): void + { + Schema::dropIfExists(Table::ASSETUPLOADSESSIONS); + } +}; diff --git a/src/Database/Migrations/Install.php b/src/Database/Migrations/Install.php index b2c0ab73696..1d79ebfc2cf 100644 --- a/src/Database/Migrations/Install.php +++ b/src/Database/Migrations/Install.php @@ -306,6 +306,23 @@ public function createTables(?Logger $logger = null): void $table->dateTime('dateUpdated'); }); + $logger?->subLabel('asset_upload_sessions'); + Schema::create(Table::ASSETUPLOADSESSIONS, function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->string('owner'); + $table->string('uploader'); + $table->string('disk'); + $table->string('filename'); + $table->unsignedBigInteger('size'); + $table->unsignedBigInteger('chunkSize')->default(1); + $table->jsonb('parameters'); + $table->jsonb('state'); + $table->jsonb('result')->nullable(); + $table->dateTime('expiresAt')->index(); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + }); + $logger?->subLabel('assets_sites'); Schema::create('assets_sites', function (Blueprint $table) { $table->integer('assetId'); diff --git a/src/Database/Table.php b/src/Database/Table.php index e3fd7c75e2c..23d733c05ac 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -17,6 +17,8 @@ public const string ASSETINDEXINGSESSIONS = 'assetindexingsessions'; + public const string ASSETUPLOADSESSIONS = 'assetuploadsessions'; + public const string ASSETS = 'assets'; public const string ASSETS_SITES = 'assets_sites'; diff --git a/src/GarbageCollection/Actions/RemoveExpiredUploads.php b/src/GarbageCollection/Actions/RemoveExpiredUploads.php new file mode 100644 index 00000000000..961e6c7f589 --- /dev/null +++ b/src/GarbageCollection/Actions/RemoveExpiredUploads.php @@ -0,0 +1,32 @@ +components->task('removing expired upload sessions', function () { + $result = $this->uploads->cleanupExpired(); + + if ($result['failed'] > 0) { + throw new RuntimeException('Some upload sessions could not be cleaned up. See the application log for details.'); + } + }); + } +} diff --git a/src/GarbageCollection/GarbageCollection.php b/src/GarbageCollection/GarbageCollection.php index a34a9e8dee2..69cbcbba2ee 100644 --- a/src/GarbageCollection/GarbageCollection.php +++ b/src/GarbageCollection/GarbageCollection.php @@ -34,6 +34,7 @@ use CraftCms\Cms\GarbageCollection\Actions\PurgePendingUsers; use CraftCms\Cms\GarbageCollection\Actions\PurgeUnsavedDrafts; use CraftCms\Cms\GarbageCollection\Actions\RemoveEmptyTempFolders; +use CraftCms\Cms\GarbageCollection\Actions\RemoveExpiredUploads; use CraftCms\Cms\GarbageCollection\Jobs\RunGarbageCollection; use CraftCms\Cms\Queue\Models\JobProgress; use CraftCms\Cms\User\Elements\User; @@ -138,6 +139,7 @@ public function run(bool $force = false): void ]]], HardDeleteVolumes::class, RemoveEmptyTempFolders::class, + RemoveExpiredUploads::class, ]); Artisan::call('model:prune', [ diff --git a/src/Http/Controllers/Assets/UploadController.php b/src/Http/Controllers/Assets/UploadController.php index 04aa594e023..ae513912e5c 100644 --- a/src/Http/Controllers/Assets/UploadController.php +++ b/src/Http/Controllers/Assets/UploadController.php @@ -6,21 +6,16 @@ use CraftCms\Cms\Asset\Assets; use CraftCms\Cms\Asset\AssetsHelper; +use CraftCms\Cms\Asset\AssetUploadHandler; +use CraftCms\Cms\Asset\Data\UploadResult; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Exceptions\AssetDisallowedExtensionException; use CraftCms\Cms\Asset\Exceptions\UploadFailedException; -use CraftCms\Cms\Asset\Folders; -use CraftCms\Cms\Asset\Validation\AssetRules; use CraftCms\Cms\Cms; -use CraftCms\Cms\Element\Conditions\ElementCondition; use CraftCms\Cms\Element\Elements; -use CraftCms\Cms\Field\Assets as AssetsField; -use CraftCms\Cms\Field\Fields; use CraftCms\Cms\Http\RespondsWithFlash; -use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\File; use CraftCms\Cms\Support\Query; -use CraftCms\Cms\Translation\Formatter; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; @@ -36,8 +31,7 @@ public function __construct( private Assets $assets, - private Folders $folders, - private Fields $fields, + private AssetUploadHandler $uploads, private Elements $elements, ) {} @@ -49,127 +43,15 @@ public function upload(Request $request): Response abort_if(! $uploadedFile, 400, 'No file was uploaded'); - $folderId = $request->integer('folderId') ?: null; - $fieldId = $request->integer('fieldId') ?: null; - - abort_if(! $folderId && ! $fieldId, 400, 'No target destination provided for uploading'); - $tempPath = $this->getUploadedFileTempPath($uploadedFile); - if (empty($folderId)) { - /** @var AssetsField|null $field */ - $field = $this->fields->getFieldById($fieldId); - - abort_if(! $field instanceof AssetsField, 400, 'The field provided is not an Assets field'); - - if ($elementId = $request->integer('elementId')) { - $siteId = $request->integer('siteId') ?: null; - $element = $this->elements->getElementById($elementId, null, $siteId); - } else { - $element = null; - } - $folderId = $field->resolveDynamicPathToFolderId($element); - - $selectionCondition = $field->getSelectionCondition(); - if ($selectionCondition instanceof ElementCondition) { - $selectionCondition->referenceElement = $element; - } - } else { - $selectionCondition = null; - } - - abort_if(empty($folderId), 400, 'The target destination provided for uploading is not valid'); - - $folder = $this->folders->findFolder(['id' => $folderId]); - - abort_if(! $folder, 400, 'The target folder provided for uploading is not valid'); - - Gate::authorize('uploadAsset', $folder); - - $filename = AssetsHelper::prepareAssetName($uploadedFile->getClientOriginalName()); - - if ($selectionCondition) { - $tempFolder = $this->assets->getUserTemporaryUploadFolder(); - - if ($folder->id !== $tempFolder->id) { - // upload to the user's temp folder initially, with a temp name - $originalFolder = $folder; - $originalFilename = $filename; - $folder = $tempFolder; - $filename = uniqid('asset', true).'.'.pathinfo($filename, PATHINFO_EXTENSION); - } - } - - $asset = new Asset; - $asset->tempFilePath = $tempPath; - $asset->setFilename($filename); - $asset->setMimeType(File::getMimeType($tempPath, checkExtension: false) ?? $uploadedFile->getClientMimeType()); - $asset->newFolderId = $folder->id; - $asset->setVolumeId($folder->volumeId); - $asset->uploaderId = $request->craftUser()?->getCraftUserId(); - $asset->avoidFilenameConflicts = true; - - if (isset($originalFilename)) { - $asset->title = AssetsHelper::filename2Title(pathinfo($originalFilename, PATHINFO_FILENAME)); - } - - $asset->ruleset->useScenario(AssetRules::SCENARIO_CREATE); - $result = $this->elements->saveElement($asset); - - // In case of error, let user know about it. - if (! $result) { - return $this->asModelFailure($asset); - } - - if ($selectionCondition) { - if (! $selectionCondition->matchElement($asset)) { - // delete and reject it - $this->elements->deleteElement($asset, true); - - return $this->asFailure(t('{filename} isn’t selectable for this field.', [ - 'filename' => $uploadedFile->getClientOriginalName(), - ])); - } - - if (isset($originalFilename, $originalFolder)) { - // move it into the original target destination - $asset->newFilename = $originalFilename; - $asset->newFolderId = $originalFolder->id; - $asset->ruleset->useScenario(AssetRules::SCENARIO_MOVE); - - if (! $this->elements->saveElement($asset)) { - return $this->asModelFailure($asset); - } - } - } - - // try to get uploaded asset's URL - $url = null; - try { - $url = $asset->getUrl(); - } catch (Throwable) { - // do nothing - } - - if ($asset->conflictingFilename !== null) { - $conflictingAsset = Asset::findOne(['folderId' => $folder->id, 'filename' => $asset->conflictingFilename]); - - return new JsonResponse([ - 'conflict' => t('A file with the name “{filename}” already exists.', ['filename' => $asset->conflictingFilename]), - 'assetId' => $asset->id, - 'filename' => $asset->conflictingFilename, - 'conflictingAssetId' => $conflictingAsset->id ?? null, - 'suggestedFilename' => $asset->suggestedFilename, - 'conflictingAssetUrl' => ($conflictingAsset && $conflictingAsset->getVolume()->sourceHasUrls()) ? $conflictingAsset->getUrl() : null, - 'url' => $url, - ]); - } - - return $this->asSuccess(data: [ - 'filename' => $asset->getFilename(), - 'assetId' => $asset->id, - 'url' => $url, - ]); + return $this->respond($this->uploads->store( + $request->all(), + $uploadedFile->getClientOriginalName(), + $uploadedFile->getClientMimeType(), + $tempPath, + uploaderId: $request->craftUser()?->getCraftUserId(), + )); } public function replaceFile(Request $request): Response @@ -260,20 +142,20 @@ public function replaceFile(Request $request): Response $resultingAsset = $assetToReplace ?: $sourceAsset; - return $this->asSuccess(data: [ - 'assetId' => $assetId, - 'filename' => $resultingAsset->getFilename(), - 'formattedSize' => $resultingAsset->getFormattedSize(0), - 'formattedSizeInBytes' => $resultingAsset->getFormattedSizeInBytes(false), - 'formattedDateUpdated' => I18N::getFormatter()->asDatetime( - $resultingAsset->dateUpdated, - Formatter::FORMAT_WIDTH_SHORT, - true, - ), - 'dimensions' => $resultingAsset->getDimensions(), - 'updatedTimestamp' => $resultingAsset->dateUpdated->getTimestamp(), - 'resultingUrl' => $resultingAsset->getUrl(), - ]); + return $this->respond($this->uploads->replacementResult($resultingAsset, $assetId)); + } + + private function respond(UploadResult $result): Response + { + if ($result->status !== 200) { + return $this->asFailure($result->message ?? null, $result->payload()); + } + + if (isset($result->conflict)) { + return new JsonResponse($result->payload()); + } + + return $this->asSuccess(data: $result->payload()); } /** diff --git a/src/Http/Controllers/Assets/UploadSessionController.php b/src/Http/Controllers/Assets/UploadSessionController.php new file mode 100644 index 00000000000..9c4e8f3088d --- /dev/null +++ b/src/Http/Controllers/Assets/UploadSessionController.php @@ -0,0 +1,62 @@ +uploads->start($request); + $prefix = $request->isCpRequest() ? 'craft.actions.craft.cp.uploads' : 'craft.actions.craft.uploads'; + + return new JsonResponse(new UploadSessionData( + id: $session->id, + chunkSize: $session->chunkSize, + partCount: $session->partCount(), + urls: [ + 'part' => route("$prefix.part", ['upload' => $session->id]), + 'complete' => route("$prefix.complete", ['upload' => $session->id]), + 'cancel' => route("$prefix.destroy", ['upload' => $session->id]), + ], + )->toArray(), 201); + } + + public function part(Request $request, string $upload): JsonResponse + { + $data = $request->validate(['part' => ['required', 'integer', 'min:1']]); + + return new JsonResponse($this->uploads->part($request, $upload, (int) $data['part'])); + } + + public function chunk(Request $request, string $upload, int $part): Response + { + $this->uploads->receive($request, $upload, $part); + + return response()->noContent(); + } + + public function complete(Request $request, string $upload): JsonResponse + { + $result = $this->uploads->complete($request, $upload); + + return new JsonResponse($result->payload(), $result->status); + } + + public function destroy(Request $request, string $upload): Response + { + $this->uploads->cancel($request, $upload); + + return response()->noContent(); + } +} diff --git a/src/Http/Requests/UploadRequest.php b/src/Http/Requests/UploadRequest.php new file mode 100644 index 00000000000..efde8b28794 --- /dev/null +++ b/src/Http/Requests/UploadRequest.php @@ -0,0 +1,29 @@ +> */ + public function rules(): array + { + return [ + 'filename' => ['required', 'string', 'max:255'], + 'size' => ['required', 'integer', 'min:1', 'max:'.AssetsHelper::getMaxAssetUploadSize()], + 'operation' => ['sometimes', Rule::in(['upload', 'replace'])], + 'folderId' => ['nullable', 'integer', 'min:1'], + 'fieldId' => ['nullable', 'integer', 'min:1'], + 'elementId' => ['nullable', 'integer', 'min:1'], + 'siteId' => ['nullable', 'integer', 'min:1'], + 'assetId' => ['nullable', 'integer', 'min:1'], + 'context' => ['sometimes', 'array', 'max:20'], + ]; + } +} diff --git a/tests/Feature/Asset/AssetsTest.php b/tests/Feature/Asset/AssetsTest.php index 454efaf6983..918628ad05c 100644 --- a/tests/Feature/Asset/AssetsTest.php +++ b/tests/Feature/Asset/AssetsTest.php @@ -24,7 +24,9 @@ use CraftCms\Cms\Support\Facades\Assets as AssetsFacade; use CraftCms\Cms\Support\Url; use CraftCms\Cms\Tests\TestClasses\Asset\ControlPanelAssetTransformDriver; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Storage; beforeEach(function () { $this->assets = app(Assets::class); @@ -324,6 +326,96 @@ }], ]); +it('preserves visibility when renaming an asset within its volume', function (string $subpath) { + $disk = Storage::fake('test-disk'); + config()->set('filesystems.disks.test-disk.root', $disk->path('')); + $volume = Volume::factory()->create(['fs' => 'disk:test-disk', 'subpath' => $subpath]); + $folder = app(Folders::class)->getRootFolderByVolumeId($volume->id); + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + 'filename' => 'document.txt', + 'kind' => FileKind::Text->value, + ]); + $disk->put($subpath.'document.txt', 'contents', 'public'); + + expect($this->assets->moveAsset($asset, $folder, 'renamed.txt'))->toBeTrue(); + + $disk->assertMissing($subpath.'document.txt'); + expect($disk->get($subpath.'renamed.txt'))->toBe('contents') + ->and($disk->getVisibility($subpath.'renamed.txt'))->toBe('public') + ->and(Asset::findOne($asset->id)->filename)->toBe('renamed.txt'); +})->with(['root' => '', 'subpath' => 'uploads/']); + +it('keeps replacement bytes in a volume with a subpath', function () { + $disk = Storage::fake('test-disk'); + config()->set('filesystems.disks.test-disk.root', $disk->path('')); + $volume = Volume::factory()->create(['fs' => 'disk:test-disk', 'subpath' => 'uploads/']); + $folder = app(Folders::class)->getRootFolderByVolumeId($volume->id); + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $folder->id, + 'filename' => 'document.txt', + 'kind' => FileKind::Text->value, + ]); + $disk->put('uploads/document.txt', 'original'); + $replacement = UploadedFile::fake()->createWithContent('document.txt', 'replacement'); + + $this->assets->replaceAssetFile($asset, $replacement->getPathname(), 'document.txt'); + + expect($asset->errors()->getMessages())->toBe([]); + expect($disk->get('uploads/document.txt'))->toBe('replacement') + ->and(Asset::findOne($asset->id)->size)->toBe(11); +}); + +it('moves existing assets between volumes while preserving their contents and metadata', function (bool $sameDisk) { + config()->set('filesystems.disks.asset-move-destination', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/asset-move-destination'), + ]); + $sourceDisk = Storage::fake('test-disk'); + $destinationDisk = $sameDisk ? $sourceDisk : Storage::fake('asset-move-destination'); + $sourceVolume = Volume::factory()->create(['fs' => 'disk:test-disk']); + $destinationVolume = Volume::factory()->create(['fs' => $sameDisk ? 'disk:test-disk' : 'disk:asset-move-destination']); + $sourceFolder = app(Folders::class)->getRootFolderByVolumeId($sourceVolume->id); + $destinationFolder = app(Folders::class)->getRootFolderByVolumeId($destinationVolume->id); + $image = UploadedFile::fake()->image('photo.png', 13, 17); + $bytes = file_get_contents($image->getPathname()); + $modified = now()->subDay()->startOfSecond(); + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $sourceVolume->id, + 'folderId' => $sourceFolder->id, + 'filename' => 'photo.png', + 'kind' => FileKind::Image->value, + 'size' => strlen($bytes), + 'width' => 13, + 'height' => 17, + 'dateModified' => $modified, + ]); + $sourceDisk->put('photo.png', $bytes); + $asset = Asset::findOne($asset->id); + $modified = $asset->dateModified; + $filename = 'moved.png'; + + expect($this->assets->moveAsset($asset, $destinationFolder, $filename))->toBeTrue(); + + expect($destinationDisk->get($filename))->toBe($bytes); + $sourceDisk->assertMissing('photo.png'); + + $saved = Asset::findOne($asset->id); + expect($saved->folderId)->toBe($destinationFolder->id) + ->and($saved->getVolumeId())->toBe($destinationVolume->id) + ->and($saved->getFilename())->toBe($filename) + ->and($saved->kind)->toBe(FileKind::Image->value) + ->and($saved->size)->toBe(strlen($bytes)) + ->and($saved->getWidth())->toBe(13) + ->and($saved->getHeight())->toBe(17) + ->and($saved->dateModified->getTimestamp())->toBe($modified->getTimestamp()); +})->with([ + 'same disk' => [true], + 'different disks' => [false], +]); + it('resets caches', function () { $this->assets->reset(); diff --git a/tests/Feature/Http/Controllers/Assets/UploadControllerTest.php b/tests/Feature/Http/Controllers/Assets/UploadControllerTest.php index 5073dd4b7df..24e3fc50773 100644 --- a/tests/Feature/Http/Controllers/Assets/UploadControllerTest.php +++ b/tests/Feature/Http/Controllers/Assets/UploadControllerTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use CraftCms\Cms\Asset\Conditions\AssetCondition; +use CraftCms\Cms\Asset\Conditions\FileTypeConditionRule; +use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Models\Volume; use CraftCms\Cms\Asset\Models\VolumeFolder as VolumeFolderModel; use CraftCms\Cms\Entry\Models\Entry as EntryModel; @@ -26,12 +29,12 @@ $this->folder = VolumeFolderModel::factory()->create(['volumeId' => $this->volume->id]); }); -it('requires authentication', function () { +it('requires authentication', function (string $action) { auth()->logout(); - postJson(action([UploadController::class, 'upload'])) + postJson(action([UploadController::class, $action])) ->assertUnauthorized(); -}); +})->with(['upload', 'replaceFile']); it('requires a file or field for upload', function () { postJson(action([UploadController::class, 'upload']), [ @@ -69,13 +72,6 @@ ->exists())->toBeTrue(); }); -it('requires authentication for replace file', function () { - auth()->logout(); - - postJson(action([UploadController::class, 'replaceFile'])) - ->assertUnauthorized(); -}); - it('validates replace file parameters', function () { postJson(action([UploadController::class, 'replaceFile']))->assertBadRequest(); }); @@ -94,3 +90,35 @@ 'targetFilename' => 'replacement.jpg', ]], ]); + +it('reports rejected field uploads in the requested response format', function (bool $json) { + $result = EntryModel::factory() + ->withField('attachment', AssetsField::class, [ + 'defaultUploadLocationSource' => "volume:{$this->volume->uid}", + 'selectionCondition' => [ + 'class' => AssetCondition::class, + 'conditionRules' => [[ + 'class' => FileTypeConditionRule::class, + 'values' => ['image'], + ]], + ], + ])->createElementWithFields([]); + + $response = post(action([UploadController::class, 'upload']), [ + 'fieldId' => $result->fields->get('attachment')->id, + 'elementId' => $result->element->id, + 'assets-upload' => UploadedFile::fake()->createWithContent('attachment.txt', 'abc'), + ], ['Accept' => $json ? 'application/json' : 'text/html']); + + $message = 'attachment.txt isn’t selectable for this field.'; + if ($json) { + $response->assertBadRequest()->assertJsonPath('message', $message); + } else { + $response->assertRedirect()->assertSessionHas('error', $message); + } + + expect(Asset::find()->count())->toBe(0); +})->with([ + 'JSON' => [true], + 'HTML' => [false], +]); diff --git a/tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php b/tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php new file mode 100644 index 00000000000..fe4d29c838c --- /dev/null +++ b/tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php @@ -0,0 +1,277 @@ +set('filesystems.disks.upload-parts', ['driver' => 'local', 'root' => storage_path('framework/testing/upload-parts')]); + config()->set('filesystems.disks.upload-destination', ['driver' => 'local', 'root' => storage_path('framework/testing/upload-destination')]); + Storage::fake('upload-parts'); + Storage::fake('upload-destination'); + Cms::config()->tempAssetUploadFs = 'disk:upload-parts'; + Cms::config()->uploadChunkSize = 3; + + $this->volume = Volume::factory()->create(['fs' => 'disk:upload-destination']); + $this->folder = VolumeFolder::factory()->create(['volumeId' => $this->volume->id, 'path' => '']); +}); + +it('requires authentication before creating an upload', function () { + auth()->logout(); + + postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'example.txt', 'size' => 6, 'folderId' => $this->folder->id, + ])->assertUnauthorized(); + + expect(UploadSession::count())->toBe(0); + Storage::disk('upload-parts')->assertDirectoryEmpty('/'); +}); + +it('authorizes the destination before accepting bytes', function () { + Gate::partialMock()->shouldReceive('authorize')->andThrow(new AuthorizationException); + + postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'example.txt', 'size' => 6, 'folderId' => $this->folder->id, + ])->assertForbidden(); + + expect(UploadSession::count())->toBe(0); +}); + +it('assembles retried parts once and completes idempotently', function () { + $session = postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'example.txt', 'size' => 6, 'folderId' => $this->folder->id, + ])->assertCreated()->assertJsonPath('chunkSize', 3)->json(); + + foreach ([[1, 'abc'], [2, 'def'], [1, 'abc']] as [$part, $bytes]) { + $request = postJson($session['urls']['part'], ['part' => $part])->assertOk()->json(); + $this->call('POST', $request['url'], server: ['CONTENT_TYPE' => 'application/octet-stream'], content: $bytes) + ->assertNoContent(); + } + + Storage::disk('upload-parts')->put("upload-sessions/{$session['id']}/file", 'partial'); + $completed = postJson($session['urls']['complete'])->assertOk()->assertJsonMissingPath('conflictingAssetId')->json(); + $asset = Asset::findOne($completed['assetId']); + + expect($asset->getFilename())->toBe('example.txt') + ->and($asset->size)->toBe(6); + expect(Storage::disk('upload-destination')->get($asset->getPath()))->toBe('abcdef'); + Storage::disk('upload-parts')->assertDirectoryEmpty('upload-sessions'); + + postJson($session['urls']['complete']) + ->assertOk()->assertJsonPath('assetId', $asset->id); + expect(Asset::find()->folderId($this->folder->id)->count())->toBe(1); +}); + +it('rejects incorrect part sizes and incomplete files', function () { + $session = postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'example.txt', 'size' => 6, 'folderId' => $this->folder->id, + ])->assertCreated()->json(); + $request = postJson($session['urls']['part'], ['part' => 1])->assertOk()->json(); + + $this->call('POST', $request['url'], server: ['CONTENT_TYPE' => 'application/octet-stream', 'HTTP_ACCEPT' => 'application/json'], content: 'toolong') + ->assertUnprocessable(); + postJson($session['urls']['part'], ['part' => 3])->assertUnprocessable(); + postJson($session['urls']['complete'])->assertUnprocessable(); + + expect(Asset::find()->folderId($this->folder->id)->count())->toBe(0); + Storage::disk('upload-parts')->assertMissing("upload-sessions/{$session['id']}/parts/1"); +}); + +it('does not accept another users upload session', function () { + $session = postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'example.txt', 'size' => 3, 'folderId' => $this->folder->id, + ])->assertCreated()->json(); + + auth()->logout(); + + postJson($session['urls']['part'], ['part' => 1])->assertNotFound(); + postJson($session['urls']['complete'])->assertNotFound(); + $this->deleteJson($session['urls']['cancel'])->assertNotFound(); + expect(UploadSession::count())->toBe(1); +}); + +it('rechecks permissions before issuing another part request', function () { + $session = postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'example.txt', 'size' => 3, 'folderId' => $this->folder->id, + ])->assertCreated()->json(); + + Gate::partialMock()->shouldReceive('authorize')->andThrow(new AuthorizationException); + + postJson($session['urls']['part'], ['part' => 1])->assertForbidden(); + $this->deleteJson($session['urls']['cancel'])->assertNoContent(); +}); + +it('allows a five gib asset when configured regardless of PHP request limits', function () { + Cms::config()->maxUploadFileSize = 5368709120; + Cms::config()->uploadChunkSize = 2097152; + + postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'archive.zip', 'size' => 5368709120, 'folderId' => $this->folder->id, + ])->assertCreated()->assertJsonPath('partCount', 2560); + + postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'archive.zip', 'size' => 5368709121, 'folderId' => $this->folder->id, + ])->assertUnprocessable(); +}); + +it('expires abandoned sessions but preserves active uploads', function () { + $session = postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'example.txt', 'size' => 3, 'folderId' => $this->folder->id, + ])->assertCreated()->json(); + + $this->travel(23)->hours(); + postJson($session['urls']['part'], ['part' => 1])->assertOk(); + $this->travel(2)->hours(); + expect(app(AssetUploads::class)->cleanupExpired())->toBe(['removed' => 0, 'failed' => 0]); + + $this->travel(23)->hours(); + postJson($session['urls']['part'], ['part' => 1])->assertGone(); + expect(app(AssetUploads::class)->cleanupExpired())->toBe(['removed' => 1, 'failed' => 0]); + expect(UploadSession::count())->toBe(0); +}); + +it('binds guest uploads to application-defined destinations and limits', function () { + auth()->logout(); + $authorize = fn (Request $request, array $file): bool => $file['folderId'] === $this->folder->id + && $file['size'] <= 3 + && $file['filename'] === 'proof of address.txt' + && ($file['context']['form'] ?? null) === 'submission'; + app(AssetUploads::class)->allowGuestUploadsUsing($authorize); + + postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'proof of address.txt', 'size' => 4, 'folderId' => $this->folder->id, 'context' => ['form' => 'submission'], + ])->assertForbidden(); + + postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'proof of address.txt', 'size' => 3, 'folderId' => 99999, 'context' => ['form' => 'submission'], + ])->assertForbidden(); + + expect(UploadSession::count())->toBe(0); + + $response = postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'proof of address.txt', 'size' => 3, 'folderId' => $this->folder->id, 'context' => ['form' => 'submission'], + ])->assertCreated(); + $session = $response->json(); + $this->withCredentials(); + $this->withCookie(config('session.cookie'), $response->getCookie(config('session.cookie'))->getValue()); + + $storedSession = UploadSession::findOrFail($session['id']); + expect($storedSession->parameters['folderId'])->toBe($this->folder->id); + $storedSession->update(['parameters' => array_reverse($storedSession->parameters, preserve_keys: true)]); + app(AssetUploads::class)->allowGuestUploadsUsing(fn (): bool => false); + postJson($session['urls']['part'], ['part' => 1])->assertForbidden(); + postJson($session['urls']['complete'])->assertForbidden(); + Storage::disk('upload-destination')->assertDirectoryEmpty('/'); + + app(AssetUploads::class)->allowGuestUploadsUsing($authorize); + $part = postJson($session['urls']['part'], ['part' => 1])->assertOk()->json(); + $this->call('POST', $part['url'], cookies: $this->prepareCookiesForRequest(), server: ['CONTENT_TYPE' => 'application/octet-stream'], content: 'abc')->assertNoContent(); + $completed = postJson($session['urls']['complete'])->assertOk()->json(); + + $asset = Asset::findOne($completed['assetId']); + expect($asset->folderId)->toBe($this->folder->id) + ->and($asset->filename)->toBe('proof-of-address.txt') + ->and(Storage::disk('upload-destination')->get($asset->getPath()))->toBe('abc'); +}); + +it('preserves image dimensions through chunk assembly and asset processing', function () { + $image = UploadedFile::fake()->image('photo.png', 13, 17); + $bytes = file_get_contents($image->getRealPath()); + Cms::config()->uploadChunkSize = 1048576; + $session = postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'photo.png', 'size' => strlen($bytes), 'folderId' => $this->folder->id, + ])->assertCreated()->json(); + $part = postJson($session['urls']['part'], ['part' => 1])->assertOk()->json(); + $this->call('POST', $part['url'], server: ['CONTENT_TYPE' => 'application/octet-stream'], content: $bytes)->assertNoContent(); + $completed = postJson($session['urls']['complete'])->assertOk()->json(); + + $asset = Asset::findOne($completed['assetId']); + expect($asset->getWidth())->toBe(13)->and($asset->getHeight())->toBe(17) + ->and($asset->getMimeType())->toBe('image/png'); +}); + +it('resolves a fields dynamic upload folder from its element context', function () { + $result = Entry::factory() + ->withField('attachment', Assets::class, [ + 'defaultUploadLocationSource' => "volume:{$this->volume->uid}", + 'defaultUploadLocationSubpath' => '{uid}', + ])->createElementWithFields(['title' => 'Upload target']); + + $session = postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'example.txt', 'size' => 3, + 'fieldId' => $result->fields->get('attachment')->id, + 'elementId' => $result->element->id, 'siteId' => $result->element->siteId, + ])->assertCreated()->json(); + $part = postJson($session['urls']['part'], ['part' => 1])->assertOk()->json(); + $this->call('POST', $part['url'], server: ['CONTENT_TYPE' => 'application/octet-stream'], content: 'abc')->assertNoContent(); + $completed = postJson($session['urls']['complete'])->assertOk()->json(); + + expect(Asset::findOne($completed['assetId'])->getPath())->toBe("{$result->element->uid}/example.txt"); +}); + +it('keeps the staged bytes when required processing fails and retries completion', function () { + $session = postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'example.txt', 'size' => 3, 'folderId' => $this->folder->id, + ])->assertCreated()->json(); + $part = postJson($session['urls']['part'], ['part' => 1])->assertOk()->json(); + $this->call('POST', $part['url'], server: ['CONTENT_TYPE' => 'application/octet-stream'], content: 'abc')->assertNoContent(); + Event::listen(AssetFileHandling::class, function () { + throw new RuntimeException('Required processing failed.'); + }); + postJson($session['urls']['complete'])->assertServerError(); + Storage::disk('upload-parts')->assertExists("upload-sessions/{$session['id']}/file"); + + Event::forget(AssetFileHandling::class); + postJson($session['urls']['complete'])->assertOk(); + expect(Asset::find()->folderId($this->folder->id)->count())->toBe(1); +}); + +it('replaces the existing asset without creating a second asset', function (string $subpath) { + config()->set('filesystems.disks.upload-destination.root', Storage::disk('upload-destination')->path('')); + $this->volume = Volume::factory()->create(['fs' => 'disk:upload-destination', 'subpath' => $subpath]); + $this->folder = VolumeFolder::factory()->create(['volumeId' => $this->volume->id, 'path' => '']); + + $upload = function (string $bytes, array $parameters): array { + $session = postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'example.txt', 'size' => strlen($bytes), ...$parameters, + ])->assertCreated()->json(); + foreach (str_split($bytes, 3) as $index => $chunk) { + $part = postJson($session['urls']['part'], ['part' => $index + 1])->assertOk()->json(); + $this->call('POST', $part['url'], server: ['CONTENT_TYPE' => 'application/octet-stream'], content: $chunk)->assertNoContent(); + } + + return postJson($session['urls']['complete'])->assertOk()->json(); + }; + + $original = $upload('abc', ['folderId' => $this->folder->id]); + $replacement = $upload('defghi', ['operation' => 'replace', 'assetId' => $original['assetId']]); + $asset = Asset::findOne($original['assetId']); + + expect($replacement['assetId'])->toBe($original['assetId']) + ->and($asset->size)->toBe(6) + ->and(Storage::disk('upload-destination')->get($subpath.$asset->getPath()))->toBe('defghi') + ->and(Asset::find()->folderId($this->folder->id)->count())->toBe(1); +})->with([ + 'volume root' => [''], + 'volume subpath' => ['uploads/'], +]); diff --git a/tests/Unit/Asset/AssetUploadersTest.php b/tests/Unit/Asset/AssetUploadersTest.php new file mode 100644 index 00000000000..6080d41b1b0 --- /dev/null +++ b/tests/Unit/Asset/AssetUploadersTest.php @@ -0,0 +1,52 @@ +set("filesystems.disks.$name", ['driver' => 'local', 'root' => storage_path("framework/testing/$name")]); + Storage::fake($name); + } + + Cms::config()->tempAssetUploadFs = 'disk:upload-first'; +}); + +it('routes custom uploader operations to each sessions disk', function () { + $manager = app(AssetUploaders::class); + $manager->extend('custom', fn (Container $app) => $app->make(ChunkedUploader::class)); + Cms::config()->assetUploader = 'custom'; + + expect($manager->getDefaultDriver())->toBe('custom'); + + $uploader = $manager->driver(); + $sessions = []; + + foreach (['upload-first', 'upload-second'] as $disk) { + $session = new UploadSession(['id' => $disk, 'disk' => "disk:$disk", 'size' => 3, 'chunkSize' => 3]); + $sessions[] = $session; + $stream = fopen('php://temp', 'w+b'); + fwrite($stream, 'abc'); + rewind($stream); + + try { + $uploader->receive($session, 1, $stream); + } finally { + fclose($stream); + } + } + + Storage::disk('upload-first')->assertExists('upload-sessions/upload-first/parts/1'); + Storage::disk('upload-first')->assertMissing('upload-sessions/upload-second/parts/1'); + Storage::disk('upload-second')->assertExists('upload-sessions/upload-second/parts/1'); + + $uploader->abort($sessions[0]); + Storage::disk('upload-first')->assertMissing('upload-sessions/upload-first/parts/1'); + Storage::disk('upload-second')->assertExists('upload-sessions/upload-second/parts/1'); +}); diff --git a/tests/Unit/Asset/S3UploaderTest.php b/tests/Unit/Asset/S3UploaderTest.php new file mode 100644 index 00000000000..0eb886a5c6e --- /dev/null +++ b/tests/Unit/Asset/S3UploaderTest.php @@ -0,0 +1,124 @@ +markTestSkipped('The optional S3 adapter and AWS SDK are not installed.'); + } + + $this->commands = []; + $this->client = new S3Client([ + 'version' => 'latest', 'region' => 'us-east-1', + 'credentials' => ['key' => 'test', 'secret' => 'test'], + 'handler' => function (CommandInterface $command) { + $this->commands[] = $command; + + return Create::promiseFor(new Result(match ($command->getName()) { + 'CreateMultipartUpload' => ['UploadId' => 'multipart-id'], + 'ListParts' => ['Parts' => [ + ['PartNumber' => 1, 'Size' => 8388608, 'ETag' => 'first'], + ['PartNumber' => 2, 'Size' => 3, 'ETag' => 'second'], + ]], + 'HeadObject' => ['ContentLength' => 5368709121], + 'UploadPartCopy' => ['CopyPartResult' => ['ETag' => 'copied']], + default => [], + })); + }, + ]); + $this->disk = Mockery::mock(AwsS3V3Adapter::class); + $this->disk->shouldReceive('getClient')->andReturn($this->client); + $this->disk->shouldReceive('getConfig')->andReturn(['bucket' => 'upload-bucket', 'region' => 'us-east-1']); + $this->disk->shouldReceive('path')->andReturnUsing(fn (string $path) => 'prefix/'.$path); + $filesystems = Mockery::mock(Filesystems::class); + $filesystems->shouldReceive('disk')->with('disk:uploads')->andReturn($this->disk); + app()->instance(Filesystems::class, $filesystems); +}); + +it('signs direct multipart requests and completes using storage-verified parts', function () { + $session = new UploadSession(['id' => 'session', 'disk' => 'disk:uploads', 'filename' => 'movie.mp4', 'size' => 8388611]); + $uploader = app(S3Uploader::class); + $uploader->start($session); + $request = $uploader->partRequest($session, 2); + + expect($request->method)->toBe('PUT') + ->and($request->url)->toContain('uploadId=multipart-id', 'partNumber=2', 'X-Amz-Signature=') + ->and($session->chunkSize)->toBe(8388608); + + $this->disk->shouldReceive('exists')->once()->with($session->path())->andReturnFalse(); + $file = $uploader->complete($session); + $completion = array_last($this->commands); + expect($file->path)->toBe($session->path()) + ->and($completion->getName())->toBe('CompleteMultipartUpload') + ->and($completion['MultipartUpload']['Parts'])->toBe([ + ['PartNumber' => 1, 'ETag' => 'first'], ['PartNumber' => 2, 'ETag' => 'second'], + ]); +}); + +it('rejects storage parts that do not match the declared size', function () { + $session = new UploadSession(['id' => 'session', 'disk' => 'disk:uploads', 'filename' => 'movie.mp4', 'size' => 8388612]); + $uploader = app(S3Uploader::class); + $uploader->start($session); + $this->disk->shouldReceive('exists')->andReturnFalse(); + + expect(fn () => $uploader->complete($session))->toThrow(HttpException::class, 'incorrect size'); + expect(array_map(fn ($command) => $command->getName(), $this->commands))->not->toContain('CompleteMultipartUpload'); +}); + +it('uses multipart storage copies for objects larger than five gib', function () { + $this->disk->shouldReceive('getDriver')->andReturn(new Filesystem( + new S3Adapter($this->client, 'upload-bucket', 'prefix'), + )); + $file = new UploadedAssetFile($this->disk, 'staged/file', 'archive.zip'); + $file->storeAs($this->disk, 'assets/archive.zip', 'application/zip'); + + $names = array_map(fn ($command) => $command->getName(), $this->commands); + expect($names)->toContain('CreateMultipartUpload', 'UploadPartCopy', 'CompleteMultipartUpload') + ->not->toContain('GetObject', 'PutObject'); + expect(count(array_filter($names, fn ($name) => $name === 'UploadPartCopy')))->toBeGreaterThan(1); +}); + +it('stores processed bytes when sanitization changed the local file', function () { + $stream = fopen('php://temp', 'w+b'); + fwrite($stream, 'original'); + rewind($stream); + $this->disk->shouldReceive('readStream')->once()->with('staged/file')->andReturn($stream); + $this->disk->shouldReceive('size')->once()->with('staged/file')->andReturn(8); + $file = new UploadedAssetFile($this->disk, 'staged/file', 'image.svg'); + + try { + $path = $file->localPath(); + file_put_contents($path, 'sanitized'); + $this->disk->shouldReceive('writeStream')->once()->withArgs(fn ($destination, $bytes, $options) => $destination === 'assets/image.svg' + && stream_get_contents($bytes) === 'sanitized' + && $options['mimetype'] === 'image/svg+xml')->andReturnTrue(); + + $file->storeAs($this->disk, 'assets/image.svg', 'image/svg+xml', $path); + expect($this->commands)->toBe([]); + } finally { + $file->release(); + } +}); + +it('aborts multipart storage and removes the staging prefix', function () { + $session = new UploadSession(['id' => 'session', 'disk' => 'disk:uploads', 'state' => ['uploadId' => 'multipart-id']]); + $this->disk->shouldReceive('deleteDirectory')->once()->with('upload-sessions/session')->andReturnTrue(); + app(S3Uploader::class)->abort($session); + + expect($this->commands[0]->getName())->toBe('AbortMultipartUpload') + ->and($this->commands[0]['Key'])->toBe('prefix/upload-sessions/session/file') + ->and($this->commands[0]['UploadId'])->toBe('multipart-id'); +}); diff --git a/vite.config.js b/vite.config.js index 7cb02f3e6fc..ea1d600d581 100644 --- a/vite.config.js +++ b/vite.config.js @@ -335,6 +335,7 @@ export default defineConfig(({mode}) => { input: [ 'resources/js/cp.ts', 'resources/js/legacy.ts', + 'resources/js/uploads.ts', 'resources/css/cp.css', 'workbench/resources/js/cp.ts', ], diff --git a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php index 1bf5c13d673..f2eb72ea51d 100644 --- a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php +++ b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php @@ -5,6 +5,9 @@ namespace Workbench\App\Providers; use CraftCms\Cms\Asset\Data\AssetTransformerIndexData; +use CraftCms\Cms\Asset\Data\UploadPartRequest; +use CraftCms\Cms\Asset\Data\UploadResult; +use CraftCms\Cms\Asset\Data\UploadSessionData; use CraftCms\Cms\Cp\Data\NavItem; use CraftCms\Cms\Cp\Data\NotificationButtonData; use CraftCms\Cms\Cp\Data\NotificationData; @@ -58,6 +61,9 @@ protected function configure(TypeScriptTransformerConfigFactory $config): void ->provider(new ClassListTransformedProvider( [ AssetTransformerIndexData::class, + UploadPartRequest::class, + UploadResult::class, + UploadSessionData::class, GqlSchema::class, GqlToken::class, ImageTransform::class, diff --git a/workbench/config/craft/general.php b/workbench/config/craft/general.php new file mode 100644 index 00000000000..6a8d561162a --- /dev/null +++ b/workbench/config/craft/general.php @@ -0,0 +1,8 @@ +maxUploadFileSize(200 * 1024 * 1024); From 3a1cdab4276637745d97bef363282d1840522039 Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 8 Sep 2026 16:11:48 +0200 Subject: [PATCH 2/7] Route all asset uploads through upload sessions --- CHANGELOG.md | 1 + docs/uploads.md | 10 +- .../craftcms-legacy/cp/src/js/AssetIndex.js | 4 +- .../craftcms-legacy/cp/src/js/ImageUpload.js | 11 +- .../markdown-field/behaviors/uploads.test.ts | 42 ++++ .../markdown-field/behaviors/uploads.ts | 63 +----- .../js/modules/uploader/base-uploader.ts | 8 - .../js/modules/uploader/uploader.test.ts | 21 +- resources/js/modules/uploader/uploader.ts | 60 +---- .../js/pages/assets/AssetUploadButton.test.ts | 2 +- .../js/pages/assets/AssetUploadButton.vue | 8 +- routes/actions.php | 5 +- src/Asset/AssetUploadHandler.php | 12 +- src/Asset/AssetUploads.php | 4 +- .../ResolveUploadConflictController.php | 77 +++++++ .../Controllers/Assets/UploadController.php | 208 ------------------ .../ResolveUploadConflictControllerTest.php | 106 +++++++++ .../Assets/UploadControllerTest.php | 124 ----------- .../Assets/UploadSessionControllerTest.php | 28 +++ .../legacy/controllers/AssetsController.php | 12 +- 20 files changed, 321 insertions(+), 485 deletions(-) create mode 100644 resources/js/modules/markdown-field/behaviors/uploads.test.ts create mode 100644 src/Http/Controllers/Assets/ResolveUploadConflictController.php delete mode 100644 src/Http/Controllers/Assets/UploadController.php create mode 100644 tests/Feature/Http/Controllers/Assets/ResolveUploadConflictControllerTest.php delete mode 100644 tests/Feature/Http/Controllers/Assets/UploadControllerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 458ce2f0992..2ec387dc94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Moved legacy relation-field settings HTML and entry-title input HTML into the Yii adapter. ([#19591](https://github.com/craftcms/cms/pull/19591)) - Migrated the reassign entries, replace relations, and replace references modals to the Form API. ([#19589](https://github.com/craftcms/cms/pull/19589)) - Added chunked asset uploads, direct S3 multipart uploads, upload retry and cancellation controls, and an extensible upload-session API. +- Removed the `assets/upload` and `assets/replace-file` multipart endpoints. Asset uploads now use upload sessions, and filename conflicts are resolved through `assets/resolve-upload-conflict`. - Added support for refreshable standard plugin settings forms and conditional configuration of core form nodes. ([#19545](https://github.com/craftcms/cms/pull/19545)) - Added support for sending queued Laravel notifications to `CraftCms\Cms\User\Elements\User` elements. ([#19541](https://github.com/craftcms/cms/pull/19541)) - Added Markdown comments to element activity timelines, with support for editing, removing, structured user mentions, and email notifications. diff --git a/docs/uploads.md b/docs/uploads.md index e3a2aef30aa..8b250b959f3 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -1,6 +1,6 @@ # Asset uploads -Craft uploads files in parts, then validates and saves the asset. The control panel supports progress, cancellation, and retry for asset uploads and replacements. Existing multipart form uploads remain supported. +Craft uploads files in parts, then validates and saves the asset. The control panel supports progress, cancellation, and retry for asset uploads and replacements. All asset uploads use upload sessions. ## Configuration @@ -97,4 +97,10 @@ The contract has four methods: Uploaders are cached by name. Keep upload-specific state on the session and resolve its disk through `Filesystems::disk($session->disk)`. Keep provider credentials on the server. -Craft handles authorization, expiry, asset validation and creation, browser retries, and progress. The control panel’s `Craft.createUploader()` factory still supports custom JavaScript uploader classes registered for a filesystem type. Custom legacy uploaders can opt out of upload sessions with `uploadSessions: false`. +Craft handles authorization, expiry, asset validation and creation, browser retries, and progress. The control panel’s `Craft.createUploader()` factory still supports custom JavaScript uploader classes registered for a filesystem type. + +## Migrating existing integrations + +The `assets/upload` and `assets/replace-file` multipart endpoints have been removed. Send files through `AssetUpload` or the upload-session endpoints instead. Custom JavaScript uploaders must use upload sessions; `uploadSessions: false` is no longer supported. + +After a filename conflict, use `assets/resolve-upload-conflict` to replace an existing asset or unindexed file with the already-uploaded asset. Supply `sourceAssetId` and either `assetId` or `targetFilename`. This endpoint does not receive file bytes. diff --git a/packages/craftcms-legacy/cp/src/js/AssetIndex.js b/packages/craftcms-legacy/cp/src/js/AssetIndex.js index 2a84b918f8e..8bffb4211d3 100644 --- a/packages/craftcms-legacy/cp/src/js/AssetIndex.js +++ b/packages/craftcms-legacy/cp/src/js/AssetIndex.js @@ -600,7 +600,7 @@ Craft.AssetIndex = Craft.BaseElementIndex.extend( var doFollowup = (parameterArray, parameterIndex, callback) => { var data = {}; var action = null; - const {replaceAction, deleteAction} = this.uploader.settings; + const {resolveConflictAction, deleteAction} = this.uploader.settings; const followupAlways = () => { parameterIndex++; @@ -626,7 +626,7 @@ Craft.AssetIndex = Craft.BaseElementIndex.extend( }; if (parameterArray[parameterIndex].choice === 'replace') { - action = replaceAction; + action = resolveConflictAction; data.sourceAssetId = parameterArray[parameterIndex].assetId; if (parameterArray[parameterIndex].conflictingAssetId) { diff --git a/packages/craftcms-legacy/cp/src/js/ImageUpload.js b/packages/craftcms-legacy/cp/src/js/ImageUpload.js index 850264d39a8..5d7fa176966 100644 --- a/packages/craftcms-legacy/cp/src/js/ImageUpload.js +++ b/packages/craftcms-legacy/cp/src/js/ImageUpload.js @@ -43,7 +43,14 @@ Craft.ImageUpload = Garnish.Base.extend( options.events.fileuploaddone = this._onUploadComplete.bind(this); options.events.fileuploadfail = this._onUploadFailure.bind(this); - this.uploader = Craft.createUploader(null, this.$container, options); + for (const [name, handler] of Object.entries(options.events)) { + this.$container.on(name, handler); + } + this.uploader = this.$container.fileupload({ + ...options, + autoUpload: true, + sequentialUploads: true, + }); this.initButtons(); }, @@ -113,7 +120,7 @@ Craft.ImageUpload = Garnish.Base.extend( this.refreshImage(data.result); // Last file - if (this.uploader.isLastUpload()) { + if (this.uploader.fileupload('active') < 2) { this.progressBar.hideProgressBar(); this.$container.removeClass('uploading'); } diff --git a/resources/js/modules/markdown-field/behaviors/uploads.test.ts b/resources/js/modules/markdown-field/behaviors/uploads.test.ts new file mode 100644 index 00000000000..f81688c2f37 --- /dev/null +++ b/resources/js/modules/markdown-field/behaviors/uploads.test.ts @@ -0,0 +1,42 @@ +import {afterEach, expect, it, vi} from 'vitest'; +import {AssetUpload, UploadError} from '@/upload-client'; +import {fileUploadOptions} from './uploads'; + +const {flash} = vi.hoisted(() => ({flash: vi.fn()})); +vi.mock('@craftcms/ui', () => ({t: (message: string) => message})); +vi.mock('@/common/composables/useFlashMessages', () => ({ + useFlashMessages: () => ({flash}), +})); + +afterEach(() => { + vi.restoreAllMocks(); + flash.mockReset(); +}); + +it.each([ + ['image/png', '![uploaded.png]({asset:42@2:url})'], + ['text/plain', '[uploaded.png]({asset:42@2:url})'], +])( + 'inserts the asset returned by the upload session for %s', + async (type, markdown) => { + vi.spyOn(AssetUpload.prototype, 'upload').mockResolvedValue({ + assetId: 42, + filename: 'uploaded.png', + }); + const file = new File(['contents'], 'original.png', {type}); + + await expect(fileUploadOptions(12, 2)!.onInsertFile!(file)).resolves.toBe( + markdown + ); + } +); + +it('reports an upload failure without inserting a link', async () => { + const error = new UploadError('The file is not allowed.', 422); + vi.spyOn(AssetUpload.prototype, 'upload').mockRejectedValue(error); + + await expect( + fileUploadOptions(12, 2)!.onInsertFile!(new File(['contents'], 'file.txt')) + ).rejects.toBe(error); + expect(flash).toHaveBeenCalledWith('error', 'The file is not allowed.'); +}); diff --git a/resources/js/modules/markdown-field/behaviors/uploads.ts b/resources/js/modules/markdown-field/behaviors/uploads.ts index a5933364537..d8bd73134fb 100644 --- a/resources/js/modules/markdown-field/behaviors/uploads.ts +++ b/resources/js/modules/markdown-field/behaviors/uploads.ts @@ -1,28 +1,12 @@ import {t} from '@craftcms/ui'; -import {useHttp} from '@inertiajs/vue3'; import type {Options} from 'overtype'; -import {upload as uploadAsset} from '@actions/Assets/UploadController'; +import {store} from '@/routes/craft/actions/craft/cp/uploads'; +import {AssetUpload, type UploadResult} from '@/upload-client'; import {useFlashMessages} from '@/common/composables/useFlashMessages'; import {escapeMarkdownLabel} from './utilities'; -import axios from 'axios'; const ASSET_REF_HANDLE = 'asset'; -type AssetUploadResponse = { - assetId?: number | string; - filename?: string; - message?: string; -}; - -type UploadRequest = { - 'assets-upload': File; - folderId: string; -}; - -interface UploadErrorEnvelope { - response?: {data?: string}; -} - const {flash} = useFlashMessages(); export function fileUploadOptions( @@ -36,7 +20,7 @@ export function fileUploadOptions( return { batch: false, enabled: true, - // OverType defaults to 10 MB when omitted; keep this high so PHP validates uploads. + // Let the upload session enforce the configured asset size limit. maxSize: Number.MAX_SAFE_INTEGER, onInsertFile: (file) => { const upload = Array.isArray(file) ? file[0] : file; @@ -53,21 +37,19 @@ async function uploadFile( uploadFolderId: number, uploadSiteId: number | string ): Promise { - const uploadRequest = useHttp({ - 'assets-upload': file, - folderId: uploadFolderId.toString(), + const task = new AssetUpload(file, { + url: store.url(), + parameters: {folderId: uploadFolderId}, }); try { - const data = await uploadRequest.post(uploadAsset().url); + const data = await task.upload(); return uploadedAssetMarkdown(file, data, uploadSiteId); } catch (error) { flash( 'error', - uploadErrorMessage( - error instanceof Object ? error : new Error(String(error)) - ) + error instanceof Error ? error.message : t('Couldn’t upload file.') ); throw error; @@ -76,7 +58,7 @@ async function uploadFile( function uploadedAssetMarkdown( file: File, - response: AssetUploadResponse, + response: UploadResult, uploadSiteId: number | string ): string { if (!response.assetId) { @@ -90,30 +72,3 @@ function uploadedAssetMarkdown( ? `![${label}](${ref})` : `[${label || ref}](${ref})`; } - -function uploadErrorMessage(error: Error | UploadErrorEnvelope): string { - const responseData = axios.isAxiosError(error) - ? error.response?.data - : undefined; - const responseMessage = - Object(responseData).constructor === String - ? parseUploadErrorMessage(String(responseData)) - : undefined; - - if (responseMessage) { - return responseMessage; - } - - return error instanceof Error ? error.message : t('Couldn’t upload file.'); -} - -function parseUploadErrorMessage(responseData: string): string | undefined { - try { - // SAFETY: The upload error endpoint serializes an object with an optional message string. - const data = JSON.parse(responseData) as {message?: string}; - - return data.message; - } catch { - return undefined; - } -} diff --git a/resources/js/modules/uploader/base-uploader.ts b/resources/js/modules/uploader/base-uploader.ts index 5360cb1ce84..3881988dd4b 100644 --- a/resources/js/modules/uploader/base-uploader.ts +++ b/resources/js/modules/uploader/base-uploader.ts @@ -21,8 +21,6 @@ const DEFAULTS = { headers: {Accept: 'application/json;q=0.9,*/*;q=0.8'}, paramName: 'assets-upload', url: null, - createAction: null, - replaceAction: null, deleteAction: null, replace: false, }; @@ -65,12 +63,6 @@ export class BaseUploader extends Base { this.$fileInput = this.settings.fileInput || $element; this.events = this.settings.events; - if (!this.settings.url) { - this.settings.url = this.settings.replace - ? Craft.getActionUrl(this.settings.replaceAction) - : Craft.getActionUrl(this.settings.createAction); - } - if (this.settings.allowedKinds && this.settings.allowedKinds.length) { if (Object(this.settings.allowedKinds).constructor === String) { this.settings.allowedKinds = [String(this.settings.allowedKinds)]; diff --git a/resources/js/modules/uploader/uploader.test.ts b/resources/js/modules/uploader/uploader.test.ts index 89c2c2ce8bb..546d53635ca 100644 --- a/resources/js/modules/uploader/uploader.test.ts +++ b/resources/js/modules/uploader/uploader.test.ts @@ -24,7 +24,8 @@ beforeEach(() => { uploader.$element = {trigger, off: vi.fn()}; uploader.uploader = {fileupload: vi.fn()}; uploader.events = {}; - uploader.settings = {}; + uploader.settings = {url: '/uploads', maxFileSize: Number.MAX_SAFE_INTEGER}; + uploader.processErrorMessages = vi.fn(); uploader._inProgressCounter = 0; }); @@ -39,9 +40,9 @@ it('does not cancel a successfully canceled upload again during teardown', async ); const cancel = vi.spyOn(AssetUpload.prototype, 'cancel').mockResolvedValue(); const file = new File(['abc'], 'document.txt'); - const data = {files: [file], abort: async () => {}}; + const data = fileData(file); - uploader['uploadFile'](file, data); + uploader.onFileAdd({stopPropagation: vi.fn()}, data); await vi.waitFor(() => expect(trigger).toHaveBeenCalledWith('fileuploadstop') ); @@ -64,9 +65,9 @@ it('allows retry after a replacement failure handler throws', async () => { .mockRejectedValueOnce(new UploadError('Upload failed.', 500)) .mockResolvedValueOnce({assetId: 123}); const file = new File(['abc'], 'replacement.txt'); - const data = {files: [file], submit: () => {}}; + const data = fileData(file); - uploader['uploadFile'](file, data); + uploader.onFileAdd({stopPropagation: vi.fn()}, data); await vi.waitFor(() => expect(reportError).toHaveBeenCalledWith(handlerError) ); @@ -82,3 +83,13 @@ it('allows retry after a replacement failure handler throws', async () => { trigger.mock.calls.filter(([name]) => name === 'fileuploadstop') ).toHaveLength(2); }); + +function fileData(file: File) { + return { + files: [file], + originalFiles: [file], + process: () => ({done: (callback: () => void) => callback()}), + abort: async () => {}, + submit: () => {}, + }; +} diff --git a/resources/js/modules/uploader/uploader.ts b/resources/js/modules/uploader/uploader.ts index a22f79b22ec..3d9c32d6411 100644 --- a/resources/js/modules/uploader/uploader.ts +++ b/resources/js/modules/uploader/uploader.ts @@ -2,7 +2,6 @@ import {BaseUploader} from './base-uploader'; import {UploadNotification} from './upload-notification'; import {AssetUpload, UploadError} from '@/upload-client'; import {store} from '@/routes/craft/actions/craft/cp/uploads'; -import {upload, replaceFile} from '@actions/Assets/UploadController'; // blueimp jQuery File Upload plugin seam — see base-uploader.ts. declare const $: any; @@ -11,12 +10,8 @@ declare const Craft: any; const DEFAULTS = { autoUpload: false, sequentialUploads: true, - // Resolved from `Craft.maxUploadSize` via the static getter (see below). - // SAFETY: Upload size is nullable until the runtime Craft config is available. - maxFileSize: null as number | null, replaceFileInput: false, - createAction: 'assets/upload', - replaceAction: 'assets/replace-file', + resolveConflictAction: 'assets/resolve-upload-conflict', deleteAction: 'assets/delete-asset', }; @@ -31,14 +26,17 @@ export class Uploader extends BaseUploader { _totalFileCounter = 0; _validFileCounter = 0; _onFileAdd: any = null; - private sessionUploads = false; private destroyed = false; private queue: Promise = Promise.resolve(); private queued = new Set(); private uploads = new Map(); static override get defaults(): any { - return {...DEFAULTS, maxFileSize: Craft.maxUploadSize}; + return { + ...DEFAULTS, + url: store.url(), + maxFileSize: Craft.maxAssetUploadSize, + }; } constructor($element?: any, settings?: any) { @@ -49,26 +47,10 @@ export class Uploader extends BaseUploader { } override init($element: any, settings: any): void { - const customMaxFileSize = settings?.maxFileSize; settings = $.extend({}, Uploader.defaults, settings); super.init($element, settings); delete this.settings.events; - const target = new URL(this.settings.url, location.href); - const expected = new URL( - this.settings.replace ? replaceFile.url() : upload.url(), - location.href - ); - this.sessionUploads = - this.settings.uploadSessions !== false && - (target.pathname === expected.pathname || - target.searchParams.get('action') === - (this.settings.replace ? 'assets/replace-file' : 'assets/upload')); - - if (this.sessionUploads && customMaxFileSize == null) { - this.settings.maxFileSize = Craft.maxAssetUploadSize; - } - this.uploader = this.$element.fileupload(this.settings); Object.entries(this.events).forEach(([name, handler]) => { @@ -79,28 +61,6 @@ export class Uploader extends BaseUploader { this.$element.on('fileuploadadd', this._onFileAdd); } - /** - * Set uploader parameters. - */ - override setParams(paramObject: any): void { - super.setParams(paramObject); - - // Only set params if the uploader has been initialized - // It won't be if the input is disabled - if (this.uploader.data('blueimpFileupload')) { - this.uploader.fileupload('option', {formData: this.formData}); - } - } - - /** - * Get the number of uploads in progress. - */ - override getInProgress(): number { - return this.sessionUploads - ? this._inProgressCounter - : this.uploader.fileupload('active'); - } - /** * Called on file add. */ @@ -149,11 +109,7 @@ export class Uploader extends BaseUploader { if (pass) { this._validFileCounter++; - if (this.sessionUploads) { - this.uploadFile(file, data); - } else { - data.submit(); - } + this.uploadFile(file, data); } if (++this._totalFileCounter === data.originalFiles.length) { @@ -168,7 +124,7 @@ export class Uploader extends BaseUploader { private uploadFile(file: File, data: any): void { const task = new AssetUpload(file, { - url: store.url(), + url: this.settings.url, parameters: { ...this.formData, operation: this.settings.replace ? 'replace' : 'upload', diff --git a/resources/js/pages/assets/AssetUploadButton.test.ts b/resources/js/pages/assets/AssetUploadButton.test.ts index 88b35ac7686..7d9743694b9 100644 --- a/resources/js/pages/assets/AssetUploadButton.test.ts +++ b/resources/js/pages/assets/AssetUploadButton.test.ts @@ -68,7 +68,7 @@ it('opens the file picker and configures uploads for the selected folder', async input, expect.objectContaining({ fileInput: input, - url: '/admin/actions/assets/upload', + url: '/admin/actions/assets/uploads', }) ); expect(setParams).toHaveBeenCalledWith({folderId: 12}); diff --git a/resources/js/pages/assets/AssetUploadButton.vue b/resources/js/pages/assets/AssetUploadButton.vue index c882764019f..dbd3b67f66b 100644 --- a/resources/js/pages/assets/AssetUploadButton.vue +++ b/resources/js/pages/assets/AssetUploadButton.vue @@ -2,7 +2,7 @@ import {t} from '@craftcms/ui'; import {router} from '@inertiajs/vue3'; import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue'; - import {upload} from '@actions/Assets/UploadController'; + import {store} from '@/routes/craft/actions/craft/cp/uploads'; declare const $: any; @@ -41,7 +41,7 @@ ); const emit = defineEmits<{ - /** One completed upload, as `assets/upload` reported it. */ + /** One completed upload, as the upload session reported it. */ (event: 'uploaded', asset: {id: number; label: string}): void; }>(); @@ -66,7 +66,7 @@ // Files dropped on the caller's container upload as if picked, which is // what makes a relation field a drop target. ...(props.dropZone ? {dropZone: $(props.dropZone)} : {}), - url: upload.url(), + url: store.url(), events: { // jQuery File Upload calls this as `(event, data)` with the parsed // response on `data.result`; the CustomEvent branch covers an uploader @@ -74,7 +74,7 @@ fileuploaddone: (event: Event, data: any = null) => { Craft.cp?.runQueue?.(); - // `assets/upload` answers with the new asset's id and filename. A + // The upload session answers with the new asset's id and filename. A // filename conflict answers with `conflict` instead and is resolved // separately, so there is nothing to attach yet. const result = diff --git a/routes/actions.php b/routes/actions.php index 9feeae4a558..c7f4ad48539 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -17,8 +17,8 @@ use CraftCms\Cms\Http\Controllers\Assets\IconController as AssetsIconController; use CraftCms\Cms\Http\Controllers\Assets\ImageEditorController; use CraftCms\Cms\Http\Controllers\Assets\PreviewController as AssetsPreviewController; +use CraftCms\Cms\Http\Controllers\Assets\ResolveUploadConflictController; use CraftCms\Cms\Http\Controllers\Assets\TransformController; -use CraftCms\Cms\Http\Controllers\Assets\UploadController as AssetsUploadController; use CraftCms\Cms\Http\Controllers\Assets\UploadSessionController; use CraftCms\Cms\Http\Controllers\Auth\LoginController; use CraftCms\Cms\Http\Controllers\Auth\PasskeyController; @@ -338,8 +338,7 @@ Route::post('asset-indexes/finish-indexing-session', [AssetIndexesController::class, 'finishIndexingSession']); // Assets - Route::post('assets/upload', [AssetsUploadController::class, 'upload']); - Route::post('assets/replace-file', [AssetsUploadController::class, 'replaceFile']); + Route::post('assets/resolve-upload-conflict', ResolveUploadConflictController::class); Route::post('assets/delete-asset', [AssetsActionController::class, 'deleteAsset']); Route::post('assets/move-asset', [AssetsActionController::class, 'moveAsset']); Route::post('assets/download-asset', [AssetsActionController::class, 'downloadAsset']); diff --git a/src/Asset/AssetUploadHandler.php b/src/Asset/AssetUploadHandler.php index 599d221dca6..2f6d87465f7 100644 --- a/src/Asset/AssetUploadHandler.php +++ b/src/Asset/AssetUploadHandler.php @@ -16,7 +16,6 @@ use CraftCms\Cms\Field\Fields; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\I18N; -use CraftCms\Cms\Support\File; use CraftCms\Cms\Translation\Formatter; use Illuminate\Support\Facades\Gate; use Throwable; @@ -81,15 +80,13 @@ public function resolveTarget(array $parameters, bool $authorizedGuest = false): /** @param array $parameters */ public function store( array $parameters, - string $originalName, - string $mimeType, - ?string $tempPath = null, - ?UploadedAssetFile $source = null, + UploadedAssetFile $file, bool $authorizedGuest = false, ?int $uploaderId = null, ): UploadResult { [$folder, $selectionCondition] = $this->resolveTarget($parameters, $authorizedGuest); + $originalName = $file->filename; $filename = AssetsHelper::prepareAssetName($originalName); if ($selectionCondition) { @@ -105,13 +102,12 @@ public function store( } $asset = new Asset; - $asset->tempFilePath = $tempPath; - $asset->uploadSource = $source; + $asset->uploadSource = $file; if ($authorizedGuest) { $asset->sanitizeOnUpload = true; } $asset->setFilename($filename); - $asset->setMimeType($tempPath ? (File::getMimeType($tempPath, checkExtension: false) ?? $mimeType) : $mimeType); + $asset->setMimeType($file->mimeType()); $asset->newFolderId = $folder->id; $asset->setVolumeId($folder->volumeId); $asset->uploaderId = $uploaderId; diff --git a/src/Asset/AssetUploads.php b/src/Asset/AssetUploads.php index d249f8cbb24..c0fe7b40ad9 100644 --- a/src/Asset/AssetUploads.php +++ b/src/Asset/AssetUploads.php @@ -119,9 +119,7 @@ public function complete(Request $request, string $id): UploadResult ? $this->uploads->replace((int) $session->parameters['assetId'], $file) : $this->uploads->store( $session->parameters, - $file->filename, - $file->mimeType(), - source: $file, + $file, authorizedGuest: ! $request->user(), uploaderId: $request->craftUser()?->getCraftUserId(), ); diff --git a/src/Http/Controllers/Assets/ResolveUploadConflictController.php b/src/Http/Controllers/Assets/ResolveUploadConflictController.php new file mode 100644 index 00000000000..0317518d3cf --- /dev/null +++ b/src/Http/Controllers/Assets/ResolveUploadConflictController.php @@ -0,0 +1,77 @@ +validate([ + 'sourceAssetId' => ['required', 'integer', 'min:1'], + 'assetId' => ['nullable', 'integer', 'min:1', 'different:sourceAssetId'], + 'targetFilename' => ['required_without:assetId', 'nullable', 'string', 'max:255'], + ]); + + $sourceAsset = $this->assets->getAssetById($request->integer('sourceAssetId')); + abort_unless($sourceAsset !== null, 404, 'Asset not found.'); + Gate::authorize('replaceFile', $sourceAsset); + $targetFilename = $request->input('targetFilename'); + + if ($assetId = $request->integer('assetId')) { + $assetToReplace = $this->assets->getAssetById($assetId); + abort_unless($assetToReplace !== null, 404, 'Asset not found.'); + } else { + abort_if(str_contains($targetFilename, '/') || str_contains($targetFilename, '\\'), 422, 'Invalid filename.'); + abort_unless(pathinfo($targetFilename, PATHINFO_EXTENSION) === $sourceAsset->getExtension(), 422, 'The filename must retain the original file extension.'); + + $assetToReplace = Asset::find() + ->folderId($sourceAsset->folderId) + ->filename(Query::escapeParam($targetFilename)) + ->one(); + } + + if ($assetToReplace !== null) { + abort_if($assetToReplace->id === $sourceAsset->id, 422, 'An asset cannot replace itself.'); + Gate::authorize('replaceFile', $assetToReplace); + $this->assets->replaceAssetFile($assetToReplace, $sourceAsset->getCopyOfFile(), $assetToReplace->getFilename(), $sourceAsset->getMimeType()); + + if ($assetToReplace->errors()->isNotEmpty()) { + return $this->asModelFailure($assetToReplace); + } + + $this->elements->deleteElement($sourceAsset); + } else { + $sourceAsset->getVolume()->sourceDisk()->delete(rtrim((string) $sourceAsset->folderPath, '/').'/'.$targetFilename); + $sourceAsset->newFilename = $targetFilename; + + if (! $this->elements->saveElement($sourceAsset)) { + return $this->asModelFailure($sourceAsset); + } + } + + $resultingAsset = $assetToReplace ?? $sourceAsset; + + return new JsonResponse($this->uploads->replacementResult($resultingAsset, $resultingAsset->id)->payload()); + } +} diff --git a/src/Http/Controllers/Assets/UploadController.php b/src/Http/Controllers/Assets/UploadController.php deleted file mode 100644 index ae513912e5c..00000000000 --- a/src/Http/Controllers/Assets/UploadController.php +++ /dev/null @@ -1,208 +0,0 @@ -file('assets-upload'); - - abort_if(is_array($uploadedFile), 400, 'Exactly one file must be uploaded'); - - abort_if(! $uploadedFile, 400, 'No file was uploaded'); - - $tempPath = $this->getUploadedFileTempPath($uploadedFile); - - return $this->respond($this->uploads->store( - $request->all(), - $uploadedFile->getClientOriginalName(), - $uploadedFile->getClientMimeType(), - $tempPath, - uploaderId: $request->craftUser()?->getCraftUserId(), - )); - } - - public function replaceFile(Request $request): Response - { - $assetId = $request->integer('assetId'); - $sourceAssetId = $request->integer('sourceAssetId'); - $targetFilename = $request->input('targetFilename'); - - if ( - $targetFilename && - (str_contains((string) $targetFilename, '/') || str_contains((string) $targetFilename, '\\')) - ) { - abort(400, 'Invalid filename: $targetFilename'); - } - - $uploadedFile = $request->file('replaceFile'); - - if (is_array($uploadedFile)) { - abort(400, 'Exactly one file must be uploaded.'); - } - - // Must have at least one existing asset (source or target). - // Must have either target asset or target filename. - // Must have either uploaded file or source asset. - if ((empty($assetId) && empty($sourceAssetId)) || - (empty($assetId) && empty($targetFilename)) || - ($uploadedFile === null && empty($sourceAssetId)) - ) { - abort(400, 'Incorrect combination of parameters.'); - } - - $sourceAsset = null; - $assetToReplace = null; - - if ($assetId && ! $assetToReplace = $this->assets->getAssetById($assetId)) { - abort(404, 'Asset not found.'); - } - - if ($sourceAssetId && ! $sourceAsset = $this->assets->getAssetById($sourceAssetId)) { - abort(404, 'Asset not found.'); - } - - if ($assetToReplace) { - Gate::authorize('replaceFile', $assetToReplace); - } - - if ($sourceAsset) { - Gate::authorize('replaceFile', $sourceAsset); - } - - // Handle the Element Action - if ($assetToReplace !== null && $uploadedFile) { - $tempPath = $this->getUploadedFileTempPath($uploadedFile); - $filename = AssetsHelper::prepareAssetName($uploadedFile->getClientOriginalName()); - $this->assets->replaceAssetFile($assetToReplace, $tempPath, $filename, $uploadedFile->getClientMimeType()); - } elseif ($sourceAsset !== null) { - // Or replace using an existing Asset - if ($assetToReplace === null) { - // Make sure the extension didn't change - if (pathinfo((string) $targetFilename, PATHINFO_EXTENSION) !== $sourceAsset->getExtension()) { - abort(400, $targetFilename.' doesn\'t have the original file extension.'); - } - - /** @var Asset|null $assetToReplace */ - $assetToReplace = Asset::find() - ->select(['elements.id']) - ->folderId($sourceAsset->folderId) - ->filename(Query::escapeParam($targetFilename)) - ->one(); - - if ($assetToReplace) { - Gate::authorize('replaceFile', $assetToReplace); - } - } - - if (! empty($assetToReplace)) { - $tempPath = $sourceAsset->getCopyOfFile(); - $this->assets->replaceAssetFile($assetToReplace, $tempPath, $assetToReplace->getFilename(), $sourceAsset->getMimeType()); - $this->elements->deleteElement($sourceAsset); - } else { - $volume = $sourceAsset->getVolume(); - $volume->sourceDisk()->delete(rtrim((string) $sourceAsset->folderPath, '/').'/'.$targetFilename); - $sourceAsset->newFilename = $targetFilename; - $this->elements->saveElement($sourceAsset); - $assetId = $sourceAsset->id; - } - } - - $resultingAsset = $assetToReplace ?: $sourceAsset; - - return $this->respond($this->uploads->replacementResult($resultingAsset, $assetId)); - } - - private function respond(UploadResult $result): Response - { - if ($result->status !== 200) { - return $this->asFailure($result->message ?? null, $result->payload()); - } - - if (isset($result->conflict)) { - return new JsonResponse($result->payload()); - } - - return $this->asSuccess(data: $result->payload()); - } - - /** - * @throws UploadFailedException - */ - private function getUploadedFileTempPath(UploadedFile $uploadedFile): string - { - if ($uploadedFile->getError() !== UPLOAD_ERR_OK) { - throw new UploadFailedException($uploadedFile->getError()); - } - - // Make sure the file extension is allowed - $allowedExtensions = Cms::config()->allowedFileExtensions; - $extension = strtolower(pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_EXTENSION)); - - if (! in_array($extension, $allowedExtensions, true)) { - throw new AssetDisallowedExtensionException(t('“{extension}” is not an allowed file extension.', [ - 'extension' => $extension, - ])); - } - - // Move the uploaded file to the temp folder - $tempPath = $this->saveAsTempFile($uploadedFile); - - if ($tempPath === false) { - throw new UploadFailedException(UPLOAD_ERR_CANT_WRITE); - } - - return $tempPath; - } - - private function saveAsTempFile(UploadedFile $uploadedFile): string|false - { - $tempPath = tempnam(sys_get_temp_dir(), 'craft-upload-'); - - if ($tempPath === false) { - return false; - } - - try { - $uploadedFile->move(dirname($tempPath), basename($tempPath)); - } catch (Throwable) { - File::delete($tempPath); - - return false; - } - - return $tempPath; - } -} diff --git a/tests/Feature/Http/Controllers/Assets/ResolveUploadConflictControllerTest.php b/tests/Feature/Http/Controllers/Assets/ResolveUploadConflictControllerTest.php new file mode 100644 index 00000000000..c41e22467a9 --- /dev/null +++ b/tests/Feature/Http/Controllers/Assets/ResolveUploadConflictControllerTest.php @@ -0,0 +1,106 @@ +set('filesystems.disks.upload-conflicts', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/upload-conflicts'), + ]); + Storage::fake('upload-conflicts'); + $volume = Volume::factory()->create(['fs' => 'disk:upload-conflicts']); + $this->folder = VolumeFolder::factory()->create(['volumeId' => $volume->id, 'path' => '']); + $this->source = AssetModel::factory()->createElement([ + 'volumeId' => $volume->id, + 'folderId' => $this->folder->id, + 'filename' => 'document_1.txt', + 'kind' => 'text', + 'size' => 11, + ]); + Storage::disk('upload-conflicts')->put('document_1.txt', 'replacement'); + Storage::disk('upload-conflicts')->put('document.txt', 'original'); +}); + +it('requires authentication', function () { + auth()->logout(); + + postJson(action(ResolveUploadConflictController::class))->assertUnauthorized(); +}); + +it('requires an existing source asset and a replacement target', function (array $parameters) { + postJson(action(ResolveUploadConflictController::class), $parameters)->assertUnprocessable(); + + expect(Storage::disk('upload-conflicts')->get('document.txt'))->toBe('original'); +})->with([ + 'missing source' => [['targetFilename' => 'document.txt']], + 'missing target' => [['sourceAssetId' => '1']], +]); + +it('resolves a conflict using an already uploaded asset', function (string $target) { + $parameters = ['sourceAssetId' => (string) $this->source->id]; + $expectedId = $this->source->id; + + if ($target !== 'unindexed file') { + $asset = AssetModel::factory()->createElement([ + 'volumeId' => $this->source->volumeId, + 'folderId' => $this->folder->id, + 'filename' => 'document.txt', + 'kind' => 'text', + ]); + $expectedId = $asset->id; + } + + if ($target === 'asset ID') { + $parameters['assetId'] = (string) $expectedId; + } else { + $parameters['targetFilename'] = 'document.txt'; + } + + postJson(action(ResolveUploadConflictController::class), $parameters) + ->assertOk()->assertJsonPath('assetId', $expectedId)->assertJsonPath('filename', 'document.txt'); + + expect(Storage::disk('upload-conflicts')->get('document.txt'))->toBe('replacement') + ->and(Asset::find()->folderId($this->folder->id)->count())->toBe(1); + Storage::disk('upload-conflicts')->assertMissing('document_1.txt'); +})->with(['asset ID', 'asset filename', 'unindexed file']); + +it('checks replacement permissions for both assets', function (string $deniedAsset) { + $target = AssetModel::factory()->createElement([ + 'volumeId' => $this->source->volumeId, + 'folderId' => $this->folder->id, + 'filename' => 'document.txt', + 'kind' => 'text', + ]); + $deniedId = $deniedAsset === 'source' ? $this->source->id : $target->id; + Gate::partialMock()->shouldReceive('authorize')->andReturnUsing(function (string $ability, mixed $asset = null) use ($deniedId): Response { + if ($ability === 'replaceFile' && $asset->id === $deniedId) { + throw new AuthorizationException; + } + + return Response::allow(); + }); + + postJson(action(ResolveUploadConflictController::class), [ + 'sourceAssetId' => $this->source->id, + 'assetId' => $target->id, + ])->assertForbidden(); + + expect(Storage::disk('upload-conflicts')->get('document.txt'))->toBe('original') + ->and(Storage::disk('upload-conflicts')->get('document_1.txt'))->toBe('replacement') + ->and(Asset::find()->folderId($this->folder->id)->count())->toBe(2); +})->with(['source', 'target']); diff --git a/tests/Feature/Http/Controllers/Assets/UploadControllerTest.php b/tests/Feature/Http/Controllers/Assets/UploadControllerTest.php deleted file mode 100644 index 24e3fc50773..00000000000 --- a/tests/Feature/Http/Controllers/Assets/UploadControllerTest.php +++ /dev/null @@ -1,124 +0,0 @@ -set('filesystems.disks.test-disk', [ - 'driver' => 'local', - 'root' => storage_path('framework/testing/upload-controller-test/test-disk'), - ]); - - $this->volume = Volume::factory()->create(['fs' => 'disk:test-disk']); - $this->folder = VolumeFolderModel::factory()->create(['volumeId' => $this->volume->id]); -}); - -it('requires authentication', function (string $action) { - auth()->logout(); - - postJson(action([UploadController::class, $action])) - ->assertUnauthorized(); -})->with(['upload', 'replaceFile']); - -it('requires a file or field for upload', function () { - postJson(action([UploadController::class, 'upload']), [ - 'folderId' => $this->folder->id, - ])->assertBadRequest(); -}); - -it('uploads to a dynamic field location using posted element context', function () { - $result = EntryModel::factory() - ->withField('coverImage', AssetsField::class, [ - 'defaultUploadLocationSource' => "volume:{$this->volume->uid}", - 'defaultUploadLocationSubpath' => '{uid}', - ]) - ->createElementWithFields(['title' => 'Hub Resource']); - - $entry = $result->element; - $field = $result->fields->get('coverImage'); - - post(action([UploadController::class, 'upload']), [ - 'fieldId' => (string) $field->id, - 'elementId' => (string) $entry->id, - 'siteId' => (string) $entry->siteId, - 'assets-upload' => UploadedFile::fake()->image('cover.jpg'), - ], [ - 'Accept' => 'application/json', - ]) - ->assertOk() - ->assertJson([ - 'filename' => 'cover.jpg', - ]); - - expect($this->folder->newQuery() - ->where('volumeId', $this->volume->id) - ->where('path', "{$entry->uid}/") - ->exists())->toBeTrue(); -}); - -it('validates replace file parameters', function () { - postJson(action([UploadController::class, 'replaceFile']))->assertBadRequest(); -}); - -it('casts posted asset IDs before looking them up', function (Closure $requestData) { - post(action([UploadController::class, 'replaceFile']), $requestData(), [ - 'Accept' => 'application/json', - ])->assertNotFound(); -})->with([ - 'target asset' => [fn () => [ - 'assetId' => '999999', - 'replaceFile' => UploadedFile::fake()->image('replacement.jpg'), - ]], - 'source asset' => [fn () => [ - 'sourceAssetId' => '999999', - 'targetFilename' => 'replacement.jpg', - ]], -]); - -it('reports rejected field uploads in the requested response format', function (bool $json) { - $result = EntryModel::factory() - ->withField('attachment', AssetsField::class, [ - 'defaultUploadLocationSource' => "volume:{$this->volume->uid}", - 'selectionCondition' => [ - 'class' => AssetCondition::class, - 'conditionRules' => [[ - 'class' => FileTypeConditionRule::class, - 'values' => ['image'], - ]], - ], - ])->createElementWithFields([]); - - $response = post(action([UploadController::class, 'upload']), [ - 'fieldId' => $result->fields->get('attachment')->id, - 'elementId' => $result->element->id, - 'assets-upload' => UploadedFile::fake()->createWithContent('attachment.txt', 'abc'), - ], ['Accept' => $json ? 'application/json' : 'text/html']); - - $message = 'attachment.txt isn’t selectable for this field.'; - if ($json) { - $response->assertBadRequest()->assertJsonPath('message', $message); - } else { - $response->assertRedirect()->assertSessionHas('error', $message); - } - - expect(Asset::find()->count())->toBe(0); -})->with([ - 'JSON' => [true], - 'HTML' => [false], -]); diff --git a/tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php b/tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php index fe4d29c838c..4a7be48a564 100644 --- a/tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php +++ b/tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use CraftCms\Cms\Asset\AssetUploads; +use CraftCms\Cms\Asset\Conditions\AssetCondition; +use CraftCms\Cms\Asset\Conditions\FileTypeConditionRule; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Events\AssetFileHandling; use CraftCms\Cms\Asset\Models\UploadSession; @@ -275,3 +277,29 @@ 'volume root' => [''], 'volume subpath' => ['uploads/'], ]); + +it('rejects uploaded files that do not match the fields selection condition', function () { + $result = Entry::factory() + ->withField('attachment', Assets::class, [ + 'defaultUploadLocationSource' => "volume:{$this->volume->uid}", + 'selectionCondition' => [ + 'class' => AssetCondition::class, + 'conditionRules' => [[ + 'class' => FileTypeConditionRule::class, + 'values' => ['image'], + ]], + ], + ])->createElementWithFields([]); + + $session = postJson(action([UploadSessionController::class, 'store']), [ + 'filename' => 'attachment.txt', 'size' => 3, + 'fieldId' => $result->fields->get('attachment')->id, + 'elementId' => $result->element->id, + ])->assertCreated()->json(); + $part = postJson($session['urls']['part'], ['part' => 1])->assertOk()->json(); + $this->call('POST', $part['url'], server: ['CONTENT_TYPE' => 'application/octet-stream'], content: 'abc')->assertNoContent(); + + postJson($session['urls']['complete'])->assertBadRequest() + ->assertJsonPath('message', 'attachment.txt isn’t selectable for this field.'); + expect(Asset::find()->count())->toBe(0); +}); diff --git a/yii2-adapter/legacy/controllers/AssetsController.php b/yii2-adapter/legacy/controllers/AssetsController.php index 04dbec50d41..33b65c4c62f 100644 --- a/yii2-adapter/legacy/controllers/AssetsController.php +++ b/yii2-adapter/legacy/controllers/AssetsController.php @@ -7,16 +7,16 @@ * @license https://craftcms.github.io/license/ */ +declare(strict_types=1); + namespace craft\controllers; use craft\base\Event as YiiEvent; use craft\events\SaveAssetImageEvent; use craft\web\Controller; -use craft\web\UploadedFile; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Element\Validation\ElementRules; use CraftCms\Cms\Image\Events\ImageEditorSaving; -use CraftCms\Cms\Support\Facades\Deprecator; use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\Sites; use Illuminate\Support\Facades\Event; @@ -86,16 +86,10 @@ public static function registerEvents(): void * * @throws BadRequestHttpException * - * @deprecated in 4.0.0. Use `assets/upload` for uploads, or save elements directly. + * @deprecated in 4.0.0. Save elements directly. */ public function actionSaveAsset(): ?Response { - if (UploadedFile::getInstanceByName('assets-upload') !== null) { - Deprecator::log(__METHOD__, 'Uploading new files via `assets/save-asset` has been deprecated. Use `assets/upload` instead.'); - - return $this->runAction('upload'); - } - $assetId = $this->request->getBodyParam('sourceId') ?? $this->request->getRequiredParam('assetId'); $siteId = $this->request->getBodyParam('siteId'); $assetVariable = $this->request->getValidatedBodyParam('assetVariable') ?? 'asset'; From a08614b39fab5166cf4ebb3ac543664e91368385 Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 8 Sep 2026 17:00:52 +0200 Subject: [PATCH 3/7] Share upload sessions across assets and user photos --- CHANGELOG.md | 3 +- docs/uploads.md | 62 +++-- .../craftcms-legacy/cp/src/js/ImageUpload.js | 40 +--- .../userphoto/src/UserPhotoInput.js | 1 - .../markdown-field/behaviors/uploads.test.ts | 6 +- .../markdown-field/behaviors/uploads.ts | 6 +- .../js/modules/uploader/uploader.test.ts | 8 +- resources/js/modules/uploader/uploader.ts | 12 +- resources/js/upload-client.test.ts | 67 ++++-- resources/js/upload-client.ts | 30 ++- resources/js/uploads.ts | 10 +- routes/actions.php | 12 +- src/Asset/AssetUploadHandler.php | 6 +- src/Asset/AssetUploads.php | 219 ++---------------- src/Asset/Data/UploadSessionData.php | 24 -- src/Asset/Elements/Asset.php | 4 +- src/Config/GeneralConfig.php | 8 +- ...8_101230_create_upload_sessions_table.php} | 7 +- src/Database/Migrations/Install.php | 5 +- src/Database/Table.php | 2 +- src/Filesystem/Contracts/UploadHandler.php | 23 ++ .../Contracts/Uploader.php} | 12 +- .../Data/UploadPartRequest.php | 2 +- src/Filesystem/Data/UploadSessionData.php | 41 ++++ .../Data/UploadedFile.php} | 8 +- .../Models/UploadSession.php | 6 +- .../Uploaders.php} | 21 +- .../Uploaders/ChunkedUploader.php | 18 +- .../Uploaders/S3Uploader.php | 16 +- src/Filesystem/Uploads.php | 212 +++++++++++++++++ .../Actions/RemoveExpiredUploads.php | 4 +- .../Assets/UploadSessionController.php | 63 ++--- .../Controllers/UploadSessionController.php | 41 ++++ .../Controllers/Users/PhotoController.php | 109 ++------- src/Http/Requests/AssetUploadRequest.php | 26 +++ src/Http/Requests/UploadRequest.php | 14 +- src/User/UserPhotoUploads.php | 81 +++++++ .../Assets/UploadSessionControllerTest.php | 7 +- .../Controllers/User/PhotoControllerTest.php | 127 ++++++++-- .../{Asset => Filesystem}/S3UploaderTest.php | 10 +- .../UploadersTest.php} | 10 +- .../TypeScriptTransformerServiceProvider.php | 4 +- 42 files changed, 824 insertions(+), 563 deletions(-) delete mode 100644 src/Asset/Data/UploadSessionData.php rename src/Database/Migrations/{2026_09_08_101230_create_asset_upload_sessions_table.php => 2026_09_08_101230_create_upload_sessions_table.php} (80%) create mode 100644 src/Filesystem/Contracts/UploadHandler.php rename src/{Asset/Contracts/AssetUploader.php => Filesystem/Contracts/Uploader.php} (58%) rename src/{Asset => Filesystem}/Data/UploadPartRequest.php (92%) create mode 100644 src/Filesystem/Data/UploadSessionData.php rename src/{Asset/Data/UploadedAssetFile.php => Filesystem/Data/UploadedFile.php} (97%) rename src/{Asset => Filesystem}/Models/UploadSession.php (88%) rename src/{Asset/AssetUploaders.php => Filesystem/Uploaders.php} (56%) rename src/{Asset => Filesystem}/Uploaders/ChunkedUploader.php (88%) rename src/{Asset => Filesystem}/Uploaders/S3Uploader.php (92%) create mode 100644 src/Filesystem/Uploads.php create mode 100644 src/Http/Controllers/UploadSessionController.php create mode 100644 src/Http/Requests/AssetUploadRequest.php create mode 100644 src/User/UserPhotoUploads.php rename tests/Unit/{Asset => Filesystem}/S3UploaderTest.php (95%) rename tests/Unit/{Asset/AssetUploadersTest.php => Filesystem/UploadersTest.php} (88%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec387dc94f..cef4e213bb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ - Moved legacy relation-field settings HTML and entry-title input HTML into the Yii adapter. ([#19591](https://github.com/craftcms/cms/pull/19591)) - Migrated the reassign entries, replace relations, and replace references modals to the Form API. ([#19589](https://github.com/craftcms/cms/pull/19589)) -- Added chunked asset uploads, direct S3 multipart uploads, upload retry and cancellation controls, and an extensible upload-session API. +- Added shared upload sessions for assets and user photos, direct S3 multipart uploads, upload retry and cancellation controls, and extensible upload transports and destination handlers. +- Changed `users/upload-user-photo` to start an upload session using JSON file metadata instead of multipart file data. - Removed the `assets/upload` and `assets/replace-file` multipart endpoints. Asset uploads now use upload sessions, and filename conflicts are resolved through `assets/resolve-upload-conflict`. - Added support for refreshable standard plugin settings forms and conditional configuration of core form nodes. ([#19545](https://github.com/craftcms/cms/pull/19545)) - Added support for sending queued Laravel notifications to `CraftCms\Cms\User\Elements\User` elements. ([#19541](https://github.com/craftcms/cms/pull/19541)) diff --git a/docs/uploads.md b/docs/uploads.md index 8b250b959f3..c7a6b9992f5 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -1,6 +1,6 @@ -# Asset uploads +# File uploads -Craft uploads files in parts, then validates and saves the asset. The control panel supports progress, cancellation, and retry for asset uploads and replacements. All asset uploads use upload sessions. +Craft uploads files in parts, then passes the completed file to its destination handler. Asset uploads, asset replacements, and the user-photo widget share upload sessions, progress, cancellation, and retry. Small files use a single part. ## Configuration @@ -15,8 +15,9 @@ return GeneralConfig::create() ``` - `maxUploadFileSize` limits the complete file, independently of PHP’s per-request limit. -- `tempAssetUploadFs` selects the temporary filesystem by handle or `disk:name`. Laravel S3 disks receive bytes directly from the browser; other disks receive chunks through PHP. +- `tempAssetUploadFs` selects the shared temporary filesystem for asset and photo sessions by handle or `disk:name`. Laravel S3 disks receive bytes directly from the browser; other disks receive chunks through PHP. - `uploadChunkSize` defaults to 8 MiB for PHP uploads, capped by `post_max_size`. Lower it if your proxy or web server has a smaller request limit. S3 part sizes are selected separately. +- `uploader` optionally selects a registered transport driver. By default, Craft chooses S3 multipart for Laravel S3 disks and PHP chunks for other disks. - `uploadSessionDuration` defaults to 24 hours of inactivity. Craft garbage collection removes expired uploads. Asset processing can still require PHP memory, local temporary space, and time to finish saving. Direct S3 uploads do not eliminate those requirements. Transfers between separate disks stream through PHP. @@ -29,10 +30,10 @@ The application needs permissions to create, list, complete, and abort multipart ## Frontend uploads -Load the `resources/js/uploads.ts` Vite entry through your application’s asset integration to expose `CraftUploads.AssetUpload`. Supply the URL generated by `route('craft.actions.craft.uploads.store')`, the current CSRF token, and destination parameters: +Load the `resources/js/uploads.ts` Vite entry through your application’s asset integration to expose `CraftUploads.FileUpload`. Supply the URL generated by `route('craft.actions.craft.uploads.store')`, the current CSRF token, and destination parameters: ```js -const task = new CraftUploads.AssetUpload(fileInput.files[0], { +const task = new CraftUploads.FileUpload(fileInput.files[0], { url: fileInput.dataset.uploadUrl, csrfToken: document.querySelector('meta[name="csrf-token"]').content, parameters: {folderId: Number(fileInput.dataset.folderId)}, @@ -55,7 +56,7 @@ Uploads may instead use the existing `fieldId`, `elementId`, and `siteId` destin ### Anonymous uploads -Guests cannot upload by default. Register an authorization callback in a service provider: +Guests cannot upload assets by default. Register an authorization callback in a service provider: ```php use CraftCms\Cms\Asset\AssetUploads; @@ -72,17 +73,50 @@ public function boot(AssetUploads $uploads): void Replace `123` with the destination folder ID. This example allows guests to upload files up to 10 MiB to that folder. The callback receives `folderId`, `filename`, `size`, and client-supplied `context`; add any access or file restrictions your application needs. Craft rechecks authorization throughout the upload using stored file details. -Guests must supply a `folderId` and cannot replace assets or use field destinations. Craft still enforces its file limits and sanitizes guest images. +This callback only authorizes asset uploads. It does not grant access to photo uploads. Guests must supply a `folderId` and cannot replace assets or use field destinations. Craft still enforces its file limits and sanitizes guest images. + +## User photos + +Start a photo session through `action([PhotoController::class, 'upload'])`, where `PhotoController` is `CraftCms\Cms\Http\Controllers\Users\PhotoController`. Supply `filename`, `size`, and `userId`. The same `FileUpload` client handles the returned session; use `{userId}` as its `parameters`. + +The caller must be authenticated and authorized to save the target user. Photo uploads accept only images Craft can manipulate. Completion uses the existing user-photo processing and returns `html`, `photoId`, and, where applicable, `headerPhotoHtml`. + +## Other upload destinations + +Implement `CraftCms\Cms\Filesystem\Contracts\UploadHandler` for another purpose: + +- `authorize()` validates the filename and size, authorizes the destination, and returns its normalized parameters. Craft runs it before creating a session and before accepting parts or completing the upload. The normalized destination must remain unchanged. +- `complete()` receives the bound parameters and a `CraftCms\Cms\Filesystem\Data\UploadedFile`. Process the file and return a `JsonResponse`. Craft stores the response payload and status so repeated completion requests do not repeat the operation. Exceptions leave the session available for retry. + +In your upload-start controller, validate `filename` and `size` with `CraftCms\Cms\Http\Requests\UploadRequest`, then call: + +```php +$session = $uploads->start( + $request, + DocumentUploadHandler::class, + $request->validated('filename'), + (int) $request->validated('size'), + $request->only('documentId'), +); + +return new JsonResponse(UploadSessionData::fromSession($session)->toArray(), 201); +``` + +Inject `CraftCms\Cms\Filesystem\Uploads` as `$uploads`; import `CraftCms\Cms\Filesystem\Data\UploadSessionData` and `Illuminate\Http\JsonResponse`. The handler class is selected by your controller, never supplied by the client. Validate destination parameters inside the handler and apply authentication and throttling to the start route as appropriate for its purpose. + +`UploadedFile` provides `filename`, `size()`, `mimeType()`, `localPath()`, and `storeAs()`. Craft releases the local temporary copy after processing and cleans up the staged upload after completion. Handlers must finish consuming the file before returning. + +The browser client is `FileUpload`. Its result type belongs to the caller; the transport does not require an asset ID or asset-specific response fields. ## Plugin uploaders -Implement `CraftCms\Cms\Asset\Contracts\AssetUploader`, register it during service-provider boot, and select it with `GeneralConfig::assetUploader('cloud')`: +Implement `CraftCms\Cms\Filesystem\Contracts\Uploader`, register it during service-provider boot, and select it with `GeneralConfig::uploader('cloud')`: ```php -use CraftCms\Cms\Asset\AssetUploaders; +use CraftCms\Cms\Filesystem\Uploaders; use Illuminate\Contracts\Container\Container; -public function boot(AssetUploaders $uploaders): void +public function boot(Uploaders $uploaders): void { $uploaders->extend('cloud', fn (Container $app) => $app->make(CloudUploader::class)); } @@ -92,15 +126,17 @@ The contract has four methods: - `start()` sets the session’s `chunkSize` and serializable provider `state`. - `partRequest()` returns an `UploadPartRequest` with the URL, method, and headers for a raw file slice. Use one part for whole-file transfers. -- `complete()` verifies the stored bytes and returns an `UploadedAssetFile` on the temporary disk. It must tolerate retries after storage completion. +- `complete()` verifies the stored bytes and returns an `UploadedFile` on the temporary disk. It must tolerate retries after storage completion. - `abort()` removes incomplete transfers and completed temporary objects. It must tolerate retries. Uploaders are cached by name. Keep upload-specific state on the session and resolve its disk through `Filesystems::disk($session->disk)`. Keep provider credentials on the server. -Craft handles authorization, expiry, asset validation and creation, browser retries, and progress. The control panel’s `Craft.createUploader()` factory still supports custom JavaScript uploader classes registered for a filesystem type. +Craft handles session ownership, expiry, browser retries, and progress. The selected handler authorizes the destination and processes the completed file. The control panel’s `Craft.createUploader()` factory still supports custom JavaScript uploader classes registered for a filesystem type. ## Migrating existing integrations -The `assets/upload` and `assets/replace-file` multipart endpoints have been removed. Send files through `AssetUpload` or the upload-session endpoints instead. Custom JavaScript uploaders must use upload sessions; `uploadSessions: false` is no longer supported. +The `assets/upload` and `assets/replace-file` multipart endpoints have been removed. Send files through `FileUpload` or the upload-session endpoints instead. Custom JavaScript uploaders must use upload sessions; `uploadSessions: false` is no longer supported. After a filename conflict, use `assets/resolve-upload-conflict` to replace an existing asset or unindexed file with the already-uploaded asset. Supply `sourceAssetId` and either `assetId` or `targetFilename`. This endpoint does not receive file bytes. + +The `users/upload-user-photo` endpoint now starts a JSON upload session instead of accepting multipart `photo` data. Existing user-save form payloads remain supported separately. Craft 6 branding uses `cpIconUrl` and `cpLogoUrl`; the removed rebrand upload endpoints are not restored. diff --git a/packages/craftcms-legacy/cp/src/js/ImageUpload.js b/packages/craftcms-legacy/cp/src/js/ImageUpload.js index 5d7fa176966..9fe5730f40a 100644 --- a/packages/craftcms-legacy/cp/src/js/ImageUpload.js +++ b/packages/craftcms-legacy/cp/src/js/ImageUpload.js @@ -21,36 +21,19 @@ Craft.ImageUpload = Garnish.Base.extend( $('
').appendTo(this.$container) ); - var options = { + const options = { url: Craft.getActionUrl(this.settings.uploadAction), formData: this.settings.postParameters, fileInput: this.$container.find(this.settings.fileInputSelector), - paramName: this.settings.uploadParamName, }; - // If CSRF protection isn't enabled, these won't be defined. - if ( - typeof Craft.csrfTokenName !== 'undefined' && - typeof Craft.csrfTokenValue !== 'undefined' - ) { - // Add the CSRF token - options.formData[Craft.csrfTokenName] = Craft.csrfTokenValue; - } - options.events = {}; options.events.fileuploadstart = this._onUploadStart.bind(this); options.events.fileuploadprogressall = this._onUploadProgress.bind(this); options.events.fileuploaddone = this._onUploadComplete.bind(this); options.events.fileuploadfail = this._onUploadFailure.bind(this); - for (const [name, handler] of Object.entries(options.events)) { - this.$container.on(name, handler); - } - this.uploader = this.$container.fileupload({ - ...options, - autoUpload: true, - sequentialUploads: true, - }); + this.uploader = new Craft.Uploader(this.$container, options); this.initButtons(); }, @@ -86,6 +69,7 @@ Craft.ImageUpload = Garnish.Base.extend( }, refreshImage: function (response) { + this.uploader.destroy(); this.$container.replaceWith((this.$container = $(response.html))); this.settings.onAfterRefreshImage(response); Craft.cp.elementThumbLoader.load(this.$container); @@ -119,23 +103,25 @@ Craft.ImageUpload = Garnish.Base.extend( _onUploadComplete: function (event, data = null) { this.refreshImage(data.result); - // Last file - if (this.uploader.fileupload('active') < 2) { - this.progressBar.hideProgressBar(); - this.$container.removeClass('uploading'); - } + this.progressBar.hideProgressBar(); + this.$container.removeClass('uploading'); }, /** * On Upload Failure. */ _onUploadFailure: function (event, data = null) { - const response = data.response(); + if (data?.errorThrown === 'abort') { + this.progressBar.hideProgressBar(); + this.$container.removeClass('uploading'); + return; + } + let { message, filename, errors = {}, - } = response?.jqXHR?.responseJSON || {}; + } = data?.jqXHR?.responseJSON || {}; filename = filename || data?.files?.[0].name; let errorMessages = errors ? Object.values(errors).flat() : []; @@ -168,8 +154,6 @@ Craft.ImageUpload = Garnish.Base.extend( uploadButtonSelector: null, deleteButtonSelector: null, - - uploadParamName: 'files', }, } ); diff --git a/packages/craftcms-legacy/userphoto/src/UserPhotoInput.js b/packages/craftcms-legacy/userphoto/src/UserPhotoInput.js index b6589994c3e..b5f30c148bc 100644 --- a/packages/craftcms-legacy/userphoto/src/UserPhotoInput.js +++ b/packages/craftcms-legacy/userphoto/src/UserPhotoInput.js @@ -22,7 +22,6 @@ import './user-photo-input.scss'; uploadButtonSelector: '.btn.upload-photo', deleteButtonSelector: '.btn.delete-photo', fileInputSelector: 'input[type=file]', - uploadParamName: 'photo', onAfterRefreshImage: (response) => { if (typeof response.html !== 'undefined') { diff --git a/resources/js/modules/markdown-field/behaviors/uploads.test.ts b/resources/js/modules/markdown-field/behaviors/uploads.test.ts index f81688c2f37..a6f4f49a734 100644 --- a/resources/js/modules/markdown-field/behaviors/uploads.test.ts +++ b/resources/js/modules/markdown-field/behaviors/uploads.test.ts @@ -1,5 +1,5 @@ import {afterEach, expect, it, vi} from 'vitest'; -import {AssetUpload, UploadError} from '@/upload-client'; +import {FileUpload, UploadError} from '@/upload-client'; import {fileUploadOptions} from './uploads'; const {flash} = vi.hoisted(() => ({flash: vi.fn()})); @@ -19,7 +19,7 @@ it.each([ ])( 'inserts the asset returned by the upload session for %s', async (type, markdown) => { - vi.spyOn(AssetUpload.prototype, 'upload').mockResolvedValue({ + vi.spyOn(FileUpload.prototype, 'upload').mockResolvedValue({ assetId: 42, filename: 'uploaded.png', }); @@ -33,7 +33,7 @@ it.each([ it('reports an upload failure without inserting a link', async () => { const error = new UploadError('The file is not allowed.', 422); - vi.spyOn(AssetUpload.prototype, 'upload').mockRejectedValue(error); + vi.spyOn(FileUpload.prototype, 'upload').mockRejectedValue(error); await expect( fileUploadOptions(12, 2)!.onInsertFile!(new File(['contents'], 'file.txt')) diff --git a/resources/js/modules/markdown-field/behaviors/uploads.ts b/resources/js/modules/markdown-field/behaviors/uploads.ts index d8bd73134fb..30465b90a30 100644 --- a/resources/js/modules/markdown-field/behaviors/uploads.ts +++ b/resources/js/modules/markdown-field/behaviors/uploads.ts @@ -1,10 +1,12 @@ import {t} from '@craftcms/ui'; import type {Options} from 'overtype'; import {store} from '@/routes/craft/actions/craft/cp/uploads'; -import {AssetUpload, type UploadResult} from '@/upload-client'; +import {FileUpload} from '@/upload-client'; import {useFlashMessages} from '@/common/composables/useFlashMessages'; import {escapeMarkdownLabel} from './utilities'; +type UploadResult = Omit; + const ASSET_REF_HANDLE = 'asset'; const {flash} = useFlashMessages(); @@ -37,7 +39,7 @@ async function uploadFile( uploadFolderId: number, uploadSiteId: number | string ): Promise { - const task = new AssetUpload(file, { + const task = new FileUpload(file, { url: store.url(), parameters: {folderId: uploadFolderId}, }); diff --git a/resources/js/modules/uploader/uploader.test.ts b/resources/js/modules/uploader/uploader.test.ts index 546d53635ca..162e17bcb6c 100644 --- a/resources/js/modules/uploader/uploader.test.ts +++ b/resources/js/modules/uploader/uploader.test.ts @@ -1,6 +1,6 @@ import {afterEach, beforeEach, expect, it, vi} from 'vitest'; import {Uploader} from './uploader'; -import {AssetUpload, UploadError} from '@/upload-client'; +import {FileUpload, UploadError} from '@/upload-client'; vi.mock('./base-uploader', () => ({BaseUploader: class {}})); vi.mock('./upload-notification', () => ({ @@ -35,10 +35,10 @@ afterEach(() => { }); it('does not cancel a successfully canceled upload again during teardown', async () => { - vi.spyOn(AssetUpload.prototype, 'upload').mockRejectedValue( + vi.spyOn(FileUpload.prototype, 'upload').mockRejectedValue( new UploadError('Upload failed.', 500) ); - const cancel = vi.spyOn(AssetUpload.prototype, 'cancel').mockResolvedValue(); + const cancel = vi.spyOn(FileUpload.prototype, 'cancel').mockResolvedValue(); const file = new File(['abc'], 'document.txt'); const data = fileData(file); @@ -61,7 +61,7 @@ it('allows retry after a replacement failure handler throws', async () => { throw handlerError; } }); - vi.spyOn(AssetUpload.prototype, 'upload') + vi.spyOn(FileUpload.prototype, 'upload') .mockRejectedValueOnce(new UploadError('Upload failed.', 500)) .mockResolvedValueOnce({assetId: 123}); const file = new File(['abc'], 'replacement.txt'); diff --git a/resources/js/modules/uploader/uploader.ts b/resources/js/modules/uploader/uploader.ts index 3d9c32d6411..c537f07cf16 100644 --- a/resources/js/modules/uploader/uploader.ts +++ b/resources/js/modules/uploader/uploader.ts @@ -1,6 +1,6 @@ import {BaseUploader} from './base-uploader'; import {UploadNotification} from './upload-notification'; -import {AssetUpload, UploadError} from '@/upload-client'; +import {FileUpload, UploadError} from '@/upload-client'; import {store} from '@/routes/craft/actions/craft/cp/uploads'; // blueimp jQuery File Upload plugin seam — see base-uploader.ts. @@ -28,8 +28,8 @@ export class Uploader extends BaseUploader { _onFileAdd: any = null; private destroyed = false; private queue: Promise = Promise.resolve(); - private queued = new Set(); - private uploads = new Map(); + private queued = new Set(); + private uploads = new Map(); static override get defaults(): any { return { @@ -123,11 +123,11 @@ export class Uploader extends BaseUploader { } private uploadFile(file: File, data: any): void { - const task = new AssetUpload(file, { + const task = new FileUpload(file, { url: this.settings.url, parameters: { ...this.formData, - operation: this.settings.replace ? 'replace' : 'upload', + ...(this.settings.replace ? {operation: 'replace'} : {}), }, csrfToken: Craft.csrfTokenValue, onProgress: (loaded, total) => { @@ -155,7 +155,7 @@ export class Uploader extends BaseUploader { this.enqueue(task, data); } - private enqueue(task: AssetUpload, data: any): void { + private enqueue(task: FileUpload, data: any): void { if ( this.destroyed || this.queued.has(task) || diff --git a/resources/js/upload-client.test.ts b/resources/js/upload-client.test.ts index d83dc39d8c6..701a207e7d2 100644 --- a/resources/js/upload-client.test.ts +++ b/resources/js/upload-client.test.ts @@ -1,6 +1,6 @@ import {Blob as NodeBlob, File as NodeFile} from 'node:buffer'; import {afterEach, beforeEach, expect, it, vi} from 'vitest'; -import {AssetUpload, UploadError} from './upload-client'; +import {FileUpload, UploadError} from './upload-client'; const control = vi.fn(); const sent: Blob[] = []; @@ -10,6 +10,7 @@ const transfers: { headers: Record; }[] = []; let statuses: number[]; +let result: unknown; let holdTransfers: boolean; let partRequest: {url: string; method: string; headers: Record}; @@ -95,6 +96,7 @@ beforeEach(() => { transfers.length = 0; holdTransfers = false; statuses = []; + result = {assetId: 42}; partRequest = { url: 'https://storage.example/part', method: 'PUT', @@ -112,7 +114,7 @@ beforeEach(() => { } : url === '/part' ? partRequest - : {assetId: 42}; + : result; return new Response(JSON.stringify(body), {status: 200}); }); vi.stubGlobal('Blob', NodeBlob); @@ -126,25 +128,48 @@ afterEach(() => { vi.useRealTimers(); }); -it.each([ +it.each<{ + destination: typeof partRequest; + response: unknown; + parameters: Record; +}>([ { - url: 'https://storage.example/part', - method: 'PUT', - headers: {'X-Storage': 'signed'}, + destination: { + url: 'https://storage.example/part', + method: 'PUT', + headers: {'X-Storage': 'signed'}, + }, + response: {assetId: 42}, + parameters: {folderId: 12}, + }, + { + destination: { + url: '/chunk', + method: 'POST', + headers: {'X-CSRF-TOKEN': 'local-token'}, + }, + response: {photoId: 42, html: '
Photo
'}, + parameters: {userId: 7}, + }, + { + destination: {url: '/chunk', method: 'POST', headers: {}}, + response: false, + parameters: {}, }, - {url: '/chunk', method: 'POST', headers: {'X-CSRF-TOKEN': 'local-token'}}, ])( - 'uploads bytes via $method and reports the saved asset', - async (destination) => { + 'uploads bytes via $destination.method and returns the handlers response', + async ({destination, response, parameters}) => { partRequest = destination; + result = response; const progress = vi.fn(); - const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + const upload = new FileUpload(new File(['abcdef'], 'file.txt'), { url: '/start', csrfToken: 'token', + parameters, onProgress: progress, }); - await expect(upload.upload()).resolves.toEqual({assetId: 42}); + await expect(upload.upload()).resolves.toEqual(response); expect(await Promise.all(sent.map((part) => part.text()))).toEqual([ 'abc', 'def', @@ -153,20 +178,24 @@ it.each([ '/start', expect.objectContaining({ headers: expect.objectContaining({'X-CSRF-TOKEN': 'token'}), + body: JSON.stringify({...parameters, filename: 'file.txt', size: 6}), }) ); expect(transfers).toEqual([destination, destination]); expect(progress).toHaveBeenCalledWith(1, 6); expect(progress).toHaveBeenCalledWith(4, 6); expect(progress).toHaveBeenLastCalledWith(6, 6); - await expect(upload.upload()).resolves.toEqual({assetId: 42}); + await expect(upload.upload()).resolves.toEqual(response); expect(sent).toHaveLength(2); + expect( + control.mock.calls.filter(([url]) => url === '/complete') + ).toHaveLength(1); } ); it('retains acknowledged parts for manual retry', async () => { statuses = [200, 500]; - const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + const upload = new FileUpload(new File(['abcdef'], 'file.txt'), { url: '/start', retries: 0, }); @@ -187,7 +216,7 @@ it('retains acknowledged parts for manual retry', async () => { it('stops after the configured number of automatic retries', async () => { vi.useFakeTimers(); statuses = [503, 503, 200]; - const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + const upload = new FileUpload(new File(['abcdef'], 'file.txt'), { url: '/start', retries: 1, }); @@ -219,7 +248,7 @@ it.each([403, 0, -1])( } return implementation(url); }); - const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + const upload = new FileUpload(new File(['abcdef'], 'file.txt'), { url: '/start', headers: {Authorization: 'application-token'}, }); @@ -250,7 +279,7 @@ it('stops retrying when permission to sign another part is revoked', async () => } return implementation(url); }); - const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + const upload = new FileUpload(new File(['abcdef'], 'file.txt'), { url: '/start', }); @@ -265,7 +294,7 @@ it.each(['transfer', 'retry delay'])( vi.useFakeTimers(); holdTransfers = phase === 'transfer'; statuses = [503]; - const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + const upload = new FileUpload(new File(['abcdef'], 'file.txt'), { url: '/start', }); const result = expect(upload.upload()).rejects.toMatchObject({ @@ -287,7 +316,7 @@ it.each(['transfer', 'retry delay'])( it('cancels a failed session and prevents another transfer', async () => { statuses = [500]; - const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + const upload = new FileUpload(new File(['abcdef'], 'file.txt'), { url: '/start', retries: 0, }); @@ -314,7 +343,7 @@ it('retries finalization without sending the file again', async () => { } return implementation(url); }); - const upload = new AssetUpload(new File(['abcdef'], 'file.txt'), { + const upload = new FileUpload(new File(['abcdef'], 'file.txt'), { url: '/start', retries: 0, }); diff --git a/resources/js/upload-client.ts b/resources/js/upload-client.ts index 130eda82b4a..272fbf5e198 100644 --- a/resources/js/upload-client.ts +++ b/resources/js/upload-client.ts @@ -2,9 +2,8 @@ import axios, {type AxiosRequestConfig, type AxiosResponse} from 'axios'; const client = axios.create({adapter: 'xhr'}); -export type UploadSession = CraftCms.Cms.Asset.Data.UploadSessionData; -export type UploadPartRequest = CraftCms.Cms.Asset.Data.UploadPartRequest; -export type UploadResult = Omit; +export type UploadSession = CraftCms.Cms.Filesystem.Data.UploadSessionData; +export type UploadPartRequest = CraftCms.Cms.Filesystem.Data.UploadPartRequest; export type UploadState = | 'ready' @@ -36,13 +35,13 @@ export class UploadError extends Error { } } -/** Uploads Craft asset sessions, retaining completed parts for same-page retries. */ -export class AssetUpload { +/** Uploads Craft sessions, retaining completed parts for same-page retries. */ +export class FileUpload> { state: UploadState = 'ready'; private session: UploadSession | null = null; private completedParts = 0; - private result: UploadResult | null = null; - private running: Promise | null = null; + private result: {value: Result} | null = null; + private running: Promise | null = null; private controller = new AbortController(); constructor( @@ -50,7 +49,7 @@ export class AssetUpload { private readonly options: UploadOptions ) {} - upload(): Promise { + upload(): Promise { if (this.running) { return this.running; } @@ -69,7 +68,7 @@ export class AssetUpload { if (this.state === 'completing') { throw new UploadError( - 'The asset is being saved and can no longer be canceled.', + 'The file is being saved and can no longer be canceled.', 409 ); } @@ -84,11 +83,11 @@ export class AssetUpload { } } - private async run(): Promise { + private async run(): Promise { this.controller.signal.throwIfAborted(); if (this.result) { - return this.result; + return this.result.value; } this.setState('uploading'); @@ -162,13 +161,12 @@ export class AssetUpload { } this.setState('completing'); - this.result = await this.json( - session.urls.complete, - 'POST' - ); + this.result = { + value: await this.json(session.urls.complete, 'POST'), + }; this.setState('completed'); - return this.result; + return this.result.value; } catch (error) { if (!this.controller.signal.aborted) { this.setState('failed'); diff --git a/resources/js/uploads.ts b/resources/js/uploads.ts index 3aeef66001d..0b5e52e0644 100644 --- a/resources/js/uploads.ts +++ b/resources/js/uploads.ts @@ -1,15 +1,15 @@ -import {AssetUpload, UploadError} from './upload-client'; +import {FileUpload, UploadError} from './upload-client'; -export {AssetUpload, UploadError}; -export type {UploadOptions, UploadResult, UploadState} from './upload-client'; +export {FileUpload, UploadError}; +export type {UploadOptions, UploadState} from './upload-client'; declare global { interface Window { CraftUploads: { - AssetUpload: typeof AssetUpload; + FileUpload: typeof FileUpload; UploadError: typeof UploadError; }; } } -window.CraftUploads = {AssetUpload, UploadError}; +window.CraftUploads = {FileUpload, UploadError}; diff --git a/routes/actions.php b/routes/actions.php index c7f4ad48539..99aecbe3af5 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -19,7 +19,7 @@ use CraftCms\Cms\Http\Controllers\Assets\PreviewController as AssetsPreviewController; use CraftCms\Cms\Http\Controllers\Assets\ResolveUploadConflictController; use CraftCms\Cms\Http\Controllers\Assets\TransformController; -use CraftCms\Cms\Http\Controllers\Assets\UploadSessionController; +use CraftCms\Cms\Http\Controllers\Assets\UploadSessionController as AssetUploadSessionController; use CraftCms\Cms\Http\Controllers\Auth\LoginController; use CraftCms\Cms\Http\Controllers\Auth\PasskeyController; use CraftCms\Cms\Http\Controllers\Auth\SessionInfoController; @@ -75,6 +75,7 @@ use CraftCms\Cms\Http\Controllers\Settings\VolumesController; use CraftCms\Cms\Http\Controllers\StructuresController; use CraftCms\Cms\Http\Controllers\Updates\UpdatesController; +use CraftCms\Cms\Http\Controllers\UploadSessionController; use CraftCms\Cms\Http\Controllers\Users\ActivateController; use CraftCms\Cms\Http\Controllers\Users\AuthMethodController; use CraftCms\Cms\Http\Controllers\Users\EnableController; @@ -113,10 +114,13 @@ */ foreach ($sharedActionRouteGroups as [$prefix, $middleware]) { Route::prefix($prefix)->middleware($middleware)->group(function () use ($middleware) { - Route::prefix('assets/uploads') + Route::post('assets/uploads', [AssetUploadSessionController::class, 'store']) + ->middleware('throttle:60,1') + ->name(in_array('craft.cp', $middleware, true) ? 'craft.cp.uploads.store' : 'craft.uploads.store'); + + Route::prefix('uploads') ->name(in_array('craft.cp', $middleware, true) ? 'craft.cp.uploads.' : 'craft.uploads.') ->group(function () { - Route::post('', [UploadSessionController::class, 'store'])->middleware('throttle:60,1')->name('store'); Route::post('{upload}/parts', [UploadSessionController::class, 'part'])->whereUuid('upload')->name('part'); Route::post('{upload}/parts/{part}', [UploadSessionController::class, 'chunk'])->whereUuid('upload')->whereNumber('part')->name('chunk'); Route::post('{upload}/complete', [UploadSessionController::class, 'complete'])->whereUuid('upload')->name('complete'); @@ -407,7 +411,7 @@ }); Route::post('users/render-photo-input', [PhotoController::class, 'renderInput']); - Route::post('users/upload-user-photo', [PhotoController::class, 'upload']); + Route::post('users/upload-user-photo', [PhotoController::class, 'upload'])->middleware('throttle:60,1'); Route::post('users/delete-user-photo', [PhotoController::class, 'destroy']); Route::post('users/require-password-reset', [PasswordController::class, 'requireReset']); Route::post('users/remove-password-reset-requirement', [PasswordController::class, 'removeResetRequirement']); diff --git a/src/Asset/AssetUploadHandler.php b/src/Asset/AssetUploadHandler.php index 2f6d87465f7..dc312e95bc6 100644 --- a/src/Asset/AssetUploadHandler.php +++ b/src/Asset/AssetUploadHandler.php @@ -4,7 +4,6 @@ namespace CraftCms\Cms\Asset; -use CraftCms\Cms\Asset\Data\UploadedAssetFile; use CraftCms\Cms\Asset\Data\UploadResult; use CraftCms\Cms\Asset\Data\VolumeFolder; use CraftCms\Cms\Asset\Elements\Asset; @@ -14,6 +13,7 @@ use CraftCms\Cms\Element\Elements; use CraftCms\Cms\Field\Assets as AssetsField; use CraftCms\Cms\Field\Fields; +use CraftCms\Cms\Filesystem\Data\UploadedFile; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Translation\Formatter; @@ -80,7 +80,7 @@ public function resolveTarget(array $parameters, bool $authorizedGuest = false): /** @param array $parameters */ public function store( array $parameters, - UploadedAssetFile $file, + UploadedFile $file, bool $authorizedGuest = false, ?int $uploaderId = null, ): UploadResult { @@ -176,7 +176,7 @@ public function store( ]); } - public function replace(int $assetId, UploadedAssetFile $file): UploadResult + public function replace(int $assetId, UploadedFile $file): UploadResult { $asset = $this->assets->getAssetById($assetId); abort_unless($asset !== null, 404, 'Asset not found.'); diff --git a/src/Asset/AssetUploads.php b/src/Asset/AssetUploads.php index c0fe7b40ad9..a8ba80950cc 100644 --- a/src/Asset/AssetUploads.php +++ b/src/Asset/AssetUploads.php @@ -5,29 +5,21 @@ namespace CraftCms\Cms\Asset; use Closure; -use CraftCms\Cms\Asset\Contracts\AssetUploader; -use CraftCms\Cms\Asset\Data\UploadResult; -use CraftCms\Cms\Asset\Models\UploadSession; -use CraftCms\Cms\Asset\Uploaders\ChunkedUploader; use CraftCms\Cms\Cms; -use CraftCms\Cms\Http\Requests\UploadRequest; +use CraftCms\Cms\Filesystem\Contracts\UploadHandler; +use CraftCms\Cms\Filesystem\Data\UploadedFile; use Illuminate\Container\Attributes\Singleton; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Arr; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; -use Illuminate\Support\Str; -use RuntimeException; -use Throwable; #[Singleton] -class AssetUploads +class AssetUploads implements UploadHandler { /** @var Closure(Request, array{folderId: int|null, filename: string, size: int, context: array}): bool|null */ private ?Closure $guestAuthorizer = null; public function __construct( - private readonly AssetUploaders $uploaders, private readonly Assets $assets, private readonly AssetUploadHandler $uploads, ) {} @@ -38,181 +30,14 @@ public function allowGuestUploadsUsing(Closure $authorizer): void $this->guestAuthorizer = $authorizer; } - public function start(UploadRequest $request): UploadSession - { - abort_if(! $request->user() && $this->guestAuthorizer === null, 401); - - $data = $request->validated(); - - $parameters = Arr::except($data, ['filename', 'size']); - $parameters['operation'] ??= 'upload'; - $parameters = $this->authorize($request, $parameters, $data['filename'], (int) $data['size']); - - if (Cms::config()->uploadSessionDuration < 1) { - throw new RuntimeException('uploadSessionDuration must be greater than zero.'); - } - - $diskReference = Cms::config()->getTempAssetUploadFs(); - $name = $this->uploaders->getDefaultDriver(); - $uploader = $this->uploaders->driver($name); - - $session = UploadSession::create([ - 'id' => (string) Str::uuid(), - 'owner' => $this->owner($request), - 'uploader' => $name, - 'disk' => $diskReference, - 'filename' => $data['filename'], - 'size' => (int) $data['size'], - 'parameters' => $parameters, - 'state' => [], - 'expiresAt' => now()->addSeconds(Cms::config()->uploadSessionDuration), - ]); - - $uploader->start($session); - abort_unless($session->chunkSize > 0, 500, 'The uploader returned an invalid chunk size.'); - $session->save(); - - return $session; - } - - /** @return array */ - public function part(Request $request, string $id, int $part): array - { - return $this->withSession($request, $id, function (UploadSession $session, AssetUploader $uploader) use ($part) { - abort_if($session->result !== null, 409, 'This upload has already completed.'); - $session->partSize($part); - - return $uploader->partRequest($session, $part)->toArray(); - }); - } - - public function receive(Request $request, string $id, int $part): void - { - $this->withSession($request, $id, function (UploadSession $session, AssetUploader $uploader) use ($request, $part) { - abort_unless($uploader instanceof ChunkedUploader, 409, 'This uploader does not receive bytes through PHP.'); - abort_if($session->result !== null, 409, 'This upload has already completed.'); - - $stream = $request->getContent(asResource: true); - - try { - $uploader->receive($session, $part, $stream); - } finally { - if (is_resource($stream)) { - fclose($stream); - } - } - }); - } - - public function complete(Request $request, string $id): UploadResult - { - $result = $this->withSession($request, $id, function (UploadSession $session, AssetUploader $uploader) use ($request) { - if ($session->result !== null) { - return new UploadResult(...$session->result); - } - - $file = $uploader->complete($session); - - try { - abort_unless($file->size() === $session->size, 422, 'The uploaded file has an incorrect size.'); - $result = $session->parameters['operation'] === 'replace' - ? $this->uploads->replace((int) $session->parameters['assetId'], $file) - : $this->uploads->store( - $session->parameters, - $file, - authorizedGuest: ! $request->user(), - uploaderId: $request->craftUser()?->getCraftUserId(), - ); - - $session->result = $result->toArray(); - - return $result; - } finally { - $file->release(); - } - }); - - try { - $this->withSession($request, $id, function (UploadSession $session, AssetUploader $uploader) { - $this->clean($session, $uploader); - }, authorize: false); - } catch (Throwable $exception) { - report($exception); - } - - return $result; - } - - public function cancel(Request $request, string $id): void - { - $this->withSession($request, $id, function (UploadSession $session, AssetUploader $uploader) { - $this->clean($session, $uploader); - $session->delete(); - }, authorize: false, allowExpired: true); - } - - /** @return array{removed: int, failed: int} */ - public function cleanupExpired(): array - { - $removed = $failed = 0; - - foreach (UploadSession::query()->where('expiresAt', '<=', now())->lazyById(100) as $expired) { - try { - $removed += DB::transaction(function () use ($expired) { - $session = UploadSession::query()->lockForUpdate()->find($expired->id); - - if ($session === null || $session->expiresAt->isFuture()) { - return 0; - } - - $this->clean($session, $this->uploader($session)); - $session->delete(); - - return 1; - }); - } catch (Throwable $exception) { - report($exception); - $failed++; - } - } - - return ['removed' => $removed, 'failed' => $failed]; - } - - /** - * @template T - * - * @param Closure(UploadSession, AssetUploader): T $callback - * @return T - */ - private function withSession(Request $request, string $id, Closure $callback, bool $authorize = true, bool $allowExpired = false): mixed - { - return DB::transaction(function () use ($request, $id, $callback, $authorize, $allowExpired) { - $session = UploadSession::query()->lockForUpdate()->findOrFail($id); - abort_unless(hash_equals($session->owner, $this->owner($request)), 404); - abort_if(! $allowExpired && $session->expiresAt->isPast(), 410, 'The upload session has expired.'); - - if ($authorize) { - $parameters = $this->authorize($request, $session->parameters, $session->filename, $session->size); - abort_unless(Arr::sortRecursive($parameters) === Arr::sortRecursive($session->parameters), 403, 'The upload destination is no longer authorized.'); - } - - $result = $callback($session, $this->uploader($session)); - - if ($session->exists) { - $session->expiresAt = now()->addSeconds(Cms::config()->uploadSessionDuration); - $session->save(); - } - - return $result; - }); - } - /** @param array $parameters * @return array */ - private function authorize(Request $request, array $parameters, string $filename, int $size): array + public function authorize(Request $request, array $parameters, string $filename, int $size): array { + abort_if(! $request->user() && $this->guestAuthorizer === null, 401); + $parameters['operation'] ??= 'upload'; + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); abort_unless(in_array($extension, Cms::config()->allowedFileExtensions, true), 422, 'This file extension is not allowed.'); abort_if($size > AssetsHelper::getMaxAssetUploadSize(), 422, 'The file exceeds the maximum upload size.'); @@ -246,23 +71,17 @@ private function authorize(Request $request, array $parameters, string $filename return $parameters; } - private function owner(Request $request): string + public function complete(Request $request, array $parameters, UploadedFile $file): JsonResponse { - return $request->user() - ? 'user:'.$request->user()->getAuthIdentifier() - : 'guest:'.hash('sha256', $request->session()->getId()); - } - - private function uploader(UploadSession $session): AssetUploader - { - return $this->uploaders->driver($session->uploader); - } - - private function clean(UploadSession $session, AssetUploader $uploader): void - { - if (! ($session->state['cleaned'] ?? false)) { - $uploader->abort($session); - $session->state = [...$session->state, 'cleaned' => true]; - } + $result = $parameters['operation'] === 'replace' + ? $this->uploads->replace((int) $parameters['assetId'], $file) + : $this->uploads->store( + $parameters, + $file, + authorizedGuest: ! $request->user(), + uploaderId: $request->craftUser()?->getCraftUserId(), + ); + + return new JsonResponse($result->payload(), $result->status); } } diff --git a/src/Asset/Data/UploadSessionData.php b/src/Asset/Data/UploadSessionData.php deleted file mode 100644 index 1cd3ef834ed..00000000000 --- a/src/Asset/Data/UploadSessionData.php +++ /dev/null @@ -1,24 +0,0 @@ - */ -readonly class UploadSessionData implements Arrayable -{ - /** @param array{part: string, complete: string, cancel: string} $urls */ - public function __construct( - public string $id, - public int $chunkSize, - public int $partCount, - public array $urls, - ) {} - - public function toArray(): array - { - return get_object_vars($this); - } -} diff --git a/src/Asset/Elements/Asset.php b/src/Asset/Elements/Asset.php index f19425ae72c..71fc2e19caa 100644 --- a/src/Asset/Elements/Asset.php +++ b/src/Asset/Elements/Asset.php @@ -21,7 +21,6 @@ use CraftCms\Cms\Asset\Concerns\LegacyConstants; use CraftCms\Cms\Asset\Conditions\AssetCondition; use CraftCms\Cms\Asset\Data\AssetTransformResult; -use CraftCms\Cms\Asset\Data\UploadedAssetFile; use CraftCms\Cms\Asset\Data\Volume; use CraftCms\Cms\Asset\Data\VolumeFolder; use CraftCms\Cms\Asset\Enums\FileKind; @@ -55,6 +54,7 @@ use CraftCms\Cms\Element\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Field\Enums\TranslationMethod; use CraftCms\Cms\FieldLayout\FieldLayout; +use CraftCms\Cms\Filesystem\Data\UploadedFile; use CraftCms\Cms\Filesystem\Exceptions\FilesystemException; use CraftCms\Cms\Filesystem\Filesystems\Filesystem; use CraftCms\Cms\Form\Contracts\Node; @@ -270,7 +270,7 @@ class Asset extends Element public ?string $tempFilePath = null; /** The staged upload, before it has been promoted into its volume. */ - public ?UploadedAssetFile $uploadSource = null; + public ?UploadedFile $uploadSource = null; /** * @var bool Whether the asset should avoid filename conflicts when saved. diff --git a/src/Config/GeneralConfig.php b/src/Config/GeneralConfig.php index 44099dcdf59..fd838012925 100644 --- a/src/Config/GeneralConfig.php +++ b/src/Config/GeneralConfig.php @@ -1816,11 +1816,11 @@ class GeneralConfig extends BaseConfig public string|int $maxUploadFileSize = 16777216; /** - * The registered asset uploader to use, or null to select one from the temporary disk. + * The registered upload transport to use, or null to select one from the temporary disk. * * @group Assets */ - public ?string $assetUploader = null; + public ?string $uploader = null; /** * Maximum bytes per PHP upload request. Proxy request limits may require a smaller value. @@ -4997,9 +4997,9 @@ public function maxUploadFileSize(string|int $value): self return $this; } - public function assetUploader(?string $value): self + public function uploader(?string $value): self { - $this->assetUploader = $value; + $this->uploader = $value; return $this; } diff --git a/src/Database/Migrations/2026_09_08_101230_create_asset_upload_sessions_table.php b/src/Database/Migrations/2026_09_08_101230_create_upload_sessions_table.php similarity index 80% rename from src/Database/Migrations/2026_09_08_101230_create_asset_upload_sessions_table.php rename to src/Database/Migrations/2026_09_08_101230_create_upload_sessions_table.php index c15e5c13c41..30ba27c9851 100644 --- a/src/Database/Migrations/2026_09_08_101230_create_asset_upload_sessions_table.php +++ b/src/Database/Migrations/2026_09_08_101230_create_upload_sessions_table.php @@ -11,13 +11,14 @@ { public function up(): void { - if (Schema::hasTable(Table::ASSETUPLOADSESSIONS)) { + if (Schema::hasTable(Table::UPLOADSESSIONS)) { return; } - Schema::create(Table::ASSETUPLOADSESSIONS, function (Blueprint $table) { + Schema::create(Table::UPLOADSESSIONS, function (Blueprint $table) { $table->uuid('id')->primary(); $table->string('owner'); + $table->string('handler'); $table->string('uploader'); $table->string('disk'); $table->string('filename'); @@ -34,6 +35,6 @@ public function up(): void public function down(): void { - Schema::dropIfExists(Table::ASSETUPLOADSESSIONS); + Schema::dropIfExists(Table::UPLOADSESSIONS); } }; diff --git a/src/Database/Migrations/Install.php b/src/Database/Migrations/Install.php index 1d79ebfc2cf..5d4dc2454db 100644 --- a/src/Database/Migrations/Install.php +++ b/src/Database/Migrations/Install.php @@ -306,10 +306,11 @@ public function createTables(?Logger $logger = null): void $table->dateTime('dateUpdated'); }); - $logger?->subLabel('asset_upload_sessions'); - Schema::create(Table::ASSETUPLOADSESSIONS, function (Blueprint $table) { + $logger?->subLabel('upload_sessions'); + Schema::create(Table::UPLOADSESSIONS, function (Blueprint $table) { $table->uuid('id')->primary(); $table->string('owner'); + $table->string('handler'); $table->string('uploader'); $table->string('disk'); $table->string('filename'); diff --git a/src/Database/Table.php b/src/Database/Table.php index 23d733c05ac..8be4ff65344 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -17,7 +17,7 @@ public const string ASSETINDEXINGSESSIONS = 'assetindexingsessions'; - public const string ASSETUPLOADSESSIONS = 'assetuploadsessions'; + public const string UPLOADSESSIONS = 'uploadsessions'; public const string ASSETS = 'assets'; diff --git a/src/Filesystem/Contracts/UploadHandler.php b/src/Filesystem/Contracts/UploadHandler.php new file mode 100644 index 00000000000..ef37e2a6a71 --- /dev/null +++ b/src/Filesystem/Contracts/UploadHandler.php @@ -0,0 +1,23 @@ + $parameters + * @return array The destination parameters to bind to the session. + */ + public function authorize(Request $request, array $parameters, string $filename, int $size): array; + + /** @param array $parameters */ + public function complete(Request $request, array $parameters, UploadedFile $file): JsonResponse; +} diff --git a/src/Asset/Contracts/AssetUploader.php b/src/Filesystem/Contracts/Uploader.php similarity index 58% rename from src/Asset/Contracts/AssetUploader.php rename to src/Filesystem/Contracts/Uploader.php index 8f42e012ddb..d0192ab6378 100644 --- a/src/Asset/Contracts/AssetUploader.php +++ b/src/Filesystem/Contracts/Uploader.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace CraftCms\Cms\Asset\Contracts; +namespace CraftCms\Cms\Filesystem\Contracts; -use CraftCms\Cms\Asset\Data\UploadedAssetFile; -use CraftCms\Cms\Asset\Data\UploadPartRequest; -use CraftCms\Cms\Asset\Models\UploadSession; +use CraftCms\Cms\Filesystem\Data\UploadedFile; +use CraftCms\Cms\Filesystem\Data\UploadPartRequest; +use CraftCms\Cms\Filesystem\Models\UploadSession; -interface AssetUploader +interface Uploader { /** Sets the chunk size and any provider state on the session. */ public function start(UploadSession $session): void; public function partRequest(UploadSession $session, int $part): UploadPartRequest; - public function complete(UploadSession $session): UploadedAssetFile; + public function complete(UploadSession $session): UploadedFile; /** Removes both incomplete transfers and completed temporary objects. */ public function abort(UploadSession $session): void; diff --git a/src/Asset/Data/UploadPartRequest.php b/src/Filesystem/Data/UploadPartRequest.php similarity index 92% rename from src/Asset/Data/UploadPartRequest.php rename to src/Filesystem/Data/UploadPartRequest.php index 3088087411a..db39a773e5d 100644 --- a/src/Asset/Data/UploadPartRequest.php +++ b/src/Filesystem/Data/UploadPartRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace CraftCms\Cms\Asset\Data; +namespace CraftCms\Cms\Filesystem\Data; use Illuminate\Contracts\Support\Arrayable; diff --git a/src/Filesystem/Data/UploadSessionData.php b/src/Filesystem/Data/UploadSessionData.php new file mode 100644 index 00000000000..eb36a62df07 --- /dev/null +++ b/src/Filesystem/Data/UploadSessionData.php @@ -0,0 +1,41 @@ + */ +readonly class UploadSessionData implements Arrayable +{ + /** @param array{part: string, complete: string, cancel: string} $urls */ + public function __construct( + public string $id, + public int $chunkSize, + public int $partCount, + public array $urls, + ) {} + + public static function fromSession(UploadSession $session): self + { + $prefix = request()->isCpRequest() ? 'craft.actions.craft.cp.uploads' : 'craft.actions.craft.uploads'; + + return new self( + id: $session->id, + chunkSize: $session->chunkSize, + partCount: $session->partCount(), + urls: [ + 'part' => route("$prefix.part", ['upload' => $session->id]), + 'complete' => route("$prefix.complete", ['upload' => $session->id]), + 'cancel' => route("$prefix.destroy", ['upload' => $session->id]), + ], + ); + } + + public function toArray(): array + { + return get_object_vars($this); + } +} diff --git a/src/Asset/Data/UploadedAssetFile.php b/src/Filesystem/Data/UploadedFile.php similarity index 97% rename from src/Asset/Data/UploadedAssetFile.php rename to src/Filesystem/Data/UploadedFile.php index 5e241d1317b..f773e713578 100644 --- a/src/Asset/Data/UploadedAssetFile.php +++ b/src/Filesystem/Data/UploadedFile.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace CraftCms\Cms\Asset\Data; +namespace CraftCms\Cms\Filesystem\Data; use CraftCms\Cms\Support\Facades\Path; use CraftCms\Cms\Support\File; @@ -12,7 +12,7 @@ use League\Flysystem\MountManager; use RuntimeException; -class UploadedAssetFile +class UploadedFile { private ?string $localPath = null; @@ -66,7 +66,7 @@ public function localPath(): string $target = fopen($path, 'wb'); if ($target === false) { - throw new RuntimeException('Unable to create a temporary asset file.'); + throw new RuntimeException('Unable to create a temporary upload file.'); } $this->localPath = $path; @@ -116,7 +116,7 @@ public function storeAs(FilesystemAdapter $destination, string $path, string $mi try { if (! $destination->writeStream($path, $stream, ['mimetype' => $mimeType])) { - throw new RuntimeException('Unable to store the uploaded asset.'); + throw new RuntimeException('Unable to store the uploaded file.'); } } finally { fclose($stream); diff --git a/src/Asset/Models/UploadSession.php b/src/Filesystem/Models/UploadSession.php similarity index 88% rename from src/Asset/Models/UploadSession.php rename to src/Filesystem/Models/UploadSession.php index 8d6f0fd530c..ed1ef0af859 100644 --- a/src/Asset/Models/UploadSession.php +++ b/src/Filesystem/Models/UploadSession.php @@ -2,14 +2,16 @@ declare(strict_types=1); -namespace CraftCms\Cms\Asset\Models; +namespace CraftCms\Cms\Filesystem\Models; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Filesystem\Contracts\UploadHandler; use CraftCms\Cms\Shared\BaseModel; use Illuminate\Support\Carbon; /** * @property string $id + * @property class-string $handler * @property string $owner * @property string $uploader * @property string $disk @@ -30,7 +32,7 @@ class UploadSession extends BaseModel protected $keyType = 'string'; #[\Override] - protected $table = Table::ASSETUPLOADSESSIONS; + protected $table = Table::UPLOADSESSIONS; protected function casts(): array { diff --git a/src/Asset/AssetUploaders.php b/src/Filesystem/Uploaders.php similarity index 56% rename from src/Asset/AssetUploaders.php rename to src/Filesystem/Uploaders.php index 17fc133f24d..af36137e434 100644 --- a/src/Asset/AssetUploaders.php +++ b/src/Filesystem/Uploaders.php @@ -2,25 +2,24 @@ declare(strict_types=1); -namespace CraftCms\Cms\Asset; +namespace CraftCms\Cms\Filesystem; -use CraftCms\Cms\Asset\Contracts\AssetUploader; -use CraftCms\Cms\Asset\Uploaders\ChunkedUploader; -use CraftCms\Cms\Asset\Uploaders\S3Uploader; use CraftCms\Cms\Cms; -use CraftCms\Cms\Filesystem\Filesystems; +use CraftCms\Cms\Filesystem\Contracts\Uploader; +use CraftCms\Cms\Filesystem\Uploaders\ChunkedUploader; +use CraftCms\Cms\Filesystem\Uploaders\S3Uploader; use Illuminate\Container\Attributes\Singleton; use Illuminate\Filesystem\AwsS3V3Adapter; use Illuminate\Support\Manager; use Override; #[Singleton] -class AssetUploaders extends Manager +class Uploaders extends Manager { public function getDefaultDriver(): string { - if (Cms::config()->assetUploader !== null) { - return Cms::config()->assetUploader; + if (Cms::config()->uploader !== null) { + return Cms::config()->uploader; } $disk = $this->container->make(Filesystems::class)->disk( @@ -31,17 +30,17 @@ public function getDefaultDriver(): string } #[Override] - public function driver($driver = null): AssetUploader + public function driver($driver = null): Uploader { return parent::driver($driver); } - protected function createChunkedDriver(): AssetUploader + protected function createChunkedDriver(): Uploader { return $this->container->make(ChunkedUploader::class); } - protected function createS3Driver(): AssetUploader + protected function createS3Driver(): Uploader { return $this->container->make(S3Uploader::class); } diff --git a/src/Asset/Uploaders/ChunkedUploader.php b/src/Filesystem/Uploaders/ChunkedUploader.php similarity index 88% rename from src/Asset/Uploaders/ChunkedUploader.php rename to src/Filesystem/Uploaders/ChunkedUploader.php index 30efe5df22c..14d5e2db574 100644 --- a/src/Asset/Uploaders/ChunkedUploader.php +++ b/src/Filesystem/Uploaders/ChunkedUploader.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace CraftCms\Cms\Asset\Uploaders; +namespace CraftCms\Cms\Filesystem\Uploaders; -use CraftCms\Cms\Asset\Contracts\AssetUploader; -use CraftCms\Cms\Asset\Data\UploadedAssetFile; -use CraftCms\Cms\Asset\Data\UploadPartRequest; -use CraftCms\Cms\Asset\Models\UploadSession; use CraftCms\Cms\Cms; +use CraftCms\Cms\Filesystem\Contracts\Uploader; +use CraftCms\Cms\Filesystem\Data\UploadedFile; +use CraftCms\Cms\Filesystem\Data\UploadPartRequest; use CraftCms\Cms\Filesystem\Filesystems; +use CraftCms\Cms\Filesystem\Models\UploadSession; use CraftCms\Cms\Support\PHP; use RuntimeException; -class ChunkedUploader implements AssetUploader +class ChunkedUploader implements Uploader { public function __construct(private readonly Filesystems $filesystems) {} @@ -67,12 +67,12 @@ public function receive(UploadSession $session, int $part, mixed $stream): void } } - public function complete(UploadSession $session): UploadedAssetFile + public function complete(UploadSession $session): UploadedFile { $disk = $this->filesystems->disk($session->disk); if ($disk->exists($session->path()) && $disk->size($session->path()) === $session->size) { - return new UploadedAssetFile($disk, $session->path(), $session->filename); + return new UploadedFile($disk, $session->path(), $session->filename); } $buffer = tmpfile(); @@ -108,7 +108,7 @@ public function complete(UploadSession $session): UploadedAssetFile fclose($buffer); } - return new UploadedAssetFile($disk, $session->path(), $session->filename); + return new UploadedFile($disk, $session->path(), $session->filename); } public function abort(UploadSession $session): void diff --git a/src/Asset/Uploaders/S3Uploader.php b/src/Filesystem/Uploaders/S3Uploader.php similarity index 92% rename from src/Asset/Uploaders/S3Uploader.php rename to src/Filesystem/Uploaders/S3Uploader.php index 21d8018dca4..cfd586451dd 100644 --- a/src/Asset/Uploaders/S3Uploader.php +++ b/src/Filesystem/Uploaders/S3Uploader.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace CraftCms\Cms\Asset\Uploaders; +namespace CraftCms\Cms\Filesystem\Uploaders; -use CraftCms\Cms\Asset\Contracts\AssetUploader; -use CraftCms\Cms\Asset\Data\UploadedAssetFile; -use CraftCms\Cms\Asset\Data\UploadPartRequest; -use CraftCms\Cms\Asset\Models\UploadSession; +use CraftCms\Cms\Filesystem\Contracts\Uploader; +use CraftCms\Cms\Filesystem\Data\UploadedFile; +use CraftCms\Cms\Filesystem\Data\UploadPartRequest; use CraftCms\Cms\Filesystem\Filesystems; +use CraftCms\Cms\Filesystem\Models\UploadSession; use Illuminate\Filesystem\AwsS3V3Adapter; use InvalidArgumentException; use Throwable; -class S3Uploader implements AssetUploader +class S3Uploader implements Uploader { public function __construct(private readonly Filesystems $filesystems) {} @@ -55,7 +55,7 @@ public function partRequest(UploadSession $session, int $part): UploadPartReques return new UploadPartRequest((string) $request->getUri(), 'PUT', $headers); } - public function complete(UploadSession $session): UploadedAssetFile + public function complete(UploadSession $session): UploadedFile { $disk = $this->disk($session); @@ -92,7 +92,7 @@ public function complete(UploadSession $session): UploadedAssetFile ]); } - return new UploadedAssetFile($disk, $session->path(), $session->filename); + return new UploadedFile($disk, $session->path(), $session->filename); } public function abort(UploadSession $session): void diff --git a/src/Filesystem/Uploads.php b/src/Filesystem/Uploads.php new file mode 100644 index 00000000000..e6b6f1ae5ee --- /dev/null +++ b/src/Filesystem/Uploads.php @@ -0,0 +1,212 @@ + $handler + * @param array $parameters + */ + public function start(Request $request, string $handler, string $filename, int $size, array $parameters): UploadSession + { + $parameters = app($handler)->authorize($request, $parameters, $filename, $size); + + if (Cms::config()->uploadSessionDuration < 1) { + throw new RuntimeException('uploadSessionDuration must be greater than zero.'); + } + + $diskReference = Cms::config()->getTempAssetUploadFs(); + $name = $this->uploaders->getDefaultDriver(); + $uploader = $this->uploaders->driver($name); + + $session = UploadSession::create([ + 'id' => (string) Str::uuid(), + 'owner' => $this->owner($request), + 'handler' => $handler, + 'uploader' => $name, + 'disk' => $diskReference, + 'filename' => $filename, + 'size' => $size, + 'parameters' => $parameters, + 'state' => [], + 'expiresAt' => now()->addSeconds(Cms::config()->uploadSessionDuration), + ]); + + $uploader->start($session); + abort_unless($session->chunkSize > 0, 500, 'The uploader returned an invalid chunk size.'); + $session->save(); + + return $session; + } + + /** @return array */ + public function part(Request $request, string $id, int $part): array + { + return $this->withSession($request, $id, function (UploadSession $session, Uploader $uploader) use ($part) { + abort_if($session->result !== null, 409, 'This upload has already completed.'); + $session->partSize($part); + + return $uploader->partRequest($session, $part)->toArray(); + }); + } + + public function receive(Request $request, string $id, int $part): void + { + $this->withSession($request, $id, function (UploadSession $session, Uploader $uploader) use ($request, $part) { + abort_unless($uploader instanceof ChunkedUploader, 409, 'This uploader does not receive bytes through PHP.'); + abort_if($session->result !== null, 409, 'This upload has already completed.'); + + $stream = $request->getContent(asResource: true); + + try { + $uploader->receive($session, $part, $stream); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + }); + } + + public function complete(Request $request, string $id): JsonResponse + { + $result = $this->withSession($request, $id, function (UploadSession $session, Uploader $uploader) use ($request) { + if ($session->result !== null) { + return new JsonResponse($session->result['data'], $session->result['status']); + } + + $file = $uploader->complete($session); + + try { + abort_unless($file->size() === $session->size, 422, 'The uploaded file has an incorrect size.'); + $result = $this->handler($session)->complete($request, $session->parameters, $file); + $session->result = ['data' => $result->getData(true), 'status' => $result->getStatusCode()]; + + return $result; + } finally { + $file->release(); + } + }); + + try { + $this->withSession($request, $id, function (UploadSession $session, Uploader $uploader) { + $this->clean($session, $uploader); + }, authorize: false); + } catch (Throwable $exception) { + report($exception); + } + + return $result; + } + + public function cancel(Request $request, string $id): void + { + $this->withSession($request, $id, function (UploadSession $session, Uploader $uploader) { + $this->clean($session, $uploader); + $session->delete(); + }, authorize: false, allowExpired: true); + } + + /** @return array{removed: int, failed: int} */ + public function cleanupExpired(): array + { + $removed = $failed = 0; + + foreach (UploadSession::query()->where('expiresAt', '<=', now())->lazyById(100) as $expired) { + try { + $removed += DB::transaction(function () use ($expired) { + $session = UploadSession::query()->lockForUpdate()->find($expired->id); + + if ($session === null || $session->expiresAt->isFuture()) { + return 0; + } + + $this->clean($session, $this->uploader($session)); + $session->delete(); + + return 1; + }); + } catch (Throwable $exception) { + report($exception); + $failed++; + } + } + + return ['removed' => $removed, 'failed' => $failed]; + } + + /** + * @template T + * + * @param Closure(UploadSession, Uploader): T $callback + * @return T + */ + private function withSession(Request $request, string $id, Closure $callback, bool $authorize = true, bool $allowExpired = false): mixed + { + return DB::transaction(function () use ($request, $id, $callback, $authorize, $allowExpired) { + $session = UploadSession::query()->lockForUpdate()->findOrFail($id); + abort_unless(hash_equals($session->owner, $this->owner($request)), 404); + abort_if(! $allowExpired && $session->expiresAt->isPast(), 410, 'The upload session has expired.'); + + if ($authorize) { + $parameters = $this->handler($session)->authorize($request, $session->parameters, $session->filename, $session->size); + abort_unless(Arr::sortRecursive($parameters) === Arr::sortRecursive($session->parameters), 403, 'The upload destination is no longer authorized.'); + } + + $result = $callback($session, $this->uploader($session)); + + if ($session->exists) { + $session->expiresAt = now()->addSeconds(Cms::config()->uploadSessionDuration); + $session->save(); + } + + return $result; + }); + } + + private function owner(Request $request): string + { + return $request->user() + ? 'user:'.$request->user()->getAuthIdentifier() + : 'guest:'.hash('sha256', $request->session()->getId()); + } + + private function handler(UploadSession $session): UploadHandler + { + return app($session->handler); + } + + private function uploader(UploadSession $session): Uploader + { + return $this->uploaders->driver($session->uploader); + } + + private function clean(UploadSession $session, Uploader $uploader): void + { + if (! ($session->state['cleaned'] ?? false)) { + $uploader->abort($session); + $session->state = [...$session->state, 'cleaned' => true]; + } + } +} diff --git a/src/GarbageCollection/Actions/RemoveExpiredUploads.php b/src/GarbageCollection/Actions/RemoveExpiredUploads.php index 961e6c7f589..d8c569e966d 100644 --- a/src/GarbageCollection/Actions/RemoveExpiredUploads.php +++ b/src/GarbageCollection/Actions/RemoveExpiredUploads.php @@ -4,8 +4,8 @@ namespace CraftCms\Cms\GarbageCollection\Actions; -use CraftCms\Cms\Asset\AssetUploads; use CraftCms\Cms\Config\GeneralConfig; +use CraftCms\Cms\Filesystem\Uploads; use CraftCms\Cms\GarbageCollection\GarbageCollection; use RuntimeException; @@ -14,7 +14,7 @@ class RemoveExpiredUploads extends GarbageCollectionAction public function __construct( GarbageCollection $garbageCollection, GeneralConfig $generalConfig, - private readonly AssetUploads $uploads, + private readonly Uploads $uploads, ) { parent::__construct($garbageCollection, $generalConfig); } diff --git a/src/Http/Controllers/Assets/UploadSessionController.php b/src/Http/Controllers/Assets/UploadSessionController.php index 9c4e8f3088d..b10dfb57f9f 100644 --- a/src/Http/Controllers/Assets/UploadSessionController.php +++ b/src/Http/Controllers/Assets/UploadSessionController.php @@ -5,58 +5,27 @@ namespace CraftCms\Cms\Http\Controllers\Assets; use CraftCms\Cms\Asset\AssetUploads; -use CraftCms\Cms\Asset\Data\UploadSessionData; -use CraftCms\Cms\Http\Requests\UploadRequest; +use CraftCms\Cms\Filesystem\Data\UploadSessionData; +use CraftCms\Cms\Filesystem\Uploads; +use CraftCms\Cms\Http\Requests\AssetUploadRequest; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; -use Symfony\Component\HttpFoundation\Response; +use Illuminate\Support\Arr; readonly class UploadSessionController { - public function __construct(private AssetUploads $uploads) {} + public function __construct(private Uploads $uploads) {} - public function store(UploadRequest $request): JsonResponse + public function store(AssetUploadRequest $request): JsonResponse { - $session = $this->uploads->start($request); - $prefix = $request->isCpRequest() ? 'craft.actions.craft.cp.uploads' : 'craft.actions.craft.uploads'; - - return new JsonResponse(new UploadSessionData( - id: $session->id, - chunkSize: $session->chunkSize, - partCount: $session->partCount(), - urls: [ - 'part' => route("$prefix.part", ['upload' => $session->id]), - 'complete' => route("$prefix.complete", ['upload' => $session->id]), - 'cancel' => route("$prefix.destroy", ['upload' => $session->id]), - ], - )->toArray(), 201); - } - - public function part(Request $request, string $upload): JsonResponse - { - $data = $request->validate(['part' => ['required', 'integer', 'min:1']]); - - return new JsonResponse($this->uploads->part($request, $upload, (int) $data['part'])); - } - - public function chunk(Request $request, string $upload, int $part): Response - { - $this->uploads->receive($request, $upload, $part); - - return response()->noContent(); - } - - public function complete(Request $request, string $upload): JsonResponse - { - $result = $this->uploads->complete($request, $upload); - - return new JsonResponse($result->payload(), $result->status); - } - - public function destroy(Request $request, string $upload): Response - { - $this->uploads->cancel($request, $upload); - - return response()->noContent(); + $data = $request->validated(); + $session = $this->uploads->start( + $request, + AssetUploads::class, + $data['filename'], + (int) $data['size'], + Arr::except($data, ['filename', 'size']), + ); + + return new JsonResponse(UploadSessionData::fromSession($session)->toArray(), 201); } } diff --git a/src/Http/Controllers/UploadSessionController.php b/src/Http/Controllers/UploadSessionController.php new file mode 100644 index 00000000000..0fbfed8f156 --- /dev/null +++ b/src/Http/Controllers/UploadSessionController.php @@ -0,0 +1,41 @@ +validate(['part' => ['required', 'integer', 'min:1']]); + + return new JsonResponse($this->uploads->part($request, $upload, (int) $data['part'])); + } + + public function chunk(Request $request, string $upload, int $part): Response + { + $this->uploads->receive($request, $upload, $part); + + return response()->noContent(); + } + + public function complete(Request $request, string $upload): JsonResponse + { + return $this->uploads->complete($request, $upload); + } + + public function destroy(Request $request, string $upload): Response + { + $this->uploads->cancel($request, $upload); + + return response()->noContent(); + } +} diff --git a/src/Http/Controllers/Users/PhotoController.php b/src/Http/Controllers/Users/PhotoController.php index f3c3cefd5ec..7b3b6db9c81 100644 --- a/src/Http/Controllers/Users/PhotoController.php +++ b/src/Http/Controllers/Users/PhotoController.php @@ -4,84 +4,41 @@ namespace CraftCms\Cms\Http\Controllers\Users; -use Closure; -use CraftCms\Cms\Asset\AssetsHelper; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Element\Elements; -use CraftCms\Cms\Http\RespondsWithFlash; -use CraftCms\Cms\Support\File; +use CraftCms\Cms\Filesystem\Data\UploadSessionData; +use CraftCms\Cms\Filesystem\Uploads; +use CraftCms\Cms\Http\Requests\UploadRequest; use CraftCms\Cms\User\Elements\User; -use CraftCms\Cms\User\Users; -use CraftCms\Cms\View\TemplateMode; -use CraftCms\Cms\View\TemplateResolver; -use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use CraftCms\Cms\User\UserPhotoUploads; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Log; -use Symfony\Component\HttpFoundation\Response; -use Throwable; - -use function CraftCms\Cms\t; -use function CraftCms\Cms\template; readonly class PhotoController { - use AuthorizesRequests; - use RespondsWithFlash; - public function __construct( - private Users $users, + private UserPhotoUploads $photos, ) {} public function renderInput(Request $request): JsonResponse { $user = $this->authorizeUserPhotoTarget($request); - return $this->renderPhotoTemplate($request, $user); + return $this->photos->renderInput($request, $user); } - public function upload(Request $request): Response + public function upload(UploadRequest $request, Uploads $uploads): JsonResponse { - $user = $this->authorizeUserPhotoTarget($request); - - $request->validate([ - 'photo' => [ - 'nullable', - 'file', - function (string $attribute, mixed $value, Closure $fail): void { - if ($value instanceof UploadedFile && $value->getSize() > AssetsHelper::getMaxUploadSize()) { - $fail(t('The uploaded file exceeds the maximum allowed size.')); - } - }, - ], - ]); - - if (! $request->hasFile('photo')) { - return new JsonResponse; - } - - try { - $uploadedFile = $request->file('photo'); - - // Move to our own temp location - $fileLocation = AssetsHelper::tempFilePath($uploadedFile->extension()); - $file = $uploadedFile->move(dirname($fileLocation), basename($fileLocation)); - $this->users->saveUserPhoto($fileLocation, $user, $uploadedFile->getClientOriginalName(), $file->getMimeType()); - - return $this->renderPhotoTemplate($request, $user); - } catch (Throwable $exception) { - if (isset($fileLocation) && file_exists($fileLocation)) { - File::delete($fileLocation); - } - - Log::error('There was an error uploading the photo: '.$exception->getMessage()); - - return $this->asFailure(t('There was an error uploading your photo: {error}', [ - 'error' => $exception->getMessage(), - ])); - } - + $data = $request->validated(); + $session = $uploads->start( + $request, + UserPhotoUploads::class, + $data['filename'], + (int) $data['size'], + $request->only('userId'), + ); + + return new JsonResponse(UploadSessionData::fromSession($session)->toArray(), 201); } public function destroy(Request $request, Elements $elements): JsonResponse @@ -95,7 +52,7 @@ public function destroy(Request $request, Elements $elements): JsonResponse $user->photoId = null; $elements->saveElement($user, false); - return $this->renderPhotoTemplate($request, $user); + return $this->photos->renderInput($request, $user); } private function authorizeUserPhotoTarget(Request $request): User @@ -104,34 +61,6 @@ private function authorizeUserPhotoTarget(Request $request): User 'userId' => ['required', 'integer'], ]); - $userId = $request->integer('userId'); - $user = $this->users->getUserById($userId); - - abort_if(! $user, 400, "Invalid user ID: {$userId}"); - - $this->authorize('save', $user); - - return $user; - } - - private function renderPhotoTemplate(Request $request, User $user): JsonResponse - { - $templateMode = TemplateMode::get(); - if (TemplateMode::is(TemplateMode::Site) && ! app(TemplateResolver::class)->exists('users/_photo.twig')) { - $templateMode = TemplateMode::Cp; - } - - $data = [ - 'html' => template('users/_photo', [ - 'user' => $user, - ], templateMode: $templateMode), - 'photoId' => $user->photoId, - ]; - - if ($user->getIsCurrent() && $request->isCpRequest()) { - $data['headerPhotoHtml'] = template('_layouts/components/header-photo'); - } - - return new JsonResponse($data); + return $this->photos->user($request->integer('userId')); } } diff --git a/src/Http/Requests/AssetUploadRequest.php b/src/Http/Requests/AssetUploadRequest.php new file mode 100644 index 00000000000..71463f78137 --- /dev/null +++ b/src/Http/Requests/AssetUploadRequest.php @@ -0,0 +1,26 @@ +> */ + public function rules(): array + { + return [ + ...parent::rules(), + 'operation' => ['sometimes', Rule::in(['upload', 'replace'])], + 'folderId' => ['nullable', 'integer', 'min:1'], + 'fieldId' => ['nullable', 'integer', 'min:1'], + 'elementId' => ['nullable', 'integer', 'min:1'], + 'siteId' => ['nullable', 'integer', 'min:1'], + 'assetId' => ['nullable', 'integer', 'min:1'], + 'context' => ['sometimes', 'array', 'max:20'], + ]; + } +} diff --git a/src/Http/Requests/UploadRequest.php b/src/Http/Requests/UploadRequest.php index efde8b28794..7b3cb320620 100644 --- a/src/Http/Requests/UploadRequest.php +++ b/src/Http/Requests/UploadRequest.php @@ -4,26 +4,16 @@ namespace CraftCms\Cms\Http\Requests; -use CraftCms\Cms\Asset\AssetsHelper; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\In; class UploadRequest extends FormRequest { - /** @return array> */ + /** @return array> */ public function rules(): array { return [ 'filename' => ['required', 'string', 'max:255'], - 'size' => ['required', 'integer', 'min:1', 'max:'.AssetsHelper::getMaxAssetUploadSize()], - 'operation' => ['sometimes', Rule::in(['upload', 'replace'])], - 'folderId' => ['nullable', 'integer', 'min:1'], - 'fieldId' => ['nullable', 'integer', 'min:1'], - 'elementId' => ['nullable', 'integer', 'min:1'], - 'siteId' => ['nullable', 'integer', 'min:1'], - 'assetId' => ['nullable', 'integer', 'min:1'], - 'context' => ['sometimes', 'array', 'max:20'], + 'size' => ['required', 'integer', 'min:1'], ]; } } diff --git a/src/User/UserPhotoUploads.php b/src/User/UserPhotoUploads.php new file mode 100644 index 00000000000..161a17b5e5a --- /dev/null +++ b/src/User/UserPhotoUploads.php @@ -0,0 +1,81 @@ +user() === null, 401); + $data = Validator::validate($parameters, ['userId' => ['required', 'integer', 'min:1']]); + $user = $this->user((int) $data['userId']); + + abort_unless(ImageHelper::canManipulateAsImage(pathinfo($filename, PATHINFO_EXTENSION)), 422, t('User photo must be an image that Craft can manipulate.')); + abort_if($size > AssetsHelper::getMaxAssetUploadSize(), 422, t('The uploaded file exceeds the maximum allowed size.')); + + return ['userId' => $user->id]; + } + + public function complete(Request $request, array $parameters, UploadedFile $file): JsonResponse + { + $user = $this->user((int) $parameters['userId']); + try { + $this->users->saveUserPhoto($file->localPath(), $user, $file->filename, $file->mimeType()); + } catch (ImageException $exception) { + throw ValidationException::withMessages(['photo' => $exception->getMessage()]); + } + + return $this->renderInput($request, $user); + } + + public function user(int $userId): User + { + $user = $this->users->getUserById($userId); + abort_if(! $user, 400, "Invalid user ID: {$userId}"); + Gate::authorize('save', $user); + + return $user; + } + + public function renderInput(Request $request, User $user): JsonResponse + { + $templateMode = TemplateMode::get(); + if (TemplateMode::is(TemplateMode::Site) && ! app(TemplateResolver::class)->exists('users/_photo.twig')) { + $templateMode = TemplateMode::Cp; + } + + $data = [ + 'html' => template('users/_photo', [ + 'user' => $user, + ], templateMode: $templateMode), + 'photoId' => $user->photoId, + ]; + + if ($user->getIsCurrent() && $request->isCpRequest()) { + $data['headerPhotoHtml'] = template('_layouts/components/header-photo'); + } + + return new JsonResponse($data); + } +} diff --git a/tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php b/tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php index 4a7be48a564..343aea86e46 100644 --- a/tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php +++ b/tests/Feature/Http/Controllers/Assets/UploadSessionControllerTest.php @@ -7,12 +7,13 @@ use CraftCms\Cms\Asset\Conditions\FileTypeConditionRule; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Events\AssetFileHandling; -use CraftCms\Cms\Asset\Models\UploadSession; use CraftCms\Cms\Asset\Models\Volume; use CraftCms\Cms\Asset\Models\VolumeFolder; use CraftCms\Cms\Cms; use CraftCms\Cms\Entry\Models\Entry; use CraftCms\Cms\Field\Assets; +use CraftCms\Cms\Filesystem\Models\UploadSession; +use CraftCms\Cms\Filesystem\Uploads; use CraftCms\Cms\Http\Controllers\Assets\UploadSessionController; use CraftCms\Cms\User\Elements\User; use Illuminate\Auth\Access\AuthorizationException; @@ -144,11 +145,11 @@ $this->travel(23)->hours(); postJson($session['urls']['part'], ['part' => 1])->assertOk(); $this->travel(2)->hours(); - expect(app(AssetUploads::class)->cleanupExpired())->toBe(['removed' => 0, 'failed' => 0]); + expect(app(Uploads::class)->cleanupExpired())->toBe(['removed' => 0, 'failed' => 0]); $this->travel(23)->hours(); postJson($session['urls']['part'], ['part' => 1])->assertGone(); - expect(app(AssetUploads::class)->cleanupExpired())->toBe(['removed' => 1, 'failed' => 0]); + expect(app(Uploads::class)->cleanupExpired())->toBe(['removed' => 1, 'failed' => 0]); expect(UploadSession::count())->toBe(0); }); diff --git a/tests/Feature/Http/Controllers/User/PhotoControllerTest.php b/tests/Feature/Http/Controllers/User/PhotoControllerTest.php index 8aa347e3648..69faa519f3e 100644 --- a/tests/Feature/Http/Controllers/User/PhotoControllerTest.php +++ b/tests/Feature/Http/Controllers/User/PhotoControllerTest.php @@ -1,22 +1,54 @@ set('filesystems.disks.photo-parts', ['driver' => 'local', 'root' => storage_path('framework/testing/photo-parts')]); + Storage::fake('photo-parts'); + Cms::config()->tempAssetUploadFs = 'disk:photo-parts'; + Cms::config()->uploadChunkSize = 512; + + $this->uploadPhoto = function (UploadedFile $photo): TestResponse { + $session = postJson(action([PhotoController::class, 'upload']), [ + 'userId' => auth()->id(), + 'filename' => $photo->getClientOriginalName(), + 'size' => $photo->getSize(), + ])->assertCreated()->json(); + + foreach (str_split($photo->getContent(), $session['chunkSize']) as $index => $bytes) { + $part = postJson($session['urls']['part'], ['part' => $index + 1])->assertOk()->json(); + $this->call('POST', $part['url'], server: ['CONTENT_TYPE' => 'application/octet-stream'], content: $bytes)->assertNoContent(); + } + + $response = postJson($session['urls']['complete']); + if ($response->isSuccessful()) { + postJson($session['urls']['complete'])->assertExactJson($response->json()); + } + + return $response; + }; }); it('requires login', function () { @@ -29,7 +61,7 @@ test('userId is required', function () { postJson(action([PhotoController::class, 'renderInput']))->assertJsonValidationErrorFor('userId'); - postJson(action([PhotoController::class, 'upload']))->assertJsonValidationErrorFor('userId'); + postJson(action([PhotoController::class, 'upload']), ['filename' => 'avatar.jpg', 'size' => 3])->assertJsonValidationErrorFor('userId'); postJson(action([PhotoController::class, 'destroy']))->assertJsonValidationErrorFor('userId'); }); @@ -59,6 +91,7 @@ 'userId' => $targetUser->id, ])], 'upload' => [fn (User $targetUser) => postJson(action([PhotoController::class, 'upload']), [ + 'filename' => 'avatar.jpg', 'size' => 3, 'userId' => $targetUser->id, ])], 'destroy' => [fn (User $targetUser) => postJson(action([PhotoController::class, 'destroy']), [ @@ -76,12 +109,13 @@ actingAs($currentUser); - $request($targetUser)->assertOk(); + $request($targetUser)->assertSuccessful(); })->with([ 'render input' => [fn (User $targetUser) => postJson(action([PhotoController::class, 'renderInput']), [ 'userId' => $targetUser->id, ])], 'upload' => [fn (User $targetUser) => postJson(action([PhotoController::class, 'upload']), [ + 'filename' => 'avatar.jpg', 'size' => 3, 'userId' => $targetUser->id, ])], 'destroy' => [fn (User $targetUser) => postJson(action([PhotoController::class, 'destroy']), [ @@ -89,7 +123,7 @@ ])], ]); -test('upload', function () { +test('uploads and replaces a user photo through sessions', function () { if (DB::isMysql()) { $this->markTestSkipped('Bulk ops cause issues with MySQL'); } @@ -105,16 +139,21 @@ ProjectConfig::set('users.photoVolumeUid', $volume->uid); - post(action([PhotoController::class, 'upload']), [ - 'userId' => auth()->id(), - 'photo' => UploadedFile::fake()->image('avatar.jpg'), - ], ['Accept' => 'application/json'])->assertOk()->assertJsonStructure([ + ($this->uploadPhoto)(UploadedFile::fake()->image('avatar.jpg'))->assertOk()->assertJsonStructure([ 'html', 'photoId', 'headerPhotoHtml', ]); - expect(User::findOne(auth()->id())->getPhoto())->not->toBeNull(); + $photoId = User::findOne(auth()->id())->photoId; + expect($photoId)->not->toBeNull(); + + ($this->uploadPhoto)(UploadedFile::fake()->image('replacement.jpg', 20, 20)) + ->assertOk()->assertJsonPath('photoId', $photoId); + + expect(User::findOne(auth()->id())->photoId)->toBe($photoId) + ->and(Asset::find()->volumeId($volume->id)->count())->toBe(1); + Storage::disk('photo-parts')->assertDirectoryEmpty('upload-sessions'); }); test('upload rejects photos larger than the configured upload limit', function () { @@ -122,8 +161,10 @@ postJson(action([PhotoController::class, 'upload']), [ 'userId' => auth()->id(), - 'photo' => UploadedFile::fake()->createWithContent('avatar.jpg', str_repeat('a', 11)), - ])->assertJsonValidationErrorFor('photo'); + 'filename' => 'avatar.jpg', 'size' => 11, + ])->assertUnprocessable(); + + expect(UploadSession::count())->toBe(0); }); test('destroy', function () { @@ -142,10 +183,7 @@ ProjectConfig::set('users.photoVolumeUid', $volume->uid); - post(action([PhotoController::class, 'upload']), [ - 'userId' => auth()->id(), - 'photo' => UploadedFile::fake()->image('avatar.jpg'), - ], ['Accept' => 'application/json']); + ($this->uploadPhoto)(UploadedFile::fake()->image('avatar.jpg'))->assertOk(); $photoId = User::findOne(auth()->id())->photoId; @@ -158,3 +196,62 @@ expect(User::findOne(auth()->id())->photoId)->toBeNull() ->and(Asset::find()->id($photoId)->status(null)->one())->toBeNull(); }); + +it('does not grant photo uploads through the asset guest authorizer', function () { + auth()->logout(); + app(AssetUploads::class)->allowGuestUploadsUsing(fn () => true); + + postJson(action([PhotoController::class, 'upload']), [ + 'userId' => 1, 'filename' => 'avatar.jpg', 'size' => 3, + ])->assertUnauthorized(); + + expect(UploadSession::count())->toBe(0); +}); + +it('binds photo sessions to the server-selected handler and user', function () { + $session = postJson(action([PhotoController::class, 'upload']), [ + 'userId' => (string) auth()->id(), 'filename' => 'avatar.jpg', 'size' => 3, + 'handler' => AssetUploads::class, 'folderId' => 999, + ])->assertCreated()->json(); + + $stored = UploadSession::findOrFail($session['id']); + expect($stored->handler)->toBe(UserPhotoUploads::class) + ->and($stored->parameters)->toBe(['userId' => auth()->id()]); + + Gate::partialMock()->shouldReceive('authorize')->andThrow(new AuthorizationException); + + postJson($session['urls']['part'], ['part' => 1])->assertForbidden(); + postJson($session['urls']['complete'], ['userId' => 999])->assertForbidden(); + $this->deleteJson($session['urls']['cancel'])->assertNoContent(); + + expect(UploadSession::count())->toBe(0); + Storage::disk('photo-parts')->assertDirectoryEmpty('upload-sessions'); +}); + +it('rejects non-image photo names before accepting bytes', function () { + postJson(action([PhotoController::class, 'upload']), [ + 'userId' => auth()->id(), 'filename' => 'document.txt', 'size' => 3, + ])->assertUnprocessable(); + + expect(UploadSession::count())->toBe(0); +}); + +it('rejects invalid image contents without changing the users photo', function () { + if (DB::isMysql()) { + $this->markTestSkipped('Bulk ops cause issues with MySQL'); + } + + config()->set('filesystems.disks.invalid-photo', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/invalid-photo'), + ]); + $volume = Volume::factory()->create(['fs' => 'disk:invalid-photo']); + ProjectConfig::set('users.photoVolumeUid', $volume->uid); + $photoId = User::findOne(auth()->id())->photoId; + + ($this->uploadPhoto)(UploadedFile::fake()->createWithContent('avatar.jpg', 'not an image')) + ->assertUnprocessable(); + + expect(User::findOne(auth()->id())->photoId)->toBe($photoId) + ->and(Asset::find()->volumeId($volume->id)->count())->toBe(0); +}); diff --git a/tests/Unit/Asset/S3UploaderTest.php b/tests/Unit/Filesystem/S3UploaderTest.php similarity index 95% rename from tests/Unit/Asset/S3UploaderTest.php rename to tests/Unit/Filesystem/S3UploaderTest.php index 0eb886a5c6e..902d792fde3 100644 --- a/tests/Unit/Asset/S3UploaderTest.php +++ b/tests/Unit/Filesystem/S3UploaderTest.php @@ -5,10 +5,10 @@ use Aws\CommandInterface; use Aws\Result; use Aws\S3\S3Client; -use CraftCms\Cms\Asset\Data\UploadedAssetFile; -use CraftCms\Cms\Asset\Models\UploadSession; -use CraftCms\Cms\Asset\Uploaders\S3Uploader; +use CraftCms\Cms\Filesystem\Data\UploadedFile; use CraftCms\Cms\Filesystem\Filesystems; +use CraftCms\Cms\Filesystem\Models\UploadSession; +use CraftCms\Cms\Filesystem\Uploaders\S3Uploader; use GuzzleHttp\Promise\Create; use Illuminate\Filesystem\AwsS3V3Adapter; use League\Flysystem\AwsS3V3\AwsS3V3Adapter as S3Adapter; @@ -82,7 +82,7 @@ $this->disk->shouldReceive('getDriver')->andReturn(new Filesystem( new S3Adapter($this->client, 'upload-bucket', 'prefix'), )); - $file = new UploadedAssetFile($this->disk, 'staged/file', 'archive.zip'); + $file = new UploadedFile($this->disk, 'staged/file', 'archive.zip'); $file->storeAs($this->disk, 'assets/archive.zip', 'application/zip'); $names = array_map(fn ($command) => $command->getName(), $this->commands); @@ -97,7 +97,7 @@ rewind($stream); $this->disk->shouldReceive('readStream')->once()->with('staged/file')->andReturn($stream); $this->disk->shouldReceive('size')->once()->with('staged/file')->andReturn(8); - $file = new UploadedAssetFile($this->disk, 'staged/file', 'image.svg'); + $file = new UploadedFile($this->disk, 'staged/file', 'image.svg'); try { $path = $file->localPath(); diff --git a/tests/Unit/Asset/AssetUploadersTest.php b/tests/Unit/Filesystem/UploadersTest.php similarity index 88% rename from tests/Unit/Asset/AssetUploadersTest.php rename to tests/Unit/Filesystem/UploadersTest.php index 6080d41b1b0..c19dfb4930d 100644 --- a/tests/Unit/Asset/AssetUploadersTest.php +++ b/tests/Unit/Filesystem/UploadersTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use CraftCms\Cms\Asset\AssetUploaders; -use CraftCms\Cms\Asset\Models\UploadSession; -use CraftCms\Cms\Asset\Uploaders\ChunkedUploader; use CraftCms\Cms\Cms; +use CraftCms\Cms\Filesystem\Models\UploadSession; +use CraftCms\Cms\Filesystem\Uploaders; +use CraftCms\Cms\Filesystem\Uploaders\ChunkedUploader; use Illuminate\Contracts\Container\Container; use Illuminate\Support\Facades\Storage; @@ -19,9 +19,9 @@ }); it('routes custom uploader operations to each sessions disk', function () { - $manager = app(AssetUploaders::class); + $manager = app(Uploaders::class); $manager->extend('custom', fn (Container $app) => $app->make(ChunkedUploader::class)); - Cms::config()->assetUploader = 'custom'; + Cms::config()->uploader = 'custom'; expect($manager->getDefaultDriver())->toBe('custom'); diff --git a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php index f2eb72ea51d..fb9e7974f68 100644 --- a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php +++ b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php @@ -5,9 +5,7 @@ namespace Workbench\App\Providers; use CraftCms\Cms\Asset\Data\AssetTransformerIndexData; -use CraftCms\Cms\Asset\Data\UploadPartRequest; use CraftCms\Cms\Asset\Data\UploadResult; -use CraftCms\Cms\Asset\Data\UploadSessionData; use CraftCms\Cms\Cp\Data\NavItem; use CraftCms\Cms\Cp\Data\NotificationButtonData; use CraftCms\Cms\Cp\Data\NotificationData; @@ -15,6 +13,8 @@ use CraftCms\Cms\Dashboard\Data\WidgetTypeData; use CraftCms\Cms\Entry\Data\EntryType; use CraftCms\Cms\Entry\Data\EntryTypeIndexData; +use CraftCms\Cms\Filesystem\Data\UploadPartRequest; +use CraftCms\Cms\Filesystem\Data\UploadSessionData; use CraftCms\Cms\Form\ControlPayload; use CraftCms\Cms\Form\Enums\ChoicePresentation; use CraftCms\Cms\Form\Enums\ControlMode; From c8a0c51b59cc1039bb6c61479ae516d14ee76036 Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 8 Sep 2026 20:02:18 +0200 Subject: [PATCH 4/7] Refactor --- src/Filesystem/Uploaders/S3Uploader.php | 67 +++++++++++++------------ 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/src/Filesystem/Uploaders/S3Uploader.php b/src/Filesystem/Uploaders/S3Uploader.php index cfd586451dd..c0b384cd876 100644 --- a/src/Filesystem/Uploaders/S3Uploader.php +++ b/src/Filesystem/Uploaders/S3Uploader.php @@ -4,6 +4,8 @@ namespace CraftCms\Cms\Filesystem\Uploaders; +use Aws\S3\Exception\S3Exception; +use Aws\S3\S3Client; use CraftCms\Cms\Filesystem\Contracts\Uploader; use CraftCms\Cms\Filesystem\Data\UploadedFile; use CraftCms\Cms\Filesystem\Data\UploadPartRequest; @@ -11,7 +13,6 @@ use CraftCms\Cms\Filesystem\Models\UploadSession; use Illuminate\Filesystem\AwsS3V3Adapter; use InvalidArgumentException; -use Throwable; class S3Uploader implements Uploader { @@ -59,39 +60,41 @@ public function complete(UploadSession $session): UploadedFile { $disk = $this->disk($session); - if (! $disk->exists($session->path())) { - $uploadedParts = []; - $marker = null; + if ($disk->exists($session->path())) { + return new UploadedFile($disk, $session->path(), $session->filename); + } - do { - $result = $this->client($session)->listParts([ - ...$this->location($session), - 'UploadId' => $session->state['uploadId'], - ...($marker === null ? [] : ['PartNumberMarker' => $marker]), - ]); + $uploadedParts = []; + $marker = null; - foreach ($result['Parts'] ?? [] as $part) { - $number = (int) $part['PartNumber']; - abort_unless((int) $part['Size'] === $session->partSize($number), 422, 'An S3 upload part has an incorrect size.'); - $uploadedParts[] = ['PartNumber' => $number, 'ETag' => $part['ETag']]; - } + do { + $result = $this->client($session)->listParts([ + ...$this->location($session), + 'UploadId' => $session->state['uploadId'], + ...($marker === null ? [] : ['PartNumberMarker' => $marker]), + ]); - $marker = $result['NextPartNumberMarker'] ?? null; - } while ($result['IsTruncated'] ?? false); + foreach ($result['Parts'] ?? [] as $part) { + $number = (int) $part['PartNumber']; + abort_unless((int) $part['Size'] === $session->partSize($number), 422, 'An S3 upload part has an incorrect size.'); + $uploadedParts[] = ['PartNumber' => $number, 'ETag' => $part['ETag']]; + } - abort_unless(count($uploadedParts) === $session->partCount(), 422, 'The S3 upload is missing a part.'); + $marker = $result['NextPartNumberMarker'] ?? null; + } while ($result['IsTruncated'] ?? false); - foreach ($uploadedParts as $index => $part) { - abort_unless($part['PartNumber'] === $index + 1, 422, 'The S3 upload is missing a part.'); - } + abort_unless(count($uploadedParts) === $session->partCount(), 422, 'The S3 upload is missing a part.'); - $this->client($session)->completeMultipartUpload([ - ...$this->location($session), - 'UploadId' => $session->state['uploadId'], - 'MultipartUpload' => ['Parts' => $uploadedParts], - ]); + foreach ($uploadedParts as $index => $part) { + abort_unless($part['PartNumber'] === $index + 1, 422, 'The S3 upload is missing a part.'); } + $this->client($session)->completeMultipartUpload([ + ...$this->location($session), + 'UploadId' => $session->state['uploadId'], + 'MultipartUpload' => ['Parts' => $uploadedParts], + ]); + return new UploadedFile($disk, $session->path(), $session->filename); } @@ -103,8 +106,8 @@ public function abort(UploadSession $session): void ...$this->location($session), 'UploadId' => $session->state['uploadId'], ]); - } catch (Throwable $exception) { - if (! method_exists($exception, 'getAwsErrorCode') || $exception->getAwsErrorCode() !== 'NoSuchUpload') { + } catch (S3Exception $exception) { + if ($exception->getAwsErrorCode() !== 'NoSuchUpload') { throw $exception; } } @@ -120,7 +123,10 @@ private function location(UploadSession $session): array { $disk = $this->disk($session); - return ['Bucket' => $disk->getConfig()['bucket'], 'Key' => $disk->path($session->path())]; + return [ + 'Bucket' => $disk->getConfig()['bucket'], + 'Key' => $disk->path($session->path()), + ]; } private function disk(UploadSession $session): AwsS3V3Adapter @@ -134,8 +140,7 @@ private function disk(UploadSession $session): AwsS3V3Adapter return $disk; } - /** The SDK is an optional dependency supplied by the S3 filesystem adapter. */ - private function client(UploadSession $session): mixed + private function client(UploadSession $session): S3Client { return $this->disk($session)->getClient(); } From ff7f82e300f4466fd34ad7bf2be71c09a4580ad0 Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 8 Sep 2026 20:30:30 +0200 Subject: [PATCH 5/7] Fix user photo selection and enforce its configured folder --- CHANGELOG.md | 1 + composer.json | 1 + composer.lock | 274 +++++++++++++++++- .../element-selector-controller.test.ts | 17 ++ .../element-selector-controller.ts | 8 + .../forms/ElementSelectControl.test.ts | 21 ++ .../js/modules/forms/ElementSelectControl.vue | 2 + .../LayoutElements/Users/PhotoField.php | 15 +- src/Form/Controls/AssetSelect.php | 10 + .../Controllers/Users/SaveUserController.php | 55 +++- src/User/Users.php | 20 +- .../SaveUserController/ProfileEditTest.php | 183 ++++++++++++ 12 files changed, 592 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cef4e213bb4..e86bc260467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Migrated the reassign entries, replace relations, and replace references modals to the Form API. ([#19589](https://github.com/craftcms/cms/pull/19589)) - Added shared upload sessions for assets and user photos, direct S3 multipart uploads, upload retry and cancellation controls, and extensible upload transports and destination handlers. - Changed `users/upload-user-photo` to start an upload session using JSON file metadata instead of multipart file data. +- Fixed user photo asset selections not being saved, and restricted the photo selector to images in the configured volume and subfolder. - Removed the `assets/upload` and `assets/replace-file` multipart endpoints. Asset uploads now use upload sessions, and filename conflicts are resolved through `assets/resolve-upload-conflict`. - Added support for refreshable standard plugin settings forms and conditional configuration of core form nodes. ([#19545](https://github.com/craftcms/cms/pull/19545)) - Added support for sending queued Laravel notifications to `CraftCms\Cms\User\Elements\User` elements. ([#19541](https://github.com/craftcms/cms/pull/19541)) diff --git a/composer.json b/composer.json index b5ce86e23cb..22facec80ee 100644 --- a/composer.json +++ b/composer.json @@ -87,6 +87,7 @@ "laravel/pao": "^1.0", "laravel/pint": "^1.22", "laravel/socialite": "^5.23", + "league/flysystem-aws-s3-v3": "^3.35", "orchestra/testbench": "^11.0", "pestphp/pest": "^5.0", "pestphp/pest-plugin-agent": "^5.0", diff --git a/composer.lock b/composer.lock index 19426b55ba5..8d2588763a7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "89fdea3686e8bb00dc45b6ac82752187", + "content-hash": "9b3b53dfa2f68624076319609b8b92b9", "packages": [ { "name": "bacon/bacon-qr-code", @@ -9862,6 +9862,157 @@ } ], "packages-dev": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.394.10", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "3b7cbf17b8ba568f77546d1e5edd3f2071861c8f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/3b7cbf17b8ba568f77546d1e5edd3f2071861c8f", + "reference": "3b7cbf17b8ba568f77546d1e5edd3f2071861c8f", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", + "mtdowling/jmespath.php": "^2.9.1", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^10.0", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.394.10" + }, + "time": "2026-09-08T18:07:43+00:00" + }, { "name": "barryvdh/laravel-debugbar", "version": "v4.4.3", @@ -11518,6 +11669,61 @@ }, "time": "2026-03-17T14:54:13+00:00" }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "b03780cb97585ee7e48977f40ed599b33b751634" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/b03780cb97585ee7e48977f40ed599b33b751634", + "reference": "b03780cb97585ee7e48977f40ed599b33b751634", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.371.5", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.3" + }, + "time": "2026-08-08T16:19:23+00:00" + }, { "name": "league/oauth1-client", "version": "v1.11.0", @@ -11676,6 +11882,72 @@ }, "time": "2026-08-19T19:37:52+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.9.2", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.52" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.9-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.2" + }, + "time": "2026-07-06T18:56:19+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.14.0", diff --git a/packages/craftcms-ui/src/core/element-selector/element-selector-controller.test.ts b/packages/craftcms-ui/src/core/element-selector/element-selector-controller.test.ts index 196293cdc36..05b21dbcacd 100644 --- a/packages/craftcms-ui/src/core/element-selector/element-selector-controller.test.ts +++ b/packages/craftcms-ui/src/core/element-selector/element-selector-controller.test.ts @@ -73,6 +73,23 @@ describe('options', () => { }); describe('indexParams', () => { + it('sends asset location criteria and folder visibility when loading the index', async () => { + const loadIndexBody = vi.fn(async () => body()); + const criteria = {volumeId: 2, folderId: 7, kind: 'image'}; + const controller = create({ + criteria, + indexSettings: {showFolders: false}, + loadIndexBody, + }); + + await controller.open(); + + expect(loadIndexBody).toHaveBeenCalledWith( + 'element-selector-modals/body', + expect.objectContaining({criteria, showFolders: false}) + ); + }); + it('identifies the index', () => { const controller = create({ sources: ['section:a'], diff --git a/packages/craftcms-ui/src/core/element-selector/element-selector-controller.ts b/packages/craftcms-ui/src/core/element-selector/element-selector-controller.ts index 9f7d3d31799..438b90d6eb7 100644 --- a/packages/craftcms-ui/src/core/element-selector/element-selector-controller.ts +++ b/packages/craftcms-ui/src/core/element-selector/element-selector-controller.ts @@ -311,6 +311,14 @@ export class ElementSelectorController< params.siteIds = options.siteIds; } + if (options.criteria) { + params.criteria = {...options.criteria}; + } + + if (typeof options.indexSettings.showFolders === 'boolean') { + params.showFolders = options.indexSettings.showFolders; + } + return params; } diff --git a/resources/js/modules/forms/ElementSelectControl.test.ts b/resources/js/modules/forms/ElementSelectControl.test.ts index d861c71ef0b..1e23f999786 100644 --- a/resources/js/modules/forms/ElementSelectControl.test.ts +++ b/resources/js/modules/forms/ElementSelectControl.test.ts @@ -224,6 +224,27 @@ describe('ElementSelectControl', () => { expect(updates).toEqual([[5, 9]]); }); + it('opens a photo selector with its folder restrictions', async () => { + await mount({ + value: [], + props: { + sources: ['volume:photos/folder:avatars'], + criteria: {volumeId: 2, folderId: 7, kind: 'image'}, + showFolders: false, + }, + }); + + (container!.querySelector('craft-button') as HTMLElement).click(); + await flushSelector(); + + const [, settings] = stub.createElementSelectorModal.mock.calls[0]!; + expect(settings).toMatchObject({ + sources: ['volume:photos/folder:avatars'], + criteria: {volumeId: 2, folderId: 7, kind: 'image'}, + indexSettings: {showFolders: false}, + }); + }); + it('drops Replace when there is no element type to pick from', async () => { const root = await mount({props: {elementType: null}}); diff --git a/resources/js/modules/forms/ElementSelectControl.vue b/resources/js/modules/forms/ElementSelectControl.vue index 82aec31ae14..842d611b89e 100644 --- a/resources/js/modules/forms/ElementSelectControl.vue +++ b/resources/js/modules/forms/ElementSelectControl.vue @@ -93,6 +93,7 @@ canUpload?: boolean; uploadFolderId?: number | null; fsType?: string | null; + showFolders?: boolean; }; const props = defineProps<{ control: FormControlPayload; @@ -317,6 +318,7 @@ criteria: props.control.props.criteria as Record, condition: props.control.props.selectionCondition, showSiteMenu: props.control.props.showSiteMenu, + indexSettings: {showFolders: props.control.props.showFolders ?? true}, multiSelect: replacing === null && remaining !== 1, // Already-related elements can't be picked again — except the one being // replaced, which would otherwise disable the obvious no-op choice. diff --git a/src/FieldLayout/LayoutElements/Users/PhotoField.php b/src/FieldLayout/LayoutElements/Users/PhotoField.php index ee2ec342aa0..d805dfdab30 100644 --- a/src/FieldLayout/LayoutElements/Users/PhotoField.php +++ b/src/FieldLayout/LayoutElements/Users/PhotoField.php @@ -9,13 +9,14 @@ use CraftCms\Cms\FieldLayout\FieldLayoutElementContext; use CraftCms\Cms\FieldLayout\LayoutElements\BaseNativeField; use CraftCms\Cms\Form\Contracts\Control; -use CraftCms\Cms\Form\Controls\ElementSelect; +use CraftCms\Cms\Form\Controls\AssetSelect; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\InputNamespace; use CraftCms\Cms\Support\Facades\ProjectConfig; use CraftCms\Cms\Support\Facades\Volumes; use CraftCms\Cms\User\Elements\User; +use CraftCms\Cms\User\Users; use CraftCms\Cms\View\LegacyAssets\InternalAssetRegistry; use CraftCms\Cms\View\LegacyAssets\UserPhotoAsset; use InvalidArgumentException; @@ -71,8 +72,18 @@ protected function formControl(FieldLayoutElementContext $context): ?Control return null; } - return ElementSelect::make($this->attribute()) + $folder = app(Users::class)->userPhotoFolder($context->element); + $source = 'volume:'.$folder->getVolume()->uid; + + if ($folder->parentId) { + $source .= '/folder:'.$folder->uid; + } + + return AssetSelect::make($this->attribute()) ->elementType(Asset::class) + ->sources([$source]) + ->criteria(['volumeId' => $folder->volumeId, 'folderId' => $folder->id, 'kind' => 'image']) + ->showFolders(false) ->limit(1) ->value($context->element->photoId ? [$context->element->photoId] : []); } diff --git a/src/Form/Controls/AssetSelect.php b/src/Form/Controls/AssetSelect.php index f42abb64268..d64ba991e20 100644 --- a/src/Form/Controls/AssetSelect.php +++ b/src/Form/Controls/AssetSelect.php @@ -26,6 +26,15 @@ class AssetSelect extends ElementSelect private ?string $fsType = null; + private bool $showFolders = true; + + public function showFolders(bool $showFolders = true): static + { + $this->showFolders = $showFolders; + + return $this; + } + public function canUpload(bool $canUpload = true): static { $this->canUpload = $canUpload; @@ -56,6 +65,7 @@ public function props(mixed $value = null): array 'canUpload' => $this->canUpload, 'uploadFolderId' => $this->uploadFolderId, 'fsType' => $this->fsType, + 'showFolders' => $this->showFolders, ]; } } diff --git a/src/Http/Controllers/Users/SaveUserController.php b/src/Http/Controllers/Users/SaveUserController.php index b671c4eca1b..334efcd7bda 100644 --- a/src/Http/Controllers/Users/SaveUserController.php +++ b/src/Http/Controllers/Users/SaveUserController.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Http\Controllers\Users; use CraftCms\Cms\Asset\AssetsHelper; +use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Auth\AuthMethods; use CraftCms\Cms\Auth\Concerns\ConfirmsPasswords; use CraftCms\Cms\Config\GeneralConfig; @@ -254,6 +255,7 @@ public function __invoke(Request $request): Response // Validate and save! // --------------------------------------------------------------------- + $selectedPhoto = $this->validateSelectedPhoto($request, $user); $decodedUserPhoto = $this->validateUserPhoto($request, $user); // Don't validate required custom fields if it's public registration @@ -303,7 +305,7 @@ public function __invoke(Request $request): Response } // Save the user’s photo, if it was submitted - $this->processUserPhoto($request, app(Elements::class), $user, $decodedUserPhoto); + $this->processUserPhoto($request, app(Elements::class), $user, $decodedUserPhoto, $selectedPhoto); // If this is public registration, assign the user to the default user group if (Edition::isAtLeast(Edition::Pro) && $isPublicRegistration) { @@ -361,10 +363,10 @@ public function __invoke(Request $request): Response : null); } - private function processUserPhoto(Request $request, Elements $elements, User $user, ?string $decodedUserPhoto): void + private function processUserPhoto(Request $request, Elements $elements, User $user, ?string $decodedUserPhoto, ?Asset $selectedPhoto): void { // Delete their photo? - if ($request->input('deletePhoto')) { + if ($user->photoId && ($request->input('deletePhoto') || $request->input('photo') === [])) { $this->users->deleteUserPhoto($user); $user->photoId = null; $elements->saveElement($user); @@ -376,7 +378,12 @@ private function processUserPhoto(Request $request, Elements $elements, User $us $mimeType = null; // Did they upload a new one? - if ($photo = $request->file('photo')) { + if ($selectedPhoto !== null && $selectedPhoto->id !== $user->photoId) { + $fileLocation = $selectedPhoto->getCopyOfFile(); + $filename = $selectedPhoto->getFilename(); + $mimeType = $selectedPhoto->getMimeType(); + $newPhoto = true; + } elseif ($photo = $request->file('photo')) { $fileLocation = AssetsHelper::tempFilePath($photo->extension()); $photo->move(dirname($fileLocation), basename($fileLocation)); $filename = $photo->getClientOriginalName(); @@ -421,6 +428,46 @@ private function processUserPhoto(Request $request, Elements $elements, User $us } } + private function validateSelectedPhoto(Request $request, User $user): ?Asset + { + $selection = $request->input('photo'); + + if (! is_array($selection) || ! array_is_list($selection)) { + return null; + } + + $request->validate([ + 'photo' => ['array', 'max:1'], + 'photo.*' => ['required', 'integer'], + ]); + + if ($selection === []) { + return null; + } + + $photo = Asset::findOne((int) $selection[0]); + + if ($photo === null || ! ImageHelper::canManipulateAsImage($photo->getExtension())) { + $user->errors()->add('photo', t('The user photo provided is not an image.')); + + return null; + } + + if ($photo->id !== $user->photoId) { + Gate::authorize('view', $photo); + + $folder = $this->users->userPhotoFolder($user); + + if ($photo->volumeId !== $folder->volumeId || $photo->folderId !== $folder->id) { + $user->errors()->add('photo', t('The user photo provided is not valid.')); + + return null; + } + } + + return $photo; + } + private function validateUserPhoto(Request $request, User $user): ?string { $maxUploadSize = AssetsHelper::getMaxUploadSize(); diff --git a/src/User/Users.php b/src/User/Users.php index 8936af168e3..b7c5e7deab5 100644 --- a/src/User/Users.php +++ b/src/User/Users.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Asset\AssetsHelper; use CraftCms\Cms\Asset\Data\Volume; +use CraftCms\Cms\Asset\Data\VolumeFolder; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Exceptions\ImageException; use CraftCms\Cms\Asset\Exceptions\VolumeException; @@ -395,8 +396,9 @@ public function saveUserPhoto( if ($event->photoId && ($photo = AssetsService::getAssetById($event->photoId)) !== null) { AssetsService::replaceAssetFile($photo, $fileLocation, $filename, $mimeType); } else { - $volume = $this->userPhotoVolume(); - $folderId = $this->userPhotoFolderId($user, $volume); + $folder = $this->userPhotoFolder($user); + $volume = $folder->getVolume(); + $folderId = $folder->id; $filename = AssetsService::getNameReplacementInFolder($filename, $folderId); $photo = new Asset; @@ -426,8 +428,7 @@ public function relocateUserPhoto(User $user): void return; } - $volume = $this->userPhotoVolume(); - $folderId = $this->userPhotoFolderId($user, $volume); + $folderId = $this->userPhotoFolder($user)->id; if ($photo->folderId === $folderId) { return; @@ -463,13 +464,12 @@ private function userPhotoVolume(): Volume /** * Returns the folder that a user’s photo should be stored. * - * @param Volume $volume The user photo volume - * * @throws VolumeException if the user photo volume doesn’t exist * @throws InvalidSubpathException if the user photo subpath can’t be resolved */ - private function userPhotoFolderId(User $user, Volume $volume): int + public function userPhotoFolder(User $user): VolumeFolder { + $volume = $this->userPhotoVolume(); $subpath = (string) app(ProjectConfig::class)->get('users.photoSubpath'); if ($subpath !== '') { @@ -480,7 +480,11 @@ private function userPhotoFolderId(User $user, Volume $volume): int } } - return Folders::ensureFolderByFullPathAndVolume($subpath, $volume)->id; + if (array_intersect(explode('/', str_replace('\\', '/', $subpath)), ['.', '..'])) { + throw new InvalidSubpathException($subpath); + } + + return Folders::ensureFolderByFullPathAndVolume($subpath, $volume, justRecord: false); } /** diff --git a/tests/Feature/Http/Controllers/User/SaveUserController/ProfileEditTest.php b/tests/Feature/Http/Controllers/User/SaveUserController/ProfileEditTest.php index c3124dff14b..3baae613715 100644 --- a/tests/Feature/Http/Controllers/User/SaveUserController/ProfileEditTest.php +++ b/tests/Feature/Http/Controllers/User/SaveUserController/ProfileEditTest.php @@ -2,17 +2,29 @@ declare(strict_types=1); +use CraftCms\Cms\Asset\Elements\Asset; +use CraftCms\Cms\Asset\Folders; +use CraftCms\Cms\Asset\Models\Volume; +use CraftCms\Cms\Database\Factories\AssetFactory; use CraftCms\Cms\Database\Factories\UserFactory; use CraftCms\Cms\Edition; +use CraftCms\Cms\Filesystem\Exceptions\InvalidSubpathException; +use CraftCms\Cms\Http\Controllers\Elements\ElementSelectorModalController; use CraftCms\Cms\Http\Controllers\Users\SaveUserController; use CraftCms\Cms\Support\Facades\ProjectConfig; +use CraftCms\Cms\Support\Facades\Volumes; use CraftCms\Cms\User\Elements\User; +use CraftCms\Cms\User\Users; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Inertia\Testing\AssertableInertia; use function CraftCms\Cms\cp_url; use function CraftCms\Cms\t; use function Pest\Laravel\actingAs; use function Pest\Laravel\get; +use function Pest\Laravel\post; +use function Pest\Laravel\postJson; beforeEach(function () { Edition::set(Edition::Team); @@ -35,6 +47,177 @@ expect($updatedUser->lastName)->toBe('User'); }); +describe('photo selection', function () { + beforeEach(function () { + $this->user = User::findOne(); + config()->set('filesystems.disks.profile-photos', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/profile-photos'), + ]); + $this->disk = Storage::fake('profile-photos'); + $this->volume = Volume::factory()->create(['fs' => 'disk:profile-photos']); + ProjectConfig::set('users.photoVolumeUid', $this->volume->uid); + ProjectConfig::set('users.photoSubpath', 'profiles/{id}'); + $this->folder = app(Users::class)->userPhotoFolder($this->user); + $this->photo = AssetFactory::new()->createElement([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + 'filename' => 'avatar.png', + ]); + $this->bytes = UploadedFile::fake()->image('avatar.png', 20, 20)->getContent(); + $this->disk->put($this->photo->getPath(), $this->bytes); + actingAs($this->user); + }); + + it('saves an asset selected as the user photo without changing the source', function () { + post(action(SaveUserController::class), [ + 'userId' => $this->user->id, + 'photo' => [$this->photo->id], + ])->assertRedirect()->assertSessionHasNoErrors(); + + $saved = User::findOne($this->user->id)->getPhoto(); + expect($saved)->not->toBeNull() + ->and($saved->id)->not->toBe($this->photo->id) + ->and($saved->folderId)->toBe($this->folder->id) + ->and($this->disk->get($this->photo->getPath()))->toBe($this->bytes); + + post(action(SaveUserController::class), [ + 'userId' => $this->user->id, + 'photo' => [$saved->id], + ])->assertRedirect()->assertSessionHasNoErrors(); + + expect(User::findOne($this->user->id)->photoId)->toBe($saved->id); + + post(action(SaveUserController::class), [ + 'userId' => $this->user->id, + 'photo' => [], + ])->assertRedirect()->assertSessionHasNoErrors(); + + expect(User::findOne($this->user->id)->photoId)->toBeNull() + ->and(Asset::findOne($this->photo->id))->not->toBeNull() + ->and($this->disk->get($this->photo->getPath()))->toBe($this->bytes); + }); + + it('rejects assets outside the configured photo folder', function (string $location) { + $volume = $location === 'other volume' + ? Volume::factory()->create(['fs' => 'disk:profile-photos']) + : $this->volume; + Volumes::reset(); + $path = match ($location) { + 'parent' => 'profiles', + 'sibling' => 'profiles/another-user', + 'child' => $this->folder->path.'child', + default => $this->folder->path, + }; + $folder = app(Folders::class)->ensureFolderByFullPathAndVolume($path, Volumes::getVolumeById($volume->id)); + $photo = AssetFactory::new()->createElement(['volumeId' => $volume->id, 'folderId' => $folder->id]); + + post(action(SaveUserController::class), [ + 'userId' => $this->user->id, + 'firstName' => 'Should not save', + 'photo' => [$photo->id], + ])->assertRedirect()->assertSessionHasErrors('photo'); + + expect(User::findOne($this->user->id)->photoId)->toBeNull() + ->and(User::findOne($this->user->id)->firstName)->toBe($this->user->firstName); + })->with(['parent', 'sibling', 'child', 'other volume']); + + it('rejects invalid photo selections', function (array $selection) { + post(action(SaveUserController::class), [ + 'userId' => $this->user->id, + 'photo' => $selection, + ])->assertRedirect()->assertSessionHasErrors(); + + expect(User::findOne($this->user->id)->photoId)->toBeNull(); + })->with([ + 'missing asset' => [[999999]], + 'non-integer' => [['invalid']], + 'multiple assets' => [[1, 2]], + ]); + + it('saves an empty photo selection when the user has no photo', function () { + post(action(SaveUserController::class), [ + 'userId' => $this->user->id, + 'photo' => [], + ])->assertRedirect()->assertSessionHasNoErrors(); + + expect(User::findOne($this->user->id)->photoId)->toBeNull(); + }); + + it('rejects a non-image asset', function () { + $photo = AssetFactory::new()->createElement([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + 'filename' => 'document.txt', + 'kind' => 'text', + ]); + + post(action(SaveUserController::class), [ + 'userId' => $this->user->id, + 'photo' => [$photo->id], + ])->assertRedirect()->assertSessionHasErrors('photo'); + + expect(User::findOne($this->user->id)->photoId)->toBeNull(); + }); + + it('rejects an image the user cannot view', function () { + $user = UserFactory::new()->createElement(); + ProjectConfig::set('users.photoSubpath', $this->folder->path); + + actingAs($user)->post(action(SaveUserController::class), [ + 'userId' => $user->id, + 'photo' => [$this->photo->id], + ])->assertForbidden(); + + expect(User::findOne($user->id)->photoId)->toBeNull(); + }); + + it('creates the configured folder and restricts the photo selector to it', function () { + ProjectConfig::set('users.photoSubpath', 'new-photos/{id}'); + $path = "new-photos/{$this->user->id}"; + $this->disk->assertMissing($path); + + get(cp_url('myaccount'))->assertOk()->assertInertia(function (AssertableInertia $page) { + $nodes = flattenFormNodes($page->toArray()['props']['form']['nodes']); + $control = collect($nodes)->firstWhere('control.path', ['photo'])['control']; + $folder = app(Users::class)->userPhotoFolder($this->user); + + expect($control['props']['sources'])->toBe(["volume:{$this->volume->uid}/folder:{$folder->uid}"]) + ->and($control['props']['criteria'])->toBe(['volumeId' => $this->volume->id, 'folderId' => $folder->id, 'kind' => 'image']) + ->and($control['props']['showFolders'])->toBeFalse(); + }); + + $this->disk->assertExists($path); + }); + + it('only lists images in the photo folder without child folders', function () { + AssetFactory::new()->createElement([ + 'volumeId' => $this->volume->id, + 'folderId' => $this->folder->id, + 'filename' => 'document.txt', + 'kind' => 'text', + ]); + app(Folders::class)->ensureFolderByFullPathAndVolume($this->folder->path.'child', $this->folder->getVolume()); + + $response = postJson(action(ElementSelectorModalController::class), [ + 'context' => 'modal', + 'elementType' => Asset::class, + 'sources' => ["volume:{$this->volume->uid}/folder:{$this->folder->uid}"], + 'criteria' => ['volumeId' => $this->volume->id, 'folderId' => $this->folder->id, 'kind' => 'image'], + 'showFolders' => false, + ])->assertOk(); + + expect(array_column($response->json('props.data'), 'id'))->toBe([$this->photo->id]); + }); + + it('rejects traversal in the rendered photo subpath', function (string $subpath) { + ProjectConfig::set('users.photoSubpath', $subpath); + + expect(fn () => app(Users::class)->userPhotoFolder($this->user)) + ->toThrow(InvalidSubpathException::class); + })->with(['../outside', 'profiles/../outside', 'profiles\\..\\outside']); +}); + it('succeeds when user changes their email with correct current password', function () { $user = UserFactory::new()->createElement(); From 87f2ff5bd274fd9816b674200799c7a78ba087fd Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 9 Sep 2026 14:55:34 +0200 Subject: [PATCH 6/7] Keep Assets uploads running across control panel navigation --- CHANGELOG.md | 1 + docs/uploads.md | 6 + .../uploader/asset-upload-queue.test.ts | 306 ++++++++++++++++++ .../js/modules/uploader/asset-upload-queue.ts | 232 +++++++++++++ .../modules/uploader/upload-notification.ts | 155 +++++++-- resources/js/modules/uploader/upload-queue.ts | 158 +++++++++ .../js/modules/uploader/uploader.test.ts | 79 +++++ resources/js/modules/uploader/uploader.ts | 149 ++++----- .../uploader/useAssetUploadRefresh.test.ts | 129 ++++++++ .../modules/uploader/useAssetUploadRefresh.ts | 66 ++++ .../js/pages/assets/AssetUploadButton.test.ts | 28 ++ .../js/pages/assets/AssetUploadButton.vue | 14 + resources/js/pages/assets/Index.vue | 21 +- 13 files changed, 1228 insertions(+), 116 deletions(-) create mode 100644 resources/js/modules/uploader/asset-upload-queue.test.ts create mode 100644 resources/js/modules/uploader/asset-upload-queue.ts create mode 100644 resources/js/modules/uploader/upload-queue.ts create mode 100644 resources/js/modules/uploader/useAssetUploadRefresh.test.ts create mode 100644 resources/js/modules/uploader/useAssetUploadRefresh.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e86bc260467..e19ec84ba7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Moved legacy relation-field settings HTML and entry-title input HTML into the Yii adapter. ([#19591](https://github.com/craftcms/cms/pull/19591)) - Migrated the reassign entries, replace relations, and replace references modals to the Form API. ([#19589](https://github.com/craftcms/cms/pull/19589)) - Added shared upload sessions for assets and user photos, direct S3 multipart uploads, upload retry and cancellation controls, and extensible upload transports and destination handlers. +- Added background uploads in the Assets index that continue during Control Panel navigation, with persistent progress, retry, cancellation, and filename-conflict controls. - Changed `users/upload-user-photo` to start an upload session using JSON file metadata instead of multipart file data. - Fixed user photo asset selections not being saved, and restricted the photo selector to images in the configured volume and subfolder. - Removed the `assets/upload` and `assets/replace-file` multipart endpoints. Asset uploads now use upload sessions, and filename conflicts are resolved through `assets/resolve-upload-conflict`. diff --git a/docs/uploads.md b/docs/uploads.md index c7a6b9992f5..18ac1111fc9 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -30,6 +30,12 @@ The application needs permissions to create, list, complete, and abort multipart ## Frontend uploads +The Control Panel Assets index submits accepted files to an application-level queue. Uploads continue during Inertia navigation and folder changes, retaining their original destinations. Progress, retry, cancellation, and pending filename conflicts remain available in notifications. Conflicts open a dialog only when requested and do not block later files. Successful uploads are grouped by selection, with a link to their destination. + +Full page navigation, including refreshing, closing the tab, or signing out, interrupts transfers and loses client progress. The browser warns while uploads or unresolved results remain pending. Inertia navigation to an authentication page cancels pending uploads; a final save already in progress may still finish. Relation fields, user photos, and replacements retain their existing page-bound behavior. + +The default JavaScript uploader accepts an `enqueueUpload(file, originalFiles)` callback after validation. The Assets index supplies this callback to transfer ownership to its queue. Custom JavaScript uploaders should call it for accepted index files when present, and keep their usual upload behavior otherwise. + Load the `resources/js/uploads.ts` Vite entry through your application’s asset integration to expose `CraftUploads.FileUpload`. Supply the URL generated by `route('craft.actions.craft.uploads.store')`, the current CSRF token, and destination parameters: ```js diff --git a/resources/js/modules/uploader/asset-upload-queue.test.ts b/resources/js/modules/uploader/asset-upload-queue.test.ts new file mode 100644 index 00000000000..b6fdf913517 --- /dev/null +++ b/resources/js/modules/uploader/asset-upload-queue.test.ts @@ -0,0 +1,306 @@ +import {afterEach, beforeEach, expect, it, vi} from 'vite-plus/test'; +import type {UploadOptions, UploadState} from '@/upload-client'; +import type {UploadConflictChoice} from './upload-notification'; + +const ui = vi.hoisted(() => ({on: vi.fn(), post: vi.fn()})); +vi.mock('@inertiajs/vue3', () => ({router: {on: ui.on}})); +vi.mock('@craftcms/ui', () => ({ + t: (message: string, parameters?: {num: number}) => + parameters?.num ? `${parameters.num} files uploaded.` : message, + actionClient: {post: ui.post}, +})); + +const notices: HTMLElement[] = []; +function displayNotice(message: string, {details}: {details: HTMLElement}) { + const notice = document.createElement('div'); + notice.append(message, details); + document.body.append(notice); + notices.push(notice); + return { + close: () => notice.remove(), + on: (_event: string, callback: () => void) => queueMicrotask(callback), + $closeBtn: {hide: () => {}, on: () => {}}, + }; +} + +function clickNotice(index: number, label: string): void { + const button = [ + ...notices[index]!.querySelectorAll('craft-button'), + ].find((button) => button.textContent === label && !button.hidden); + expect(button, `Visible ${label} button`).toBeDefined(); + button!.click(); +} + +const transfers: TestUpload[] = []; +class TestUpload { + state: UploadState = 'ready'; + result = deferred(); + upload = vi.fn(() => { + this.state = 'uploading'; + this.options.onStateChange?.(this.state); + return this.result.promise; + }); + cancel = vi.fn(async () => { + this.state = 'canceled'; + this.result.reject(new Error('Canceled')); + }); + constructor( + public file: File, + public options: UploadOptions + ) { + // A queued upload can be canceled before its promise has a consumer. + void this.result.promise.catch(() => {}); + transfers.push(this); + } + complete(result: CraftCms.Cms.Asset.Data.UploadResult = {assetId: 12}): void { + this.state = 'completed'; + this.options.onStateChange?.(this.state); + this.result.resolve(result); + } + fail(message: string): void { + this.state = 'failed'; + this.result.reject(new Error(message)); + } +} +vi.mock('@/upload-client', () => ({ + FileUpload: class { + constructor(file: File, options: UploadOptions) { + return new TestUpload(file, options); + } + }, +})); + +import {AssetUploadQueue} from './asset-upload-queue'; + +let queue: AssetUploadQueue; +const destination = {folderId: 7, url: '/admin/assets/images', label: 'Images'}; +const file = () => new File(['contents'], 'photo.png'); +const conflict = { + assetId: 21, + filename: 'photo.png', + conflict: 'That filename exists.', + conflictingAssetId: 20, +}; + +beforeEach(() => { + ui.on.mockReset().mockReturnValue(vi.fn()); + transfers.length = 0; + notices.length = 0; + ui.post.mockReset().mockResolvedValue({data: {}}); + vi.stubGlobal('Craft', { + csrfTokenValue: 'token', + cp: {displayNotice, displayError: vi.fn(), runQueue: vi.fn()}, + }); + queue = new AssetUploadQueue(); +}); + +afterEach(async () => { + await queue.cancelAll(); + document.body.replaceChildren(); + vi.unstubAllGlobals(); +}); + +it('serializes selections and retains their original destinations', async () => { + const folder = {...destination}; + queue.enqueue(file(), folder, []); + queue.enqueue(file(), {...destination, folderId: 8}, []); + folder.folderId = 99; + + await vi.waitFor(() => expect(transfers[0]!.upload).toHaveBeenCalledOnce()); + expect(transfers[1]!.upload).not.toHaveBeenCalled(); + expect(transfers.map((transfer) => transfer.options.parameters)).toEqual([ + {folderId: 7}, + {folderId: 8}, + ]); + + transfers[0]!.complete(); + await vi.waitFor(() => expect(transfers[1]!.upload).toHaveBeenCalledOnce()); + transfers[1]!.complete(); + await vi.waitFor(() => expect(queue.hasPending).toBe(false)); + expect(transfers[0]!.cancel).not.toHaveBeenCalled(); +}); + +it('leaves failures actionable, continues the queue, and retries the same transfer', async () => { + queue.enqueue(file(), destination, []); + queue.enqueue(file(), destination, []); + await vi.waitFor(() => expect(transfers[0]!.upload).toHaveBeenCalled()); + transfers[0]!.fail('You no longer have permission to upload here.'); + await vi.waitFor(() => expect(transfers[1]!.upload).toHaveBeenCalled()); + expect(notices[0]!.textContent).toContain( + 'You no longer have permission to upload here.' + ); + transfers[0]!.result = deferred(); + clickNotice(0, 'Retry'); + expect(transfers[0]!.upload).toHaveBeenCalledTimes(1); + transfers[1]!.complete(); + await vi.waitFor(() => expect(transfers[0]!.upload).toHaveBeenCalledTimes(2)); + transfers[0]!.complete(); + await vi.waitFor(() => expect(queue.hasPending).toBe(false)); +}); + +it('does not interrupt navigation with a conflict dialog or block later uploads', async () => { + queue.enqueue(file(), destination, []); + queue.enqueue(file(), destination, []); + await vi.waitFor(() => expect(transfers[0]!.upload).toHaveBeenCalled()); + transfers[0]!.complete(conflict); + await vi.waitFor(() => expect(transfers[1]!.upload).toHaveBeenCalled()); + expect(document.querySelector('craft-dialog')).toBeNull(); + expect(notices[0]!.isConnected).toBe(true); + expect(notices[0]!.textContent).toContain(conflict.conflict); + + clickNotice(0, 'Action required'); + expect(document.querySelector('craft-dialog')!.textContent).toContain( + conflict.conflict + ); + choose('keepBoth'); + transfers[1]!.complete(); + await vi.waitFor(() => expect(queue.hasPending).toBe(false)); + expect(ui.post).not.toHaveBeenCalled(); +}); + +it.each([ + [conflict, {sourceAssetId: 21, assetId: 20}], + [ + {...conflict, conflictingAssetId: null}, + {sourceAssetId: 21, targetFilename: 'photo.png'}, + ], +])( + 'resolves an indexed or unindexed filename conflict only on request', + async (result, parameters) => { + await uploadConflict(result); + clickNotice(0, 'Action required'); + choose('replace'); + await vi.waitFor(() => expect(queue.hasPending).toBe(false)); + expect(ui.post).toHaveBeenCalledWith( + '/admin/actions/assets/resolve-upload-conflict', + parameters + ); + } +); + +it('retains a conflict and its error when replacement fails', async () => { + await uploadConflict(); + ui.post.mockRejectedValue(new Error('The destination was deleted.')); + clickNotice(0, 'Action required'); + choose('replace'); + await vi.waitFor(() => + expect(notices[0]!.textContent).toContain('The destination was deleted.') + ); + expect(queue.hasPending).toBe(true); +}); + +it('discards the uploaded copy when canceling a conflict', async () => { + await uploadConflict(); + clickNotice(0, 'Cancel'); + await vi.waitFor(() => expect(queue.hasPending).toBe(false)); + expect(ui.post).toHaveBeenCalledWith('/admin/actions/assets/delete-asset', { + assetId: 21, + }); + expect(queue.hasPending).toBe(false); +}); + +it('cancels a waiting file without ever starting its transfer', async () => { + queue.enqueue(file(), destination, []); + queue.enqueue(file(), destination, []); + clickNotice(1, 'Cancel'); + transfers[0]!.complete(); + await vi.waitFor(() => expect(queue.hasPending).toBe(false)); + expect(transfers[1]!.upload).not.toHaveBeenCalled(); + expect(transfers[1]!.cancel).toHaveBeenCalledOnce(); +}); + +it('clears queued work without canceling a final save or displaying its result', async () => { + queue.enqueue(file(), destination, []); + queue.enqueue(file(), destination, []); + await vi.waitFor(() => expect(transfers[0]!.upload).toHaveBeenCalled()); + transfers[0]!.state = 'completing'; + const changed = vi.fn(); + queue.addEventListener('change', changed); + + await queue.cancelAll(); + expect(queue.hasPending).toBe(false); + expect(transfers[0]!.cancel).not.toHaveBeenCalled(); + expect(transfers[1]!.cancel).toHaveBeenCalledOnce(); + transfers[0]!.complete(); + await Promise.resolve(); + expect(changed).not.toHaveBeenCalled(); + expect(document.body.textContent).not.toContain('Upload complete.'); + expect(transfers[1]!.upload).not.toHaveBeenCalled(); +}); + +it('groups completed files from one selection into one destination notice', async () => { + const selection: File[] = []; + queue.enqueue(file(), destination, selection); + queue.enqueue(file(), destination, selection); + transfers[0]!.complete(); + await vi.waitFor(() => expect(transfers[1]!.upload).toHaveBeenCalled()); + transfers[1]!.complete(); + await vi.waitFor(() => expect(queue.hasPending).toBe(false)); + + expect(document.body.textContent).toContain('2 files uploaded.'); + const links = document.querySelectorAll('a'); + expect(links).toHaveLength(1); + expect(links[0]!.getAttribute('href')).toBe(destination.url); + expect(links[0]!.textContent).toBe(destination.label); +}); + +it('warns before leaving with pending uploads and stops warning after cancellation', async () => { + queue.enqueue(file(), destination, []); + queue.enqueue(file(), destination, []); + const active = new Event('beforeunload', {cancelable: true}); + window.dispatchEvent(active); + expect(active.defaultPrevented).toBe(true); + + await queue.cancelAll(); + const idle = new Event('beforeunload', {cancelable: true}); + window.dispatchEvent(idle); + expect(idle.defaultPrevented).toBe(false); +}); + +it('keeps uploads on ordinary visits and cancels them on authentication visits', async () => { + queue.enqueue(file(), destination, []); + const navigate = ui.on.mock.calls[0]![1]; + navigate({detail: {page: {component: 'entries/Edit'}}}); + expect(queue.hasPending).toBe(true); + navigate({detail: {page: {component: 'auth/Login'}}}); + await vi.waitFor(() => expect(queue.hasPending).toBe(false)); + expect(transfers[0]!.cancel).toHaveBeenCalledOnce(); +}); + +async function uploadConflict( + result: CraftCms.Cms.Asset.Data.UploadResult = conflict +): Promise { + queue.enqueue(file(), destination, []); + await vi.waitFor(() => expect(transfers[0]!.upload).toHaveBeenCalled()); + transfers[0]!.complete(result); + await vi.waitFor(() => + expect(notices[0]!.textContent).toContain(result.conflict!) + ); +} + +function choose(choice: UploadConflictChoice): void { + const label = { + keepBoth: 'Keep both', + replace: 'Replace', + cancel: 'Cancel upload', + }[choice]; + const button = [ + ...document.querySelectorAll('craft-dialog craft-button'), + ].find((button) => button.textContent === label); + expect(button).toBeDefined(); + button!.click(); +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: Error) => void; +} { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((success, failure) => { + resolve = success; + reject = failure; + }); + return {promise, resolve, reject}; +} diff --git a/resources/js/modules/uploader/asset-upload-queue.ts b/resources/js/modules/uploader/asset-upload-queue.ts new file mode 100644 index 00000000000..6bf51e92267 --- /dev/null +++ b/resources/js/modules/uploader/asset-upload-queue.ts @@ -0,0 +1,232 @@ +import {actionClient} from '@craftcms/ui'; +import {router} from '@inertiajs/vue3'; +import {store} from '@/routes/craft/actions/craft/cp/uploads'; +import ResolveUploadConflictController from '@actions/Assets/ResolveUploadConflictController'; +import {deleteAsset} from '@actions/Assets/ActionController'; +import {UploadQueue, type UploadJob, uploadErrorMessage} from './upload-queue'; +import { + type AssetUploadDestination, + type UploadConflictChoice, + showUploadConflict, + UploadBatchNotification, +} from './upload-notification'; + +declare const Craft: any; + +type UploadResult = CraftCms.Cms.Asset.Data.UploadResult; + +interface UploadBatch { + completed: number; + notification?: UploadBatchNotification; +} + +interface AssetUploadContext { + destination: AssetUploadDestination; + batch: UploadBatch; + resolving: boolean; + closePrompt?: () => void; +} + +type QueuedAssetUpload = UploadJob; + +/** Adds Assets index conflicts, grouped results, and folder changes to the shared queue. */ +export class AssetUploadQueue extends UploadQueue< + UploadResult, + AssetUploadContext +> { + private batches = new WeakMap(); + private completedBatches = new Set(); + private changes = new Map(); + private nextRevision = 0; + private stopNavigation?: () => void; + + enqueue( + file: File, + destination: AssetUploadDestination, + selection: object + ): void { + if (!this.stopNavigation) { + const beforeUnload = (event: BeforeUnloadEvent) => { + if (this.hasPending) { + event.preventDefault(); + event.returnValue = ''; + } + }; + window.addEventListener('beforeunload', beforeUnload); + const stop = router.on('navigate', (event) => { + if (event.detail.page.component.startsWith('auth/')) { + void this.cancelAll(); + } + }); + this.stopNavigation = () => { + window.removeEventListener('beforeunload', beforeUnload); + stop(); + }; + } + + let batch = this.batches.get(selection); + if (!batch) { + batch = {completed: 0}; + this.batches.set(selection, batch); + } + this.add( + file, + { + url: store.url(), + parameters: {folderId: destination.folderId}, + csrfToken: Craft.csrfTokenValue, + }, + {destination: {...destination}, batch, resolving: false} + ); + } + + revision(folderId: number): number { + return this.changes.get(folderId) ?? 0; + } + + acknowledge(folderId: number, revision: number): void { + if (this.revision(folderId) === revision) { + this.changes.delete(folderId); + } + } + + override async cancelAll(): Promise { + this.stopNavigation?.(); + this.stopNavigation = undefined; + for (const job of this.jobs) { + job.context.closePrompt?.(); + } + this.batches = new WeakMap(); + this.changes.clear(); + for (const batch of this.completedBatches) { + batch.notification?.close(); + } + this.completedBatches.clear(); + await super.cancelAll(); + this.changes.clear(); + } + + override retry(job: QueuedAssetUpload): void { + if (job.result?.conflict) { + this.prompt(job); + } else { + super.retry(job); + } + } + + protected override succeeded( + job: QueuedAssetUpload, + result: UploadResult + ): void { + job.result = result; + this.changed(job); + if (result.conflict) { + job.notification.updateState('conflict', result.conflict); + } else { + this.uploaded(job); + } + } + + private prompt(job: QueuedAssetUpload): void { + if (!this.jobs.has(job) || job.context.resolving || !job.result?.conflict) { + return; + } + job.context.closePrompt?.(); + job.context.closePrompt = showUploadConflict( + job.result.conflict, + (choice) => { + this.resolve(job, choice).catch((error: Error) => + Craft.cp.displayError(error.message) + ); + } + ); + } + + private async resolve( + job: QueuedAssetUpload, + choice: UploadConflictChoice + ): Promise { + if (!this.jobs.has(job) || job.context.resolving) { + return; + } + if (choice === 'cancel') { + await this.cancel(job); + return; + } + job.context.resolving = true; + job.notification.updateState('completing'); + try { + if (choice === 'replace') { + await actionClient.post(ResolveUploadConflictController.url(), { + sourceAssetId: job.result!.assetId, + ...(job.result!.conflictingAssetId + ? {assetId: job.result!.conflictingAssetId} + : {targetFilename: job.result!.filename}), + }); + } + if (this.jobs.has(job)) { + this.changed(job); + this.uploaded(job); + } + } catch (error) { + if (this.jobs.has(job)) { + job.notification.updateState('conflict', uploadErrorMessage(error)); + } + } finally { + job.context.resolving = false; + } + } + + override async cancel(job: QueuedAssetUpload): Promise { + if ( + !this.jobs.has(job) || + job.context.resolving || + job.upload.state === 'completing' + ) { + return; + } + if (job.result?.conflict) { + job.context.resolving = true; + job.notification.updateState('completing'); + try { + await actionClient.post(deleteAsset.url(), { + assetId: job.result.assetId, + }); + if (this.jobs.has(job)) { + this.changed(job); + } + } catch (error) { + job.notification.updateState('conflict', uploadErrorMessage(error)); + throw error; + } finally { + job.context.resolving = false; + } + } + job.context.closePrompt?.(); + await super.cancel(job); + } + + private uploaded(job: QueuedAssetUpload): void { + super.complete(job); + const {batch, destination} = job.context; + batch.completed++; + if (!batch.notification) { + batch.notification = new UploadBatchNotification(destination, () => { + batch.notification = undefined; + this.completedBatches.delete(batch); + }); + this.completedBatches.add(batch); + } + batch.notification.update(batch.completed); + Craft.cp.runQueue(); + } + + private changed(job: QueuedAssetUpload): void { + this.changes.set(job.context.destination.folderId, ++this.nextRevision); + this.dispatchEvent( + new CustomEvent('change', {detail: job.context.destination.folderId}) + ); + } +} + +export const assetUploadQueue = new AssetUploadQueue(); diff --git a/resources/js/modules/uploader/upload-notification.ts b/resources/js/modules/uploader/upload-notification.ts index d0be18e18c3..074250b0059 100644 --- a/resources/js/modules/uploader/upload-notification.ts +++ b/resources/js/modules/uploader/upload-notification.ts @@ -1,20 +1,25 @@ import {t} from '@craftcms/ui'; +import {router} from '@inertiajs/vue3'; import type {UploadState} from '@/upload-client'; declare const Craft: any; /** Uses the existing CP notification for per-file retry and cancellation controls. */ export class UploadNotification { - private notification: any; - private shown = false; - private finished = false; + readonly close: () => void; private retryButton = document.createElement('craft-button'); private cancelButton = document.createElement('craft-button'); private progress = document.createElement('progress'); + private message = document.createElement('span'); constructor(file: File, retry: () => void, cancel: () => void) { const details = document.createElement('div'); - details.className = 'flex items-center gap-2'; + details.className = 'flex flex-col items-start gap-2'; + const controls = document.createElement('div'); + controls.className = 'flex flex-wrap gap-2'; + this.progress.className = 'w-full'; + this.message.className = 'wrap-anywhere'; + this.message.hidden = true; this.progress.max = file.size; this.progress.value = 0; this.progress.setAttribute('aria-label', t('Upload progress')); @@ -23,37 +28,143 @@ export class UploadNotification { this.retryButton.addEventListener('click', retry); this.cancelButton.textContent = t('Cancel'); this.cancelButton.addEventListener('click', cancel); - details.append(this.progress, this.retryButton, this.cancelButton); - this.notification = Craft.cp.displayNotice(file.name, { - details, - persist: true, - }); - this.notification.on('show', () => { - this.notification.$closeBtn.hide(); - this.shown = true; - if (this.finished) { - this.notification.close(); - } - }); + controls.append(this.retryButton, this.cancelButton); + details.append(this.progress, this.message, controls); + this.close = createUploadNotice(file.name, details); } updateProgress(loaded: number): void { this.progress.value = loaded; } - updateState(state: UploadState): void { - this.retryButton.hidden = state !== 'failed'; + updateState(state: UploadState | 'conflict', message = ''): void { + this.message.textContent = message; + this.message.hidden = !message; + this.progress.hidden = state === 'failed' || state === 'conflict'; + this.retryButton.textContent = + state === 'conflict' ? t('Action required') : t('Retry'); + this.retryButton.hidden = state !== 'failed' && state !== 'conflict'; this.cancelButton.toggleAttribute('disabled', state === 'completing'); if (state === 'completed' || state === 'canceled') { this.close(); } } +} + +export type UploadConflictChoice = 'keepBoth' | 'replace' | 'cancel'; + +export interface AssetUploadDestination { + folderId: number; + url: string; + label: string; +} + +/** One dismissible notification for the successful files in a selection. */ +export class UploadBatchNotification { + private count = document.createElement('span'); + readonly close: () => void; + + constructor(destination: AssetUploadDestination, onClose: () => void) { + const details = document.createElement('div'); + const link = document.createElement('a'); + details.className = 'flex flex-wrap items-center gap-2'; + link.className = 'wrap-anywhere'; + link.href = destination.url; + link.textContent = destination.label; + link.addEventListener('click', (event) => { + if ( + event.button || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) { + return; + } + event.preventDefault(); + router.visit(destination.url); + }); + details.append(this.count, link); + this.close = createUploadNotice(t('Upload complete.'), details, onClose); + } - close(): void { - this.finished = true; - if (this.shown) { - this.notification.close(); + update(count: number): void { + this.count.textContent = t( + '{num, plural, =1{# file uploaded.} other{# files uploaded.}}', + {num: count} + ); + } +} + +/** Opening this dialog is an explicit action; dismissing it leaves the conflict pending. */ +export function showUploadConflict( + message: string, + onChoice: (choice: UploadConflictChoice) => void +): () => void { + const dialog = document.createElement('craft-dialog'); + dialog.setAttribute('label', t('Filename conflict')); + const body = document.createElement('p'); + body.textContent = message; + const footer = document.createElement('div'); + footer.slot = 'footer'; + footer.className = 'flex justify-end gap-2'; + + const choices: Array<[UploadConflictChoice, string]> = [ + ['cancel', t('Cancel upload')], + ['keepBoth', t('Keep both')], + ['replace', t('Replace')], + ]; + for (const [choice, label] of choices) { + const button = document.createElement('craft-button'); + button.textContent = label; + if (choice === 'replace') { + button.setAttribute('variant', 'danger'); } + button.addEventListener('click', () => { + dialog.remove(); + onChoice(choice); + }); + footer.append(button); } + + dialog.append(body, footer); + dialog.addEventListener('craft-after-hide', () => dialog.remove()); + document.body.append(dialog); + dialog.setAttribute('open', ''); + + return () => dialog.remove(); +} + +/** CP notices appear asynchronously, so a close request may precede the show event. */ +function createUploadNotice( + message: string, + details: HTMLElement, + onClose?: () => void +): () => void { + let shown = false; + let finished = false; + const notification = Craft.cp.displayNotice(message, { + details, + persist: true, + }); + notification.on('show', () => { + shown = true; + if (onClose) { + notification.$closeBtn.on('click', onClose); + } else { + notification.$closeBtn.hide(); + } + if (finished) { + notification.close(); + } + }); + + return () => { + finished = true; + onClose?.(); + if (shown) { + notification.close(); + } + }; } diff --git a/resources/js/modules/uploader/upload-queue.ts b/resources/js/modules/uploader/upload-queue.ts new file mode 100644 index 00000000000..f060b9439dc --- /dev/null +++ b/resources/js/modules/uploader/upload-queue.ts @@ -0,0 +1,158 @@ +import {t} from '@craftcms/ui'; +import {FileUpload, type UploadOptions} from '@/upload-client'; +import {UploadNotification} from './upload-notification'; + +declare const Craft: any; + +interface UploadCallbacks { + queued?: () => void; + progress?: (loaded: number, total: number) => void; + done?: (result: Result) => void; + fail?: (error: unknown, canceled: boolean) => void; + settled?: () => void; +} + +export interface UploadJob { + upload: FileUpload; + notification: UploadNotification; + context: Context; + callbacks: UploadCallbacks; + queued: boolean; + result?: Result; +} + +/** Sequential transfers, retry notifications, and cancellation for one upload owner. */ +export class UploadQueue< + Result = unknown, + Context = undefined, +> extends EventTarget { + protected jobs = new Set>(); + private queue: Promise = Promise.resolve(); + + get hasPending(): boolean { + return this.jobs.size > 0; + } + + add( + file: File, + options: UploadOptions, + context: Context, + callbacks: UploadCallbacks = {} + ): UploadJob { + const job: UploadJob = { + context, + callbacks, + queued: false, + upload: new FileUpload(file, { + ...options, + onProgress: (loaded, total) => { + if (this.jobs.has(job)) { + job.notification.updateProgress(loaded); + callbacks.progress?.(loaded, total); + } + }, + onStateChange: (state) => { + if ( + this.jobs.has(job) && + state !== 'completed' && + state !== 'canceled' + ) { + job.notification.updateState(state); + } + }, + }), + notification: new UploadNotification( + file, + () => this.retry(job), + () => + this.cancel(job).catch((error) => + Craft.cp.displayError(uploadErrorMessage(error)) + ) + ), + }; + this.jobs.add(job); + this.retry(job); + return job; + } + + retry(job: UploadJob): void { + if ( + !this.jobs.has(job) || + job.queued || + ['uploading', 'completing', 'completed', 'canceled'].includes( + job.upload.state + ) + ) { + return; + } + job.queued = true; + job.notification.updateState('ready', t('Waiting to upload.')); + job.callbacks.queued?.(); + this.queue = this.queue + .then(() => this.run(job)) + .catch((error) => reportError(error)); + } + + async cancel(job: UploadJob): Promise { + if (!this.jobs.has(job) || job.upload.state === 'completing') { + return; + } + this.complete(job); + await job.upload.cancel(); + } + + async cancelAll(): Promise { + const jobs = [...this.jobs]; + const cancellations = jobs.map((job) => this.cancel(job)); + for (const job of jobs) { + this.complete(job); + } + const results = await Promise.allSettled(cancellations); + for (const result of results) { + if (result.status === 'rejected') { + Craft.cp.displayError(uploadErrorMessage(result.reason)); + } + } + } + + protected complete(job: UploadJob): void { + this.jobs.delete(job); + job.notification.close(); + } + + protected succeeded(job: UploadJob, result: Result): void { + this.complete(job); + job.callbacks.done?.(result); + } + + private async run(job: UploadJob): Promise { + try { + if (!this.jobs.has(job)) { + if (job.upload.state === 'canceled') { + throw new DOMException('Upload canceled.', 'AbortError'); + } + return; + } + const result = await job.upload.upload(); + if (this.jobs.has(job)) { + this.succeeded(job, result); + } + } catch (error) { + if (this.jobs.has(job)) { + job.notification.updateState('failed', uploadErrorMessage(error)); + job.callbacks.fail?.(error, false); + } else if (job.upload.state === 'canceled') { + job.callbacks.fail?.(error, true); + } else { + throw error; + } + } finally { + job.queued = false; + job.callbacks.settled?.(); + } + } +} + +export function uploadErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : t('Upload failed.'); +} diff --git a/resources/js/modules/uploader/uploader.test.ts b/resources/js/modules/uploader/uploader.test.ts index 162e17bcb6c..6df9c230f02 100644 --- a/resources/js/modules/uploader/uploader.test.ts +++ b/resources/js/modules/uploader/uploader.test.ts @@ -34,6 +34,21 @@ afterEach(() => { vi.restoreAllMocks(); }); +it('hands accepted index files to the application queue without owning their lifetime', () => { + const enqueueUpload = vi.fn(); + uploader.settings.enqueueUpload = enqueueUpload; + const upload = vi.spyOn(FileUpload.prototype, 'upload'); + const file = new File(['abc'], 'document.txt'); + const data = fileData(file); + + uploader.onFileAdd({stopPropagation: vi.fn()}, data); + uploader.destroy(); + + expect(enqueueUpload).toHaveBeenCalledWith(file, data.originalFiles); + expect(upload).not.toHaveBeenCalled(); + expect(trigger).not.toHaveBeenCalled(); +}); + it('does not cancel a successfully canceled upload again during teardown', async () => { vi.spyOn(FileUpload.prototype, 'upload').mockRejectedValue( new UploadError('Upload failed.', 500) @@ -84,6 +99,70 @@ it('allows retry after a replacement failure handler throws', async () => { ).toHaveLength(2); }); +it('reports cancellation of a waiting file and keeps legacy start/stop events balanced', async () => { + let finish!: (result: Record) => void; + const upload = vi.spyOn(FileUpload.prototype, 'upload').mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const first = fileData(new File(['a'], 'first.txt')); + const second = fileData(new File(['b'], 'second.txt')); + uploader.onFileAdd({stopPropagation: vi.fn()}, first); + uploader.onFileAdd({stopPropagation: vi.fn()}, second); + await vi.waitFor(() => expect(upload).toHaveBeenCalledOnce()); + + await second.abort(); + finish({assetId: 123}); + + await vi.waitFor(() => + expect(trigger).toHaveBeenCalledWith('fileuploadstop') + ); + expect(upload).toHaveBeenCalledOnce(); + expect(trigger).toHaveBeenCalledWith( + 'fileuploadfail', + expect.objectContaining({errorThrown: 'abort'}) + ); + expect( + trigger.mock.calls.filter(([name]) => name === 'fileuploadstart') + ).toHaveLength(1); + expect( + trigger.mock.calls.filter(([name]) => name === 'fileuploadalways') + ).toHaveLength(2); +}); + +it('detaches a final save and cancels waiting files when a local picker is destroyed', async () => { + let finish!: (result: Record) => void; + const upload = vi + .spyOn(FileUpload.prototype, 'upload') + .mockImplementation(function (this: FileUpload) { + this.state = 'completing'; + return new Promise((resolve) => { + finish = resolve; + }); + }); + const cancel = vi.spyOn(FileUpload.prototype, 'cancel'); + uploader.onFileAdd( + {stopPropagation: vi.fn()}, + fileData(new File(['a'], 'first.txt')) + ); + uploader.onFileAdd( + {stopPropagation: vi.fn()}, + fileData(new File(['b'], 'second.txt')) + ); + await vi.waitFor(() => expect(upload).toHaveBeenCalledOnce()); + + uploader.destroy(); + trigger.mockClear(); + finish({assetId: 123}); + + await upload.mock.results[0]!.value; + expect(cancel).toHaveBeenCalledOnce(); + expect(upload).toHaveBeenCalledOnce(); + expect(trigger).not.toHaveBeenCalled(); +}); + function fileData(file: File) { return { files: [file], diff --git a/resources/js/modules/uploader/uploader.ts b/resources/js/modules/uploader/uploader.ts index c537f07cf16..5156431d24a 100644 --- a/resources/js/modules/uploader/uploader.ts +++ b/resources/js/modules/uploader/uploader.ts @@ -1,6 +1,6 @@ import {BaseUploader} from './base-uploader'; -import {UploadNotification} from './upload-notification'; -import {FileUpload, UploadError} from '@/upload-client'; +import {UploadQueue} from './upload-queue'; +import {UploadError} from '@/upload-client'; import {store} from '@/routes/craft/actions/craft/cp/uploads'; // blueimp jQuery File Upload plugin seam — see base-uploader.ts. @@ -27,9 +27,7 @@ export class Uploader extends BaseUploader { _validFileCounter = 0; _onFileAdd: any = null; private destroyed = false; - private queue: Promise = Promise.resolve(); - private queued = new Set(); - private uploads = new Map(); + private uploads = new UploadQueue(); static override get defaults(): any { return { @@ -109,7 +107,11 @@ export class Uploader extends BaseUploader { if (pass) { this._validFileCounter++; - this.uploadFile(file, data); + if (this.settings.enqueueUpload) { + this.settings.enqueueUpload(file, data.originalFiles); + } else { + this.uploadFile(file, data); + } } if (++this._totalFileCounter === data.originalFiles.length) { @@ -123,104 +125,65 @@ export class Uploader extends BaseUploader { } private uploadFile(file: File, data: any): void { - const task = new FileUpload(file, { - url: this.settings.url, - parameters: { - ...this.formData, - ...(this.settings.replace ? {operation: 'replace'} : {}), - }, - csrfToken: Craft.csrfTokenValue, - onProgress: (loaded, total) => { - this.uploads.get(task)?.updateProgress(loaded); - this.$element.trigger('fileuploadprogressall', {loaded, total}); - }, - onStateChange: (state) => this.uploads.get(task)?.updateState(state), - }); - - const cancel = async () => { - await task.cancel(); - this.uploads.delete(task); - }; - - const notification = new UploadNotification( + const job = this.uploads.add( file, - () => this.enqueue(task, data), - () => { - cancel().catch((error: Error) => Craft.cp.displayError(error.message)); - } - ); - this.uploads.set(task, notification); - data.abort = cancel; - data.submit = () => this.enqueue(task, data); - this.enqueue(task, data); - } - - private enqueue(task: FileUpload, data: any): void { - if ( - this.destroyed || - this.queued.has(task) || - ['uploading', 'completing', 'completed', 'canceled'].includes(task.state) - ) { - return; - } - - this.queued.add(task); - this._inProgressCounter++; - if (this._inProgressCounter === 1) { - this.$element.trigger('fileuploadstart'); - } - - this.queue = this.queue - .then(async () => { - try { - if (this.destroyed) { - return; + { + url: this.settings.url, + parameters: { + ...this.formData, + ...(this.settings.replace ? {operation: 'replace'} : {}), + }, + csrfToken: Craft.csrfTokenValue, + }, + undefined, + { + queued: () => { + if (++this._inProgressCounter === 1) { + this.$element.trigger('fileuploadstart'); } - - data.result = await task.upload(); - this.uploads.delete(task); + }, + progress: (loaded, total) => + this.$element.trigger('fileuploadprogressall', {loaded, total}), + done: (result) => { + data.result = result; this.$element.trigger('fileuploaddone', data); - } catch (error) { - if (!this.destroyed) { - data.errorThrown = task.state === 'canceled' ? 'abort' : 'error'; - data.jqXHR = { - responseJSON: - error instanceof UploadError - ? {message: error.message, ...error.data} - : { - message: - error instanceof Error - ? error.message - : Craft.t('app', 'Upload failed.'), - }, - }; - this.$element.trigger('fileuploadfail', data); + }, + fail: (error, canceled) => { + if (this.destroyed) { + return; } - } finally { - this.queued.delete(task); + data.errorThrown = canceled ? 'abort' : 'error'; + data.jqXHR = { + responseJSON: + error instanceof UploadError + ? {message: error.message, ...error.data} + : { + message: + error instanceof Error + ? error.message + : Craft.t('app', 'Upload failed.'), + }, + }; + this.$element.trigger('fileuploadfail', data); + }, + settled: () => { + this._inProgressCounter--; if (!this.destroyed) { this.$element.trigger('fileuploadalways', data); + if (this._inProgressCounter === 0) { + this.$element.trigger('fileuploadstop'); + } } - this._inProgressCounter--; - if (!this.destroyed && this._inProgressCounter === 0) { - this.$element.trigger('fileuploadstop'); - } - } - }) - .catch((error) => reportError(error)); + }, + } + ); + data.abort = () => this.uploads.cancel(job); + data.submit = () => this.uploads.retry(job); } override destroy(): void { this.destroyed = true; - for (const [task, notification] of this.uploads) { - if (task.state !== 'completing') { - task - .cancel() - .catch((error: Error) => Craft.cp.displayError(error.message)); - } - notification.close(); - } - this.uploads.clear(); + void this.uploads.cancelAll(); if (this.uploader.fileupload('instance')) { this.uploader.fileupload('destroy'); diff --git a/resources/js/modules/uploader/useAssetUploadRefresh.test.ts b/resources/js/modules/uploader/useAssetUploadRefresh.test.ts new file mode 100644 index 00000000000..74114375d35 --- /dev/null +++ b/resources/js/modules/uploader/useAssetUploadRefresh.test.ts @@ -0,0 +1,129 @@ +import {createApp, h, nextTick, ref} from 'vue'; +import {afterEach, beforeEach, expect, it, vi} from 'vite-plus/test'; + +const navigation = vi.hoisted(() => ({ + reload: vi.fn(), + on: vi.fn(), + changes: new Map(), +})); +vi.mock('@inertiajs/vue3', () => ({router: navigation})); +vi.mock('./asset-upload-queue', () => { + const changes = navigation.changes; + return { + assetUploadQueue: Object.assign(new EventTarget(), { + changes, + revision: (folder: number) => changes.get(folder) ?? 0, + acknowledge: (folder: number, revision: number) => { + if (changes.get(folder) === revision) changes.delete(folder); + }, + }), + }; +}); + +import {assetUploadQueue} from './asset-upload-queue'; +import {useAssetUploadRefresh} from './useAssetUploadRefresh'; + +const changes = navigation.changes; +let app: ReturnType | undefined; +let container: HTMLElement; +const folder = ref(7); +const listeners = new Map void>(); + +beforeEach(() => { + folder.value = 7; + changes.clear(); + listeners.clear(); + navigation.reload.mockReset(); + navigation.on.mockReset().mockImplementation((name, listener) => { + listeners.set(name, listener); + return () => listeners.delete(name); + }); + container = document.createElement('div'); + document.body.append(container); +}); +afterEach(() => { + app?.unmount(); + app = undefined; + container.remove(); +}); + +async function mount(): Promise { + app = createApp({ + setup() { + useAssetUploadRefresh(() => folder.value); + return () => h('div'); + }, + }); + app.mount(container); + await nextTick(); +} + +function uploaded(folder: number, revision = 1): void { + changes.set(folder, revision); + assetUploadQueue.dispatchEvent(new CustomEvent('change', {detail: folder})); +} + +it('refreshes only the current destination without starting a blocking visit', async () => { + await mount(); + uploaded(8); + expect(navigation.reload).not.toHaveBeenCalled(); + uploaded(7); + expect(navigation.reload).toHaveBeenCalledWith( + expect.objectContaining({only: ['data', 'pagination'], async: true}) + ); +}); + +it('does not reload while leaving Assets and refreshes when returning to stale history', async () => { + await mount(); + listeners.get('start')!({detail: {visit: {async: false}}}); + uploaded(7); + expect(navigation.reload).not.toHaveBeenCalled(); + app!.unmount(); + await mount(); + expect(navigation.reload).toHaveBeenCalledOnce(); + const reload = navigation.reload.mock.calls[0]![0]; + reload.onSuccess(); + reload.onFinish(); + app!.unmount(); + await mount(); + expect(navigation.reload).toHaveBeenCalledOnce(); +}); + +it('catches a completion that arrives during a listing refresh', async () => { + await mount(); + uploaded(7); + const first = navigation.reload.mock.calls[0]![0]; + uploaded(7, 2); + expect(navigation.reload).toHaveBeenCalledOnce(); + first.onSuccess(); + first.onFinish(); + expect(navigation.reload).toHaveBeenCalledTimes(2); + const second = navigation.reload.mock.calls[1]![0]; + second.onSuccess(); + second.onFinish(); + app!.unmount(); + await mount(); + expect(navigation.reload).toHaveBeenCalledTimes(2); +}); + +it('does not retry failed reloads in a loop or refresh after unmount', async () => { + await mount(); + uploaded(7); + const first = navigation.reload.mock.calls[0]![0]; + first.onFinish(); + expect(navigation.reload).toHaveBeenCalledOnce(); + app!.unmount(); + app = undefined; + uploaded(7, 2); + first.onSuccess(); + first.onFinish(); + expect(navigation.reload).toHaveBeenCalledOnce(); +}); + +it('refreshes the destination after switching folders within Assets', async () => { + await mount(); + uploaded(8); + folder.value = 8; + await nextTick(); + expect(navigation.reload).toHaveBeenCalledOnce(); +}); diff --git a/resources/js/modules/uploader/useAssetUploadRefresh.ts b/resources/js/modules/uploader/useAssetUploadRefresh.ts new file mode 100644 index 00000000000..204f94eb601 --- /dev/null +++ b/resources/js/modules/uploader/useAssetUploadRefresh.ts @@ -0,0 +1,66 @@ +import {router} from '@inertiajs/vue3'; +import {useEventListener} from '@vueuse/core'; +import {onBeforeUnmount, onMounted, watch} from 'vue'; +import {assetUploadQueue} from './asset-upload-queue'; + +/** Refresh only a mounted Assets index, including a stale history visit back to it. */ +export function useAssetUploadRefresh( + folderId: () => number | undefined +): void { + let mounted = false; + let navigating = false; + let refreshing = false; + + function refresh(): void { + const folder = folderId(); + const revision = folder ? assetUploadQueue.revision(folder) : 0; + if (!mounted || navigating || refreshing || !folder || !revision) { + return; + } + + refreshing = true; + router.reload({ + only: ['data', 'pagination'], + async: true, + onSuccess: () => { + if (mounted && folderId() === folder) { + assetUploadQueue.acknowledge(folder, revision); + } + }, + onFinish: () => { + refreshing = false; + if ( + assetUploadQueue.revision(folder) !== revision || + folderId() !== folder + ) { + refresh(); + } + }, + }); + } + + useEventListener(assetUploadQueue, 'change', refresh); + watch(folderId, refresh); + + const stopStart = router.on('start', (event) => { + if (!event.detail.visit.async) { + navigating = true; + } + }); + const stopFinish = router.on('finish', (event) => { + if (!event.detail.visit.async) { + navigating = false; + refresh(); + } + }); + + onMounted(() => { + mounted = true; + refresh(); + }); + onBeforeUnmount(() => { + mounted = false; + stopStart(); + stopFinish(); + }); +} diff --git a/resources/js/pages/assets/AssetUploadButton.test.ts b/resources/js/pages/assets/AssetUploadButton.test.ts index 7d9743694b9..08ff84cb670 100644 --- a/resources/js/pages/assets/AssetUploadButton.test.ts +++ b/resources/js/pages/assets/AssetUploadButton.test.ts @@ -1,6 +1,7 @@ import {afterEach, beforeEach, expect, it, vi} from 'vite-plus/test'; import {createApp, h, nextTick} from 'vue'; import AssetUploadButton from './AssetUploadButton.vue'; +import {assetUploadQueue} from '@/modules/uploader/asset-upload-queue'; const state = vi.hoisted(() => ({ reload: vi.fn(), @@ -77,6 +78,33 @@ it('opens the file picker and configures uploads for the selected folder', async expect(destroy).toHaveBeenCalledOnce(); }); +it('captures the background destination before the picker is destroyed', () => { + const enqueue = vi + .spyOn(assetUploadQueue, 'enqueue') + .mockImplementation(() => {}); + const destination = { + folderId: 12, + url: '/admin/assets/images', + label: 'Images', + }; + const {app} = mountUploader({destination}); + const settings = vi.mocked(Craft.createUploader).mock.calls.at(-1)![2] as { + enqueueUpload: (file: File, selection: File[]) => void; + }; + const file = new File(['image'], 'image.png'); + const selection = [file]; + destination.folderId = 99; + app.unmount(); + settings.enqueueUpload(file, selection); + + expect(enqueue).toHaveBeenCalledWith( + file, + {folderId: 12, url: '/admin/assets/images', label: 'Images'}, + selection + ); + enqueue.mockRestore(); +}); + interface UploaderEvents { fileuploaddone: (...args: any[]) => void; fileuploadfail: (...args: any[]) => void; diff --git a/resources/js/pages/assets/AssetUploadButton.vue b/resources/js/pages/assets/AssetUploadButton.vue index dbd3b67f66b..3186a6d1d2c 100644 --- a/resources/js/pages/assets/AssetUploadButton.vue +++ b/resources/js/pages/assets/AssetUploadButton.vue @@ -3,6 +3,8 @@ import {router} from '@inertiajs/vue3'; import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue'; import {store} from '@/routes/craft/actions/craft/cp/uploads'; + import {assetUploadQueue} from '@/modules/uploader/asset-upload-queue'; + import type {AssetUploadDestination} from '@/modules/uploader/upload-notification'; declare const $: any; @@ -20,6 +22,8 @@ canUpload: boolean; folderId?: number; fsType?: string; + /** Assets index uploads belong to the application queue when a destination is supplied. */ + destination?: AssetUploadDestination; /** * An element that also accepts dropped files. The asset index doesn't * set one — its drop target is the whole page, handled elsewhere — but @@ -60,6 +64,7 @@ } const input = $(fileInput.value); + const destination = props.destination ? {...props.destination} : null; uploader = Craft.createUploader(props.fsType!, input, { fileInput: input, @@ -67,6 +72,12 @@ // what makes a relation field a drop target. ...(props.dropZone ? {dropZone: $(props.dropZone)} : {}), url: store.url(), + ...(destination + ? { + enqueueUpload: (file: File, selection: File[]) => + assetUploadQueue.enqueue(file, destination, selection), + } + : {}), events: { // jQuery File Upload calls this as `(event, data)` with the parsed // response on `data.result`; the CustomEvent branch covers an uploader @@ -123,6 +134,9 @@ () => props.folderId, () => props.fsType, () => props.dropZone, + () => props.destination?.folderId, + () => props.destination?.url, + () => props.destination?.label, ], createUploader ); diff --git a/resources/js/pages/assets/Index.vue b/resources/js/pages/assets/Index.vue index 2c7bc33c69a..383586b8aa5 100644 --- a/resources/js/pages/assets/Index.vue +++ b/resources/js/pages/assets/Index.vue @@ -18,6 +18,7 @@ import {useAssetMoveDrag} from '@/modules/elements/composables/useAssetMoveDrag'; import {useNewSubfolder} from '@/modules/elements/composables/useNewSubfolder'; import AssetUploadButton from './AssetUploadButton.vue'; + import {useAssetUploadRefresh} from '@/modules/uploader/useAssetUploadRefresh'; const page = usePage(); @@ -54,6 +55,21 @@ }; }); + const uploadDestination = computed(() => ({ + folderId: uploadSource.value.folderId!, + url: index.url( + {defaultSource: page.props.defaultSource ?? undefined}, + { + query: {source: page.props.source?.key}, + } + ), + label: + breadcrumbs.value.map((crumb) => crumb.label).join(' / ') || + String(page.props.source?.label ?? t('Assets')), + })); + + useAssetUploadRefresh(() => uploadSource.value.folderId); + // "New subfolder" prompt for the current folder. Its breadcrumb menu item is a // server-driven `event` action (AssetIndexViewModel::NEW_SUBFOLDER_EVENT); we // listen for that event and open the name prompt, since the modal itself can't @@ -87,7 +103,10 @@ From c3760dcd1dbd9bdf387c6d16f6f063bf9f9a2a31 Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 9 Sep 2026 15:01:59 +0200 Subject: [PATCH 7/7] Enable drag-and-drop uploads on the Assets index --- resources/js/pages/assets/AssetUploadButton.vue | 9 +-------- resources/js/pages/assets/Index.vue | 2 ++ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/resources/js/pages/assets/AssetUploadButton.vue b/resources/js/pages/assets/AssetUploadButton.vue index 3186a6d1d2c..9cfe938a394 100644 --- a/resources/js/pages/assets/AssetUploadButton.vue +++ b/resources/js/pages/assets/AssetUploadButton.vue @@ -24,12 +24,7 @@ fsType?: string; /** Assets index uploads belong to the application queue when a destination is supplied. */ destination?: AssetUploadDestination; - /** - * An element that also accepts dropped files. The asset index doesn't - * set one — its drop target is the whole page, handled elsewhere — but - * a relation field passes its own container so files can be dropped - * onto it. - */ + /** The Assets page or relation-field container that accepts dropped files. */ dropZone?: HTMLElement | null; /** * Whether finishing an upload should reload the page's index props. @@ -68,8 +63,6 @@ uploader = Craft.createUploader(props.fsType!, input, { fileInput: input, - // Files dropped on the caller's container upload as if picked, which is - // what makes a relation field a drop target. ...(props.dropZone ? {dropZone: $(props.dropZone)} : {}), url: store.url(), ...(destination diff --git a/resources/js/pages/assets/Index.vue b/resources/js/pages/assets/Index.vue index 383586b8aa5..c075d948265 100644 --- a/resources/js/pages/assets/Index.vue +++ b/resources/js/pages/assets/Index.vue @@ -21,6 +21,7 @@ import {useAssetUploadRefresh} from '@/modules/uploader/useAssetUploadRefresh'; const page = usePage(); + const dropZone = document.body; // Breadcrumb clicks navigate the same way folder rows do, so the current view // (mode, columns, sort) carries across when moving up the folder tree. @@ -106,6 +107,7 @@