diff --git a/com.woltlab.wcf/fileDelete.xml b/com.woltlab.wcf/fileDelete.xml index 3336abf1ebc..0bf2d4c5bb3 100644 --- a/com.woltlab.wcf/fileDelete.xml +++ b/com.woltlab.wcf/fileDelete.xml @@ -1681,6 +1681,7 @@ lib/data/user/option/IUserOptionOutput.class.php lib/data/user/option/UserOptionOutput.class.php lib/form/AbstractSecureForm.class.php + lib/form/AvatarEditForm.class.php lib/form/Form.class.php lib/form/MailForm.class.php lib/form/MultifactorAuthenticationAbortForm.class.php @@ -2999,6 +3000,8 @@ lib/system/template/plugin/TemplatePluginPrefilterIcon.class.php lib/system/template/plugin/TemplatePluginPrefilterLang.class.php lib/system/template/plugin/WordwrapModifierTemplatePlugin.class.php + lib/system/upload/AvatarUploadFileSaveStrategy.class.php + lib/system/upload/AvatarUploadFileValidationStrategy.class.php lib/system/user/UserCollapsibleContentHandler.class.php lib/system/user/activity/point/AbstractUserActivityPointObjectProcessor.class.php lib/system/user/activity/point/DefaultUserActivityPointObjectProcessor.class.php diff --git a/com.woltlab.wcf/objectType.xml b/com.woltlab.wcf/objectType.xml index 9472239439d..567b539407f 100644 --- a/com.woltlab.wcf/objectType.xml +++ b/com.woltlab.wcf/objectType.xml @@ -1744,6 +1744,11 @@ com.woltlab.wcf.file wcf\system\file\processor\AttachmentFileProcessor + + com.woltlab.wcf.user.avatar + com.woltlab.wcf.file + wcf\system\file\processor\UserAvatarFileProcessor + com.woltlab.wcf.page.controller diff --git a/com.woltlab.wcf/page.xml b/com.woltlab.wcf/page.xml index 59b2f271b06..856395a425c 100644 --- a/com.woltlab.wcf/page.xml +++ b/com.woltlab.wcf/page.xml @@ -100,20 +100,6 @@ Benutzerkonto-Sicherheit - - system - wcf\form\AvatarEditForm - Avatar-Verwaltung - Avatar Management - 1 - com.woltlab.wcf.AccountManagement - - Avatar Management - - - Avatar-Verwaltung - - system wcf\form\EmailActivationForm @@ -888,6 +874,7 @@ E-Mail: [E-Mail-Adresse der verantwortlichen Stelle]

Verantwortliche Stell + diff --git a/com.woltlab.wcf/templateDelete.xml b/com.woltlab.wcf/templateDelete.xml index b173661dd97..7198bdb3fa6 100644 --- a/com.woltlab.wcf/templateDelete.xml +++ b/com.woltlab.wcf/templateDelete.xml @@ -117,5 +117,6 @@ + diff --git a/com.woltlab.wcf/templates/avatarEdit.tpl b/com.woltlab.wcf/templates/avatarEdit.tpl deleted file mode 100644 index 45fc6bd46e6..00000000000 --- a/com.woltlab.wcf/templates/avatarEdit.tpl +++ /dev/null @@ -1,82 +0,0 @@ -{include file='userMenuSidebar'} - -{include file='header' __disableAds=true __sidebarLeftHasMenu=true} - -{if $__wcf->user->disableAvatar} - {lang}wcf.user.avatar.error.disabled{/lang} -{/if} - -{include file='shared_formError'} - -{if $success|isset} - {lang}wcf.global.success.edit{/lang} -{/if} - -

-
-
-
-
- - {lang}wcf.user.avatar.type.none.description{/lang} -
-
- - {if $__wcf->getSession()->getPermission('user.profile.avatar.canUploadAvatar')} -
-
- {if $avatarType == 'custom'} - {@$__wcf->getUserProfileHandler()->getAvatar()->getImageTag(96)} - {else} - - {/if} -
-
- - {lang}wcf.user.avatar.type.custom.description{/lang} - - {* placeholder for upload button: *} -
- - {if $errorField == 'custom'} - - {if $errorType == 'empty'}{lang}wcf.global.form.error.empty{/lang}{/if} - - {/if} -
-
- {/if} - - {event name='avatarFields'} -
- - {event name='sections'} - - {if !$__wcf->user->disableAvatar} -
- - {csrfToken} -
- {/if} -
- -{if $__wcf->getSession()->getPermission('user.profile.avatar.canUploadAvatar')} - -{/if} - -{include file='footer'} diff --git a/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl b/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl index b4dba63d9d8..d57b84d4845 100644 --- a/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl +++ b/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl @@ -24,6 +24,9 @@ '{unsafe:$field->getPrefixedId()|encodeJS}', {if $field->isSingleFileUpload()}true{else}false{/if}, {if $field->isBigPreview()}true{else}false{/if}, + {if $field->isSimpleReplace()}true{else}false{/if}, + {if $field->isHideDeleteButton()}true{else}false{/if}, + {if $field->getThumbnailSize() === null}undefined{else}'{$field->getThumbnailSize()|encodeJS}'{/if}, [{implode from=$actionButtons item=actionButton}{ title: '{unsafe:$actionButton['title']|encodeJS}', icon: {if $actionButton['icon'] === null}undefined{else}'{unsafe:$actionButton['icon']->toHtml()|encodeJS}'{/if}, diff --git a/com.woltlab.wcf/templates/user.tpl b/com.woltlab.wcf/templates/user.tpl index 99fbb3eac0e..328e48f0542 100644 --- a/com.woltlab.wcf/templates/user.tpl +++ b/com.woltlab.wcf/templates/user.tpl @@ -49,7 +49,7 @@ 'wcf.user.activityPoint': '{jslang}wcf.user.activityPoint{/jslang}' }); {/if} - + {if $user->canEdit() || ($__wcf->getUser()->userID == $user->userID && $user->canEditOwnProfile())} WCF.Language.addObject({ 'wcf.user.editProfile': '{jslang}wcf.user.editProfile{/jslang}' diff --git a/com.woltlab.wcf/templates/userProfileHeader.tpl b/com.woltlab.wcf/templates/userProfileHeader.tpl index 86b64a20498..3a9fe00d0b6 100644 --- a/com.woltlab.wcf/templates/userProfileHeader.tpl +++ b/com.woltlab.wcf/templates/userProfileHeader.tpl @@ -39,6 +39,12 @@ {/if} + {if $view->user->canEditAvatar()} + + {/if} + {if $view->canEditUser()} {/if} @@ -51,7 +57,7 @@
{if $view->user->userID == $__wcf->user->userID} - {unsafe:$view->user->getAvatar()->getImageTag(128)} + {else} {unsafe:$view->user->getAvatar()->getImageTag(128)} {/if} diff --git a/com.woltlab.wcf/userMenu.xml b/com.woltlab.wcf/userMenu.xml index 1d8ccefe15f..6cea9f70df5 100644 --- a/com.woltlab.wcf/userMenu.xml +++ b/com.woltlab.wcf/userMenu.xml @@ -16,11 +16,6 @@ wcf.user.menu.profile 2 - - wcf\form\AvatarEditForm - wcf.user.menu.profile - 3 - wcf\form\SignatureEditForm wcf.user.menu.profile @@ -63,5 +58,6 @@ + diff --git a/ts/WoltLabSuite/Core/Bootstrap.ts b/ts/WoltLabSuite/Core/Bootstrap.ts index b58cd698786..de5f694d891 100644 --- a/ts/WoltLabSuite/Core/Bootstrap.ts +++ b/ts/WoltLabSuite/Core/Bootstrap.ts @@ -160,6 +160,10 @@ export function setup(options: BoostrapOptions): void { whenFirstSeen(".messageTabMenu", () => { void import("./Component/Message/MessageTabMenu").then(({ setup }) => setup()); }); + whenFirstSeen("[data-edit-avatar]", () => { + void import("./Component/User/Avatar").then(({ setup }) => setup()); + }); + whenFirstSeen("woltlab-core-pagination", () => { void import("./Ui/Pagination/JumpToPage").then(({ setup }) => setup()); }); diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 857bec5eeb7..18b67b91cce 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -277,6 +277,8 @@ export function setup(): void { void upload(element, resizedFile); }) .catch((e) => { + element.dispatchEvent(new CustomEvent("cancel")); + if (e === undefined) { // User closed the dialog. return; diff --git a/ts/WoltLabSuite/Core/Component/Image/Cropper.ts b/ts/WoltLabSuite/Core/Component/Image/Cropper.ts index 0df74810338..5ff0cb49bd2 100644 --- a/ts/WoltLabSuite/Core/Component/Image/Cropper.ts +++ b/ts/WoltLabSuite/Core/Component/Image/Cropper.ts @@ -222,10 +222,10 @@ abstract class ImageCropper { const maxHeight = maxWidth / this.configuration.aspectRatio; if ( - Math.round(selection.width) < minWidth || - Math.round(selection.height) < minHeight || - Math.round(selection.width) > maxWidth || - Math.round(selection.height) > maxHeight + selection.width < minWidth || + selection.height < minHeight || + selection.width > maxWidth || + selection.height > maxHeight ) { event.preventDefault(); } @@ -260,8 +260,8 @@ abstract class ImageCropper { this.cropperSelection!.$change( 0, 0, - this.maxSize.width * selectionRatio, - this.maxSize.height * selectionRatio, + Math.min(this.cropperCanvasRect.width, this.maxSize.width * selectionRatio), + Math.min(this.cropperCanvasRect.height, this.maxSize.height * selectionRatio), this.configuration.aspectRatio, true, ); @@ -319,6 +319,29 @@ class ExactImageCropper extends ImageCropper { return super.showDialog(); } + protected getCanvas(): Promise { + // Calculate the size of the image in relation to the window size + const selectionRatio = Math.min( + this.cropperCanvasRect!.width / this.width, + this.cropperCanvasRect!.height / this.height, + ); + const width = this.cropperSelection!.width / selectionRatio; + const height = width / this.configuration.aspectRatio; + + const sizes = this.configuration.sizes + .filter((size) => { + return width >= size.width && height >= size.height; + }) + .reverse(); + + const size = sizes.length > 0 ? sizes[0] : this.minSize; + + return this.cropperSelection!.$toCanvas({ + width: size.width, + height: size.height, + }); + } + public async loadImage(): Promise { await super.loadImage(); diff --git a/ts/WoltLabSuite/Core/Component/User/Avatar.ts b/ts/WoltLabSuite/Core/Component/User/Avatar.ts new file mode 100644 index 00000000000..e9b3024631a --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/User/Avatar.ts @@ -0,0 +1,73 @@ +/** + * Handles the user avatar edit buttons. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle all + */ + +import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex"; +import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; +import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog"; +import { show as showNotification } from "WoltLabSuite/Core/Ui/Notification"; +import { registerCallback } from "WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor"; +import WoltlabCoreFile from "WoltLabSuite/Core/Component/File/woltlab-core-file"; + +interface Result { + avatar: string; +} + +async function editAvatar(button: HTMLElement): Promise { + const { ok, result } = await dialogFactory().usingFormBuilder().fromEndpoint(button.dataset.editAvatar!); + + if (ok) { + const avatarForm = document.getElementById("avatarForm"); + if (avatarForm) { + const img = avatarForm.querySelector("img.userAvatarImage")!; + if (img.src === result.avatar) { + return; + } + + // In the ACP, the form should not be reloaded after changing the avatar. + img.src = result.avatar; + showNotification(); + } else { + window.location.reload(); + } + } +} + +export function setup(): void { + wheneverFirstSeen( + "#wcf\\\\action\\\\UserAvatarAction_avatarFileIDContainer woltlab-core-file img", + (img: HTMLImageElement) => { + img.classList.add("userAvatarImage"); + img.parentElement!.classList.add("userAvatar"); + }, + ); + + const avatarForm = document.getElementById("avatarForm"); + if (avatarForm) { + registerCallback("wcf\\action\\UserAvatarAction_avatarFileID", (fileId: number | undefined) => { + if (!fileId) { + return; + } + + const file = document.querySelector( + `#wcf\\\\action\\\\UserAvatarAction_avatarFileIDContainer woltlab-core-file[file-id="${fileId}"]`, + )!; + + avatarForm.querySelector("img.userAvatarImage")!.src = file.link!; + showNotification(); + }); + } + + wheneverFirstSeen("[data-edit-avatar]", (button) => { + button.addEventListener( + "click", + promiseMutex(() => editAvatar(button)), + ); + }); +} diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts index 039c734c971..d25c8a0201a 100644 --- a/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts +++ b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts @@ -5,7 +5,7 @@ * @since 6.1 */ -import WoltlabCoreFileElement from "WoltLabSuite/Core/Component/File/woltlab-core-file"; +import WoltlabCoreFileElement, { Thumbnail } from "WoltLabSuite/Core/Component/File/woltlab-core-file"; import { getPhrase } from "WoltLabSuite/Core/Language"; import { deleteFile } from "WoltLabSuite/Core/Api/Files/DeleteFile"; import DomChangeListener from "WoltLabSuite/Core/Dom/Change/Listener"; @@ -20,6 +20,7 @@ import { innerError } from "WoltLabSuite/Core/Dom/Util"; type FileId = string; const fileProcessors = new Map(); +const callbacks = new Map) => void)[]>(); export interface ExtraButton { title: string; @@ -35,6 +36,9 @@ export class FileProcessor { readonly #fileInput: HTMLInputElement; readonly #useBigPreview: boolean; readonly #singleFileUpload: boolean; + readonly #simpleReplace: boolean; + readonly #hideDeleteButton: boolean; + readonly #thumbnailSize?: string; readonly #extraButtons: ExtraButton[]; #uploadResolve: undefined | (() => void); @@ -42,12 +46,18 @@ export class FileProcessor { fieldId: string, singleFileUpload: boolean = false, useBigPreview: boolean = false, + simpleReplace: boolean = false, + hideDeleteButton: boolean = false, + thumbnailSize?: string, extraButtons: ExtraButton[] = [], ) { this.#fieldId = fieldId; this.#useBigPreview = useBigPreview; this.#singleFileUpload = singleFileUpload; + this.#simpleReplace = simpleReplace; + this.#hideDeleteButton = hideDeleteButton; this.#extraButtons = extraButtons; + this.#thumbnailSize = thumbnailSize; this.#container = document.getElementById(fieldId + "Container")!; if (this.#container === null) { @@ -55,6 +65,19 @@ export class FileProcessor { } this.#uploadButton = this.#container.querySelector("woltlab-core-file-upload") as WoltlabCoreFileUploadElement; + + if (this.#simpleReplace) { + this.#uploadButton.addEventListener("shouldUpload", () => { + const file = + this.#uploadButton.parentElement!.querySelector("woltlab-core-file[file-id]"); + if (!file) { + return; + } + + this.#simpleFileReplace(file); + }); + } + this.#uploadButton.addEventListener("uploadStart", (event: CustomEvent) => { if (this.#uploadResolve !== undefined) { this.#uploadResolve(); @@ -65,7 +88,7 @@ export class FileProcessor { this.#fileInput = this.#uploadButton.querySelector('input[type="file"]')!; this.#container.querySelectorAll("woltlab-core-file").forEach((element) => { - this.#registerFile(element, element.parentElement); + this.#registerFile(element, element.parentElement, false); }); fileProcessors.set(fieldId, this); @@ -80,12 +103,14 @@ export class FileProcessor { buttons.classList.add("buttonList"); buttons.classList.add(this.classPrefix + "item__buttons"); - let listItem = document.createElement("li"); - listItem.append(this.getDeleteButton(element)); - buttons.append(listItem); + if (!this.#hideDeleteButton) { + const listItem = document.createElement("li"); + listItem.append(this.getDeleteButton(element)); + buttons.append(listItem); + } - if (this.#singleFileUpload) { - listItem = document.createElement("li"); + if (this.#singleFileUpload && !this.#simpleReplace) { + const listItem = document.createElement("li"); listItem.append(this.getReplaceButton(element)); buttons.append(listItem); } @@ -118,6 +143,46 @@ export class FileProcessor { container.append(buttons); } + protected getReplaceButton(element: WoltlabCoreFileElement): HTMLButtonElement { + const replaceButton = document.createElement("button"); + replaceButton.type = "button"; + replaceButton.classList.add("button", "small"); + replaceButton.textContent = getPhrase("wcf.global.button.replace"); + replaceButton.addEventListener("click", () => { + const oldContext = this.#startReplaceFile(element); + + clearPreviousErrors(this.#uploadButton); + + const changeEventListener = () => { + this.#fileInput.removeEventListener("cancel", cancelEventListener); + + // Wait until the upload starts, + // the request to the server is not synchronized with the end of the `change` event. + // Otherwise, we would swap the context too soon. + void new Promise((resolve) => { + this.#uploadResolve = resolve; + }).then(() => { + this.#uploadResolve = undefined; + this.#uploadButton.dataset.context = oldContext; + }); + }; + const cancelEventListener = () => { + this.#uploadButton.dataset.context = oldContext; + this.#registerFile(this.#replaceElement!, null, false); + this.#replaceElement = undefined; + this.#uploadResolve = undefined; + this.#fileInput.removeEventListener("change", changeEventListener); + }; + + this.#fileInput.addEventListener("cancel", cancelEventListener, { once: true }); + this.#fileInput.addEventListener("change", changeEventListener, { once: true }); + + this.#fileInput.click(); + }); + + return replaceButton; + } + #markElementUploadHasFailed(container: HTMLElement, element: WoltlabCoreFileElement, reason: unknown): void { fileInitializationFailed(container, element, reason); @@ -133,6 +198,8 @@ export class FileProcessor { const result = await deleteFile(element.fileId!); if (result.ok) { this.#unregisterFile(element); + + notifyValueChange(this.#fieldId, this.values); } else { let container: HTMLElement = element; if (!this.#useBigPreview) { @@ -150,51 +217,40 @@ export class FileProcessor { return deleteButton; } - protected getReplaceButton(element: WoltlabCoreFileElement): HTMLButtonElement { - const replaceButton = document.createElement("button"); - replaceButton.type = "button"; - replaceButton.classList.add("button", "small"); - replaceButton.textContent = getPhrase("wcf.global.button.replace"); - replaceButton.addEventListener("click", () => { - // Add to context an extra attribute that the replace button is clicked. - // After the dialog is closed or the file is selected, the context will be reset to his old value. - // This is necessary as the serverside validation will otherwise fail. - const oldContext = this.#uploadButton.dataset.context!; - const context = JSON.parse(oldContext); - context.__replace = true; - this.#uploadButton.dataset.context = JSON.stringify(context); + #simpleFileReplace(oldFile: WoltlabCoreFileElement) { + const oldContext = this.#startReplaceFile(oldFile); - this.#replaceElement = element; - this.#unregisterFile(element); + const cropCancelledEvent = () => { + this.#uploadResolve = undefined; + this.#uploadButton.dataset.context = oldContext; + this.#registerFile(this.#replaceElement!, null, false); + this.#replaceElement = undefined; + }; - clearPreviousErrors(this.#uploadButton); + this.#uploadButton.addEventListener("cancel", cropCancelledEvent, { once: true }); - const changeEventListener = () => { - this.#fileInput.removeEventListener("cancel", cancelEventListener); + void new Promise((resolve) => { + this.#uploadResolve = resolve; + }).then(() => { + this.#uploadResolve = undefined; + this.#uploadButton.dataset.context = oldContext; + this.#uploadButton.removeEventListener("cancel", cropCancelledEvent); + }); + } - // Wait until the upload starts, - // the request to the server is not synchronized with the end of the `change` event. - // Otherwise, we would swap the context too soon. - void new Promise((resolve) => { - this.#uploadResolve = resolve; - }).then(() => { - this.#uploadResolve = undefined; - this.#uploadButton.dataset.context = oldContext; - }); - }; - const cancelEventListener = () => { - this.#uploadButton.dataset.context = oldContext; - this.#registerFile(this.#replaceElement!); - this.#replaceElement = undefined; - this.#fileInput.removeEventListener("change", changeEventListener); - }; + #startReplaceFile(element: WoltlabCoreFileElement): string { + // Add to context an extra attribute that the replace button is clicked. + // After the dialog is closed or the file is selected, the context will be reset to his old value. + // This is necessary as the serverside validation will otherwise fail. + const oldContext = this.#uploadButton.dataset.context!; + const context = JSON.parse(oldContext); + context.__replace = true; + this.#uploadButton.dataset.context = JSON.stringify(context); - this.#fileInput.addEventListener("cancel", cancelEventListener, { once: true }); - this.#fileInput.addEventListener("change", changeEventListener, { once: true }); - this.#fileInput.click(); - }); + this.#replaceElement = element; + this.#unregisterFile(element); - return replaceButton; + return oldContext; } #unregisterFile(element: WoltlabCoreFileElement): void { @@ -205,7 +261,11 @@ export class FileProcessor { } } - #registerFile(element: WoltlabCoreFileElement, container: HTMLElement | null = null): void { + #registerFile( + element: WoltlabCoreFileElement, + container: HTMLElement | null = null, + notifyCallback: boolean = true, + ): void { if (container === null) { if (this.#useBigPreview) { container = this.#container.querySelector(".fileUpload__preview"); @@ -234,11 +294,11 @@ export class FileProcessor { void deleteFile(this.#replaceElement.fileId!); this.#replaceElement = undefined; } - this.#fileInitializationCompleted(element, container!); + this.#fileInitializationCompleted(element, container!, notifyCallback); }) .catch((reason) => { if (this.#replaceElement !== undefined) { - this.#registerFile(this.#replaceElement); + this.#registerFile(this.#replaceElement, null, false); this.#replaceElement = undefined; if (this.#useBigPreview) { @@ -257,19 +317,23 @@ export class FileProcessor { }); } - #fileInitializationCompleted(element: WoltlabCoreFileElement, container: HTMLElement): void { + #fileInitializationCompleted( + element: WoltlabCoreFileElement, + container: HTMLElement, + notifyCallback: boolean = true, + ): void { if (this.#useBigPreview) { - element.dataset.previewUrl = element.link!; - element.unbounded = true; + setThumbnail( + element, + element.thumbnails.find((thumbnail) => thumbnail.identifier === this.#thumbnailSize), + true, + ); } else { if (element.isImage()) { - const thumbnail = element.thumbnails.find((thumbnail) => thumbnail.identifier === "tiny"); - if (thumbnail !== undefined) { - element.thumbnail = thumbnail; - } else { - element.dataset.previewUrl = element.link!; - element.unbounded = false; - } + const thumbnailSize = this.#thumbnailSize ?? "tiny"; + + const thumbnail = element.thumbnails.find((thumbnail) => thumbnail.identifier === thumbnailSize); + setThumbnail(element, thumbnail); if (element.link !== undefined && element.filename !== undefined) { const filenameLink = document.createElement("a"); @@ -304,6 +368,10 @@ export class FileProcessor { container.append(input); this.addButtons(container, element); + + if (notifyCallback) { + notifyValueChange(this.#fieldId, this.values); + } } get values(): undefined | number | Set { @@ -324,6 +392,16 @@ export class FileProcessor { } } +function setThumbnail(element: WoltlabCoreFileElement, thumbnail?: Thumbnail, unbounded: boolean = false) { + if (unbounded) { + element.dataset.previewUrl = thumbnail !== undefined ? thumbnail.link : element.link; + } else if (thumbnail !== undefined) { + element.thumbnail = thumbnail; + } + + element.unbounded = unbounded; +} + export function getValues(fieldId: string): undefined | number | Set { const field = fileProcessors.get(fieldId); if (field === undefined) { @@ -332,3 +410,30 @@ export function getValues(fieldId: string): undefined | number | Set { return field.values; } + +/** + * Registers a callback that will be called when the value of the field changes. + * + * @since 6.2 + */ +export function registerCallback(fieldId: string, callback: (values: undefined | number | Set) => void): void { + if (!callbacks.has(fieldId)) { + callbacks.set(fieldId, []); + } + + callbacks.get(fieldId)!.push(callback); +} + +/** + * @since 6.2 + */ +export function unregisterCallback( + fieldId: string, + callback: (values: undefined | number | Set) => void, +): void { + callbacks.set(fieldId, callbacks.get(fieldId)?.filter((registeredCallback) => registeredCallback !== callback) ?? []); +} + +function notifyValueChange(fieldId: string, values: undefined | number | Set): void { + callbacks.get(fieldId)?.forEach((callback) => callback(values)); +} diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2.php new file mode 100644 index 00000000000..c275d354e18 --- /dev/null +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2.php @@ -0,0 +1,29 @@ + + */ + +use wcf\system\database\table\column\IntDatabaseTableColumn; +use wcf\system\database\table\index\DatabaseTableForeignKey; +use wcf\system\database\table\PartialDatabaseTable; + +return [ + PartialDatabaseTable::create('wcf1_user') + ->columns([ + IntDatabaseTableColumn::create('avatarFileID') + ->length(10) + ->defaultValue(null), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['avatarFileID']) + ->referencedTable('wcf1_file') + ->referencedColumns(['fileID']) + ->onDelete('SET NULL'), + ]), +]; diff --git a/wcfsetup/install/files/acp/templates/userAdd.tpl b/wcfsetup/install/files/acp/templates/userAdd.tpl index 62ab6118f6d..1fdee9563b7 100644 --- a/wcfsetup/install/files/acp/templates/userAdd.tpl +++ b/wcfsetup/install/files/acp/templates/userAdd.tpl @@ -600,32 +600,23 @@

{lang}wcf.user.avatar{/lang}

-
+
- -
-
- -
-
{if $avatarType == 'custom' && $userAvatar !== null} - {@$userAvatar->getImageTag(96)} + {else} {/if} -
-
- - - {* placeholder for upload button: *} -
+
+
- {if $errorType[customAvatar]|isset} - - {if $errorType[customAvatar] == 'empty'}{lang}wcf.global.form.error.empty{/lang}{/if} - - {/if} +
+
+
+
@@ -695,23 +686,6 @@ {/if} - - - - {event name='avatarFieldsets'}
diff --git a/wcfsetup/install/files/js/WCF.User.js b/wcfsetup/install/files/js/WCF.User.js index ef10ee5ea6a..7cdd55e47df 100644 --- a/wcfsetup/install/files/js/WCF.User.js +++ b/wcfsetup/install/files/js/WCF.User.js @@ -1406,150 +1406,6 @@ else { }); } -/** - * Namespace for avatar functions. - */ -WCF.User.Avatar = {}; - -if (COMPILER_TARGET_DEFAULT) { - /** - * Avatar upload function - * - * @see WCF.Upload - */ - WCF.User.Avatar.Upload = WCF.Upload.extend({ - /** - * user id of avatar owner - * @var integer - */ - _userID: 0, - - /** - * Initializes a new WCF.User.Avatar.Upload object. - * - * @param integer userID - */ - init: function (userID) { - this._super($('#avatarUpload > dd > div'), undefined, 'wcf\\data\\user\\avatar\\UserAvatarAction'); - this._userID = userID || 0; - - $('#avatarForm input[type=radio]').change(function () { - if ($(this).val() == 'custom') { - $('#avatarUpload > dd > div').show(); - } - else { - $('#avatarUpload > dd > div').hide(); - } - }); - if (!$('#avatarForm input[type=radio][value=custom]:checked').length) { - $('#avatarUpload > dd > div').hide(); - } - }, - - /** - * @see WCF.Upload._initFile() - */ - _initFile: function (file) { - return $('#avatarUpload > dt > img'); - }, - - /** - * @see WCF.Upload._success() - */ - _success: function (uploadID, data) { - if (data.returnValues.url) { - this._updateImage(data.returnValues.url); - - // hide error - $('#avatarUpload > dd > .innerError').remove(); - - // show success message - var $notification = new WCF.System.Notification(WCF.Language.get('wcf.user.avatar.upload.success')); - $notification.show(); - } - else if (data.returnValues.errorType) { - // show error - this._getInnerErrorElement().text(WCF.Language.get('wcf.user.avatar.upload.error.' + data.returnValues.errorType)); - } - }, - - /** - * Updates the displayed avatar image. - * - * @param string url - */ - _updateImage: function (url) { - $('#avatarUpload > dt > img').remove(); - var $image = $('').css({ - 'height': 'auto', - 'max-height': '96px', - 'max-width': '96px', - 'width': 'auto' - }); - - $('#avatarUpload > dt').prepend($image); - - WCF.DOMNodeInsertedHandler.execute(); - }, - - /** - * Returns the inner error element. - * - * @return jQuery - */ - _getInnerErrorElement: function () { - var $span = $('#avatarUpload > dd > .innerError'); - if (!$span.length) { - $span = $(''); - $('#avatarUpload > dd').append($span); - } - - return $span; - }, - - /** - * @see WCF.Upload._getParameters() - */ - _getParameters: function () { - return { - userID: this._userID - }; - } - }); -} -else { - WCF.User.Avatar.Upload = WCF.Upload.extend({ - _userID: 0, - init: function() {}, - _initFile: function() {}, - _success: function() {}, - _updateImage: function() {}, - _getInnerErrorElement: function() {}, - _getParameters: function() {}, - _name: "", - _buttonSelector: {}, - _fileListSelector: {}, - _fileUpload: {}, - _className: "", - _iframe: {}, - _internalFileID: 0, - _options: {}, - _uploadMatrix: {}, - _supportsAJAXUpload: true, - _overlay: {}, - _createButton: function() {}, - _insertButton: function() {}, - _removeButton: function() {}, - _upload: function() {}, - _createUploadMatrix: function() {}, - _error: function() {}, - _progress: function() {}, - _showOverlay: function() {}, - _evaluateResponse: function() {}, - _getFilename: function() {} - }); -} - /** * Generic implementation for grouped user lists. * diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js index 711af341576..bdcd0c0fef6 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js @@ -127,34 +127,37 @@ define(["require", "exports", "tslib", "./Core", "./Date/Picker", "./Devtools", (0, LazyLoader_1.whenFirstSeen)(".messageTabMenu", () => { void new Promise((resolve_3, reject_3) => { require(["./Component/Message/MessageTabMenu"], resolve_3, reject_3); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); + (0, LazyLoader_1.whenFirstSeen)("[data-edit-avatar]", () => { + void new Promise((resolve_4, reject_4) => { require(["./Component/User/Avatar"], resolve_4, reject_4); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-pagination", () => { - void new Promise((resolve_4, reject_4) => { require(["./Ui/Pagination/JumpToPage"], resolve_4, reject_4); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_5, reject_5) => { require(["./Ui/Pagination/JumpToPage"], resolve_5, reject_5); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-google-maps", () => { - void new Promise((resolve_5, reject_5) => { require(["./Component/GoogleMaps/woltlab-core-google-maps"], resolve_5, reject_5); }).then(tslib_1.__importStar); + void new Promise((resolve_6, reject_6) => { require(["./Component/GoogleMaps/woltlab-core-google-maps"], resolve_6, reject_6); }).then(tslib_1.__importStar); }); (0, LazyLoader_1.whenFirstSeen)("[data-google-maps-geocoding]", () => { - void new Promise((resolve_6, reject_6) => { require(["./Component/GoogleMaps/Geocoding"], resolve_6, reject_6); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_7, reject_7) => { require(["./Component/GoogleMaps/Geocoding"], resolve_7, reject_7); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-file", () => { - void new Promise((resolve_7, reject_7) => { require(["./Component/File/woltlab-core-file"], resolve_7, reject_7); }).then(tslib_1.__importStar); + void new Promise((resolve_8, reject_8) => { require(["./Component/File/woltlab-core-file"], resolve_8, reject_8); }).then(tslib_1.__importStar); }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-file-upload", () => { - void new Promise((resolve_8, reject_8) => { require(["./Component/File/woltlab-core-file"], resolve_8, reject_8); }).then(tslib_1.__importStar); - void new Promise((resolve_9, reject_9) => { require(["./Component/File/Upload"], resolve_9, reject_9); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_9, reject_9) => { require(["./Component/File/woltlab-core-file"], resolve_9, reject_9); }).then(tslib_1.__importStar); + void new Promise((resolve_10, reject_10) => { require(["./Component/File/Upload"], resolve_10, reject_10); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)(".activityPointsDisplay", () => { - void new Promise((resolve_10, reject_10) => { require(["./Component/User/ActivityPointList"], resolve_10, reject_10); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_11, reject_11) => { require(["./Component/User/ActivityPointList"], resolve_11, reject_11); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)("[data-fancybox]", () => { - void new Promise((resolve_11, reject_11) => { require(["./Component/Image/Viewer"], resolve_11, reject_11); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_12, reject_12) => { require(["./Component/Image/Viewer"], resolve_12, reject_12); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)(".jsImageViewer", () => { console.warn("The class `jsImageViewer` is deprecated. Use the attribute `data-fancybox` instead."); - void new Promise((resolve_12, reject_12) => { require(["./Component/Image/Viewer"], resolve_12, reject_12); }).then(tslib_1.__importStar).then(({ setupLegacy }) => setupLegacy()); + void new Promise((resolve_13, reject_13) => { require(["./Component/Image/Viewer"], resolve_13, reject_13); }).then(tslib_1.__importStar).then(({ setupLegacy }) => setupLegacy()); }); (0, LazyLoader_1.whenFirstSeen)(".jsEnablesOptions", () => { - void new Promise((resolve_13, reject_13) => { require(["./Component/Option/Enable"], resolve_13, reject_13); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_14, reject_14) => { require(["./Component/Option/Enable"], resolve_14, reject_14); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); // Move the reCAPTCHA widget overlay to the `pageOverlayContainer` // when widget form elements are placed in a dialog. diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js index 39545f10bbd..9b00148b846 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -190,6 +190,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol void upload(element, resizedFile); }) .catch((e) => { + element.dispatchEvent(new CustomEvent("cancel")); if (e === undefined) { // User closed the dialog. return; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js index 046b13b6cdf..4fd299bde87 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js @@ -165,10 +165,10 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Image/Resizer", "WoltL const maxWidth = this.cropperCanvasRect.width; const minHeight = minWidth / this.configuration.aspectRatio; const maxHeight = maxWidth / this.configuration.aspectRatio; - if (Math.round(selection.width) < minWidth || - Math.round(selection.height) < minHeight || - Math.round(selection.width) > maxWidth || - Math.round(selection.height) > maxHeight) { + if (selection.width < minWidth || + selection.height < minHeight || + selection.width > maxWidth || + selection.height > maxHeight) { event.preventDefault(); } }); @@ -188,7 +188,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Image/Resizer", "WoltL this.cropperImage.$center("contain"); this.cropperCanvasRect = this.cropperImage.getBoundingClientRect(); const selectionRatio = Math.min(this.cropperCanvasRect.width / this.maxSize.width, this.cropperCanvasRect.height / this.maxSize.height); - this.cropperSelection.$change(0, 0, this.maxSize.width * selectionRatio, this.maxSize.height * selectionRatio, this.configuration.aspectRatio, true); + this.cropperSelection.$change(0, 0, Math.min(this.cropperCanvasRect.width, this.maxSize.width * selectionRatio), Math.min(this.cropperCanvasRect.height, this.maxSize.height * selectionRatio), this.configuration.aspectRatio, true); this.cropperSelection.$center(); this.cropperSelection.scrollIntoView({ block: "center", inline: "center" }); } @@ -230,6 +230,22 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Image/Resizer", "WoltL } return super.showDialog(); } + getCanvas() { + // Calculate the size of the image in relation to the window size + const selectionRatio = Math.min(this.cropperCanvasRect.width / this.width, this.cropperCanvasRect.height / this.height); + const width = this.cropperSelection.width / selectionRatio; + const height = width / this.configuration.aspectRatio; + const sizes = this.configuration.sizes + .filter((size) => { + return width >= size.width && height >= size.height; + }) + .reverse(); + const size = sizes.length > 0 ? sizes[0] : this.minSize; + return this.cropperSelection.$toCanvas({ + width: size.width, + height: size.height, + }); + } async loadImage() { await super.loadImage(); const sizes = this.configuration.sizes diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Avatar.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Avatar.js new file mode 100644 index 00000000000..df84aef7abc --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/User/Avatar.js @@ -0,0 +1,52 @@ +/** + * Handles the user avatar edit buttons. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle all + */ +define(["require", "exports", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Ui/Notification", "WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor"], function (require, exports, PromiseMutex_1, Selector_1, Dialog_1, Notification_1, FileProcessor_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + async function editAvatar(button) { + const { ok, result } = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(button.dataset.editAvatar); + if (ok) { + const avatarForm = document.getElementById("avatarForm"); + if (avatarForm) { + const img = avatarForm.querySelector("img.userAvatarImage"); + if (img.src === result.avatar) { + return; + } + // In the ACP, the form should not be reloaded after changing the avatar. + img.src = result.avatar; + (0, Notification_1.show)(); + } + else { + window.location.reload(); + } + } + } + function setup() { + (0, Selector_1.wheneverFirstSeen)("#wcf\\\\action\\\\UserAvatarAction_avatarFileIDContainer woltlab-core-file img", (img) => { + img.classList.add("userAvatarImage"); + img.parentElement.classList.add("userAvatar"); + }); + const avatarForm = document.getElementById("avatarForm"); + if (avatarForm) { + (0, FileProcessor_1.registerCallback)("wcf\\action\\UserAvatarAction_avatarFileID", (fileId) => { + if (!fileId) { + return; + } + const file = document.querySelector(`#wcf\\\\action\\\\UserAvatarAction_avatarFileIDContainer woltlab-core-file[file-id="${fileId}"]`); + avatarForm.querySelector("img.userAvatarImage").src = file.link; + (0, Notification_1.show)(); + }); + } + (0, Selector_1.wheneverFirstSeen)("[data-edit-avatar]", (button) => { + button.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => editAvatar(button))); + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js index b27cebfe29f..60a25403aea 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js @@ -9,8 +9,11 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui Object.defineProperty(exports, "__esModule", { value: true }); exports.FileProcessor = void 0; exports.getValues = getValues; + exports.registerCallback = registerCallback; + exports.unregisterCallback = unregisterCallback; Listener_1 = tslib_1.__importDefault(Listener_1); const fileProcessors = new Map(); + const callbacks = new Map(); class FileProcessor { #container; #uploadButton; @@ -19,18 +22,33 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui #fileInput; #useBigPreview; #singleFileUpload; + #simpleReplace; + #hideDeleteButton; + #thumbnailSize; #extraButtons; #uploadResolve; - constructor(fieldId, singleFileUpload = false, useBigPreview = false, extraButtons = []) { + constructor(fieldId, singleFileUpload = false, useBigPreview = false, simpleReplace = false, hideDeleteButton = false, thumbnailSize, extraButtons = []) { this.#fieldId = fieldId; this.#useBigPreview = useBigPreview; this.#singleFileUpload = singleFileUpload; + this.#simpleReplace = simpleReplace; + this.#hideDeleteButton = hideDeleteButton; this.#extraButtons = extraButtons; + this.#thumbnailSize = thumbnailSize; this.#container = document.getElementById(fieldId + "Container"); if (this.#container === null) { throw new Error("Unknown field with id '" + fieldId + "'"); } this.#uploadButton = this.#container.querySelector("woltlab-core-file-upload"); + if (this.#simpleReplace) { + this.#uploadButton.addEventListener("shouldUpload", () => { + const file = this.#uploadButton.parentElement.querySelector("woltlab-core-file[file-id]"); + if (!file) { + return; + } + this.#simpleFileReplace(file); + }); + } this.#uploadButton.addEventListener("uploadStart", (event) => { if (this.#uploadResolve !== undefined) { this.#uploadResolve(); @@ -39,7 +57,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui }); this.#fileInput = this.#uploadButton.querySelector('input[type="file"]'); this.#container.querySelectorAll("woltlab-core-file").forEach((element) => { - this.#registerFile(element, element.parentElement); + this.#registerFile(element, element.parentElement, false); }); fileProcessors.set(fieldId, this); } @@ -50,11 +68,13 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui const buttons = document.createElement("ul"); buttons.classList.add("buttonList"); buttons.classList.add(this.classPrefix + "item__buttons"); - let listItem = document.createElement("li"); - listItem.append(this.getDeleteButton(element)); - buttons.append(listItem); - if (this.#singleFileUpload) { - listItem = document.createElement("li"); + if (!this.#hideDeleteButton) { + const listItem = document.createElement("li"); + listItem.append(this.getDeleteButton(element)); + buttons.append(listItem); + } + if (this.#singleFileUpload && !this.#simpleReplace) { + const listItem = document.createElement("li"); listItem.append(this.getReplaceButton(element)); buttons.append(listItem); } @@ -82,6 +102,39 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui }); container.append(buttons); } + getReplaceButton(element) { + const replaceButton = document.createElement("button"); + replaceButton.type = "button"; + replaceButton.classList.add("button", "small"); + replaceButton.textContent = (0, Language_1.getPhrase)("wcf.global.button.replace"); + replaceButton.addEventListener("click", () => { + const oldContext = this.#startReplaceFile(element); + (0, Upload_1.clearPreviousErrors)(this.#uploadButton); + const changeEventListener = () => { + this.#fileInput.removeEventListener("cancel", cancelEventListener); + // Wait until the upload starts, + // the request to the server is not synchronized with the end of the `change` event. + // Otherwise, we would swap the context too soon. + void new Promise((resolve) => { + this.#uploadResolve = resolve; + }).then(() => { + this.#uploadResolve = undefined; + this.#uploadButton.dataset.context = oldContext; + }); + }; + const cancelEventListener = () => { + this.#uploadButton.dataset.context = oldContext; + this.#registerFile(this.#replaceElement, null, false); + this.#replaceElement = undefined; + this.#uploadResolve = undefined; + this.#fileInput.removeEventListener("change", changeEventListener); + }; + this.#fileInput.addEventListener("cancel", cancelEventListener, { once: true }); + this.#fileInput.addEventListener("change", changeEventListener, { once: true }); + this.#fileInput.click(); + }); + return replaceButton; + } #markElementUploadHasFailed(container, element, reason) { (0, Helper_1.fileInitializationFailed)(container, element, reason); container.classList.add("innerError"); @@ -95,6 +148,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui const result = await (0, DeleteFile_1.deleteFile)(element.fileId); if (result.ok) { this.#unregisterFile(element); + notifyValueChange(this.#fieldId, this.values); } else { let container = element; @@ -111,45 +165,34 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui }); return deleteButton; } - getReplaceButton(element) { - const replaceButton = document.createElement("button"); - replaceButton.type = "button"; - replaceButton.classList.add("button", "small"); - replaceButton.textContent = (0, Language_1.getPhrase)("wcf.global.button.replace"); - replaceButton.addEventListener("click", () => { - // Add to context an extra attribute that the replace button is clicked. - // After the dialog is closed or the file is selected, the context will be reset to his old value. - // This is necessary as the serverside validation will otherwise fail. - const oldContext = this.#uploadButton.dataset.context; - const context = JSON.parse(oldContext); - context.__replace = true; - this.#uploadButton.dataset.context = JSON.stringify(context); - this.#replaceElement = element; - this.#unregisterFile(element); - (0, Upload_1.clearPreviousErrors)(this.#uploadButton); - const changeEventListener = () => { - this.#fileInput.removeEventListener("cancel", cancelEventListener); - // Wait until the upload starts, - // the request to the server is not synchronized with the end of the `change` event. - // Otherwise, we would swap the context too soon. - void new Promise((resolve) => { - this.#uploadResolve = resolve; - }).then(() => { - this.#uploadResolve = undefined; - this.#uploadButton.dataset.context = oldContext; - }); - }; - const cancelEventListener = () => { - this.#uploadButton.dataset.context = oldContext; - this.#registerFile(this.#replaceElement); - this.#replaceElement = undefined; - this.#fileInput.removeEventListener("change", changeEventListener); - }; - this.#fileInput.addEventListener("cancel", cancelEventListener, { once: true }); - this.#fileInput.addEventListener("change", changeEventListener, { once: true }); - this.#fileInput.click(); + #simpleFileReplace(oldFile) { + const oldContext = this.#startReplaceFile(oldFile); + const cropCancelledEvent = () => { + this.#uploadResolve = undefined; + this.#uploadButton.dataset.context = oldContext; + this.#registerFile(this.#replaceElement, null, false); + this.#replaceElement = undefined; + }; + this.#uploadButton.addEventListener("cancel", cropCancelledEvent, { once: true }); + void new Promise((resolve) => { + this.#uploadResolve = resolve; + }).then(() => { + this.#uploadResolve = undefined; + this.#uploadButton.dataset.context = oldContext; + this.#uploadButton.removeEventListener("cancel", cropCancelledEvent); }); - return replaceButton; + } + #startReplaceFile(element) { + // Add to context an extra attribute that the replace button is clicked. + // After the dialog is closed or the file is selected, the context will be reset to his old value. + // This is necessary as the serverside validation will otherwise fail. + const oldContext = this.#uploadButton.dataset.context; + const context = JSON.parse(oldContext); + context.__replace = true; + this.#uploadButton.dataset.context = JSON.stringify(context); + this.#replaceElement = element; + this.#unregisterFile(element); + return oldContext; } #unregisterFile(element) { if (this.#useBigPreview) { @@ -159,7 +202,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui element.parentElement.parentElement.remove(); } } - #registerFile(element, container = null) { + #registerFile(element, container = null, notifyCallback = true) { if (container === null) { if (this.#useBigPreview) { container = this.#container.querySelector(".fileUpload__preview"); @@ -186,11 +229,11 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui void (0, DeleteFile_1.deleteFile)(this.#replaceElement.fileId); this.#replaceElement = undefined; } - this.#fileInitializationCompleted(element, container); + this.#fileInitializationCompleted(element, container, notifyCallback); }) .catch((reason) => { if (this.#replaceElement !== undefined) { - this.#registerFile(this.#replaceElement); + this.#registerFile(this.#replaceElement, null, false); this.#replaceElement = undefined; if (this.#useBigPreview) { // `this.#replaceElement` need a new container, otherwise the element will be marked as erroneous, too. @@ -206,21 +249,15 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui (0, Helper_1.removeUploadProgress)(container); }); } - #fileInitializationCompleted(element, container) { + #fileInitializationCompleted(element, container, notifyCallback = true) { if (this.#useBigPreview) { - element.dataset.previewUrl = element.link; - element.unbounded = true; + setThumbnail(element, element.thumbnails.find((thumbnail) => thumbnail.identifier === this.#thumbnailSize), true); } else { if (element.isImage()) { - const thumbnail = element.thumbnails.find((thumbnail) => thumbnail.identifier === "tiny"); - if (thumbnail !== undefined) { - element.thumbnail = thumbnail; - } - else { - element.dataset.previewUrl = element.link; - element.unbounded = false; - } + const thumbnailSize = this.#thumbnailSize ?? "tiny"; + const thumbnail = element.thumbnails.find((thumbnail) => thumbnail.identifier === thumbnailSize); + setThumbnail(element, thumbnail); if (element.link !== undefined && element.filename !== undefined) { const filenameLink = document.createElement("a"); filenameLink.href = element.link; @@ -249,6 +286,9 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui input.value = element.fileId.toString(); container.append(input); this.addButtons(container, element); + if (notifyCallback) { + notifyValueChange(this.#fieldId, this.values); + } } get values() { if (this.#singleFileUpload) { @@ -262,6 +302,15 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui } } exports.FileProcessor = FileProcessor; + function setThumbnail(element, thumbnail, unbounded = false) { + if (unbounded) { + element.dataset.previewUrl = thumbnail !== undefined ? thumbnail.link : element.link; + } + else if (thumbnail !== undefined) { + element.thumbnail = thumbnail; + } + element.unbounded = unbounded; + } function getValues(fieldId) { const field = fileProcessors.get(fieldId); if (field === undefined) { @@ -269,4 +318,24 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui } return field.values; } + /** + * Registers a callback that will be called when the value of the field changes. + * + * @since 6.2 + */ + function registerCallback(fieldId, callback) { + if (!callbacks.has(fieldId)) { + callbacks.set(fieldId, []); + } + callbacks.get(fieldId).push(callback); + } + /** + * @since 6.2 + */ + function unregisterCallback(fieldId, callback) { + callbacks.set(fieldId, callbacks.get(fieldId)?.filter((registeredCallback) => registeredCallback !== callback) ?? []); + } + function notifyValueChange(fieldId, values) { + callbacks.get(fieldId)?.forEach((callback) => callback(values)); + } }); diff --git a/wcfsetup/install/files/lib/acp/action/UserExportGdprAction.class.php b/wcfsetup/install/files/lib/acp/action/UserExportGdprAction.class.php index 1a4e6e38fe8..e10f4e1bcc6 100644 --- a/wcfsetup/install/files/lib/acp/action/UserExportGdprAction.class.php +++ b/wcfsetup/install/files/lib/acp/action/UserExportGdprAction.class.php @@ -339,7 +339,7 @@ protected function exportUser() } } - if ($this->user->avatarID) { + if ($this->user->avatarFileID) { $data['avatarURL'] = $this->user->getAvatar()->getURL(); } diff --git a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php index c2aca3f0f08..f7ced91d1b9 100755 --- a/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/UserEditForm.class.php @@ -2,9 +2,8 @@ namespace wcf\acp\form; +use wcf\data\file\File; use wcf\data\style\Style; -use wcf\data\user\avatar\UserAvatar; -use wcf\data\user\avatar\UserAvatarAction; use wcf\data\user\cover\photo\UserCoverPhoto; use wcf\data\user\group\UserGroup; use wcf\data\user\User; @@ -12,6 +11,7 @@ use wcf\data\user\UserEditor; use wcf\data\user\UserProfileAction; use wcf\form\AbstractForm; +use wcf\system\cache\runtime\FileRuntimeCache; use wcf\system\cache\runtime\UserProfileRuntimeCache; use wcf\system\exception\IllegalLinkException; use wcf\system\exception\PermissionDeniedException; @@ -76,9 +76,8 @@ class UserEditForm extends UserAddForm /** * user avatar object - * @var UserAvatar */ - public $userAvatar; + public ?File $userAvatar = null; /** * avatar type @@ -222,9 +221,6 @@ public function readFormParameters() } } - if (isset($_POST['avatarType'])) { - $this->avatarType = $_POST['avatarType']; - } if (isset($_POST['styleID'])) { $this->styleID = \intval($_POST['styleID']); } @@ -294,8 +290,8 @@ public function readData() parent::readData(); // get the avatar object - if ($this->avatarType == 'custom' && $this->user->avatarID) { - $this->userAvatar = new UserAvatar($this->user->avatarID); + if ($this->avatarType == 'custom' && $this->user->avatarFileID) { + $this->userAvatar = FileRuntimeCache::getInstance()->getObject($this->user->avatarFileID); } // get the user cover photo object @@ -348,7 +344,7 @@ protected function readDefaultValues() $this->disableCoverPhotoReason = $this->user->disableCoverPhotoReason; $this->disableCoverPhotoExpires = $this->user->disableCoverPhotoExpires; - if ($this->user->avatarID) { + if ($this->user->avatarFileID) { $this->avatarType = 'custom'; } @@ -397,24 +393,6 @@ public function save() $this->htmlInputProcessor->setObjectID($this->userID); MessageEmbeddedObjectManager::getInstance()->registerObjects($this->htmlInputProcessor); - // handle avatar - if ($this->avatarType != 'custom') { - // delete custom avatar - if ($this->user->avatarID) { - $action = new UserAvatarAction([$this->user->avatarID], 'delete'); - $action->executeAction(); - } - } - - $avatarData = []; - if ($this->avatarType === 'none') { - $avatarData = [ - 'avatarID' => null, - ]; - } - - $this->additionalFields = \array_merge($this->additionalFields, $avatarData); - if ($this->disconnect3rdParty) { $this->additionalFields['authData'] = ''; } @@ -585,28 +563,6 @@ protected function validatePassword( } } - /** - * Validates the user avatar. - */ - protected function validateAvatar() - { - if ($this->avatarType != 'custom') { - $this->avatarType = 'none'; - } - - try { - switch ($this->avatarType) { - case 'custom': - if (!$this->user->avatarID) { - throw new UserInputException('customAvatar'); - } - break; - } - } catch (UserInputException $e) { - $this->errorType[$e->getField()] = $e->getType(); - } - } - /** * @inheritDoc */ @@ -620,8 +576,6 @@ public function validate() } } - $this->validateAvatar(); - parent::validate(); if (!isset($this->availableStyles[$this->styleID])) { diff --git a/wcfsetup/install/files/lib/acp/page/SystemCheckPage.class.php b/wcfsetup/install/files/lib/acp/page/SystemCheckPage.class.php index c3fc12f02f1..df6189c4b78 100644 --- a/wcfsetup/install/files/lib/acp/page/SystemCheckPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/SystemCheckPage.class.php @@ -86,9 +86,9 @@ class SystemCheckPage extends AbstractPage public $foreignKeys = [ 'wcf1_user' => [ - 'avatarID' => [ - 'referenceTable' => 'wcf1_user_avatar', - 'referenceColumn' => 'avatarID', + 'avatarFileID' => [ + 'referenceTable' => 'wcf1_file', + 'referenceColumn' => 'fileID', ], ], 'wcf1_comment' => [ diff --git a/wcfsetup/install/files/lib/acp/page/UserListPage.class.php b/wcfsetup/install/files/lib/acp/page/UserListPage.class.php index 311b427838e..e87312076d8 100755 --- a/wcfsetup/install/files/lib/acp/page/UserListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/UserListPage.class.php @@ -8,6 +8,7 @@ use wcf\data\user\UserProfile; use wcf\page\SortablePage; use wcf\system\cache\builder\UserOptionCacheBuilder; +use wcf\system\cache\runtime\FileRuntimeCache; use wcf\system\clipboard\ClipboardHandler; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\event\EventHandler; @@ -285,16 +286,16 @@ protected function readUsers() $statement->execute($conditions->getParameters()); $userToGroups = $statement->fetchMap('userID', 'groupID', false); - $sql = "SELECT user_avatar.*, option_value.*, user_table.* + $sql = "SELECT option_value.*, user_table.* FROM wcf1_user user_table LEFT JOIN wcf1_user_option_value option_value ON option_value.userID = user_table.userID - LEFT JOIN wcf1_user_avatar user_avatar - ON user_avatar.avatarID = user_table.avatarID " . $conditions . " ORDER BY " . (($this->sortField != 'email' && isset($this->options[$this->sortField])) ? 'option_value.userOption' . $this->options[$this->sortField]->optionID : 'user_table.' . $this->sortField) . " " . $this->sortOrder; $statement = WCF::getDB()->prepare($sql); $statement->execute($conditions->getParameters()); + + $avatarFileIDs = []; while ($row = $statement->fetchArray()) { $groupIDs = ($userToGroups[$row['userID']] ?? []); @@ -308,8 +309,14 @@ protected function readUsers() $row['isMarked'] = \intval(\in_array($row['userID'], $this->markedUsers)); $this->users[] = new UserProfile(new User(null, $row)); + + if ($row['avatarFileID'] !== null) { + $avatarFileIDs[] = $row['avatarFileID']; + } } + FileRuntimeCache::getInstance()->cacheObjectIDs($avatarFileIDs); + // get special columns foreach ($this->users as $user) { foreach ($this->columns as $column) { diff --git a/wcfsetup/install/files/lib/action/UserAvatarAction.class.php b/wcfsetup/install/files/lib/action/UserAvatarAction.class.php new file mode 100644 index 00000000000..6358e5c41db --- /dev/null +++ b/wcfsetup/install/files/lib/action/UserAvatarAction.class.php @@ -0,0 +1,145 @@ + + * @since 6.2 + */ +final class UserAvatarAction implements RequestHandlerInterface +{ + #[\Override] + public function handle(ServerRequestInterface $request): ResponseInterface + { + $parameters = Helper::mapQueryParameters( + $request->getQueryParams(), + <<<'EOT' + array { + id?: positive-int + } + EOT + ); + + if (!WCF::getUser()->userID) { + throw new PermissionDeniedException(); + } + + if (isset($parameters['id'])) { + $user = UserProfileRuntimeCache::getInstance()->getObject($parameters['id']); + } else { + $user = UserProfileHandler::getInstance()->getUserProfile(); + } + + if (!$user->canEditAvatar()) { + throw new PermissionDeniedException(); + } + + $form = $this->getForm($user); + + if ($request->getMethod() === 'GET') { + return $form->toResponse(); + } elseif ($request->getMethod() === 'POST') { + $response = $form->validateRequest($request); + if ($response !== null) { + return $response; + } + + $data = $form->getData()['data']; + + // If the user has already uploaded and optionally cropped an image, + // this is already assigned to the `$user` and does not need to be saved again. + // However, if the user wants to delete their avatar and use a standard avatar, + // this must be saved and the cache reset + if ($data['avatarType'] === 'none') { + (new SetAvatar($user->getDecoratedObject()))(); + } + + // Reload the user object to get the updated avatar + UserProfileRuntimeCache::getInstance()->removeObject($user->userID); + $user = UserProfileRuntimeCache::getInstance()->getObject($user->userID); + + return new JsonResponse([ + 'result' => [ + 'avatar' => $user->getAvatar()->getURL(), + ], + ]); + } else { + throw new \LogicException('Unreachable'); + } + } + + private function getForm(UserProfile $user): Psr15DialogForm + { + $form = new Psr15DialogForm( + UserAvatarAction::class, + WCF::getLanguage()->get('wcf.user.avatarManagement') + ); + $form->appendChildren([ + RadioButtonFormField::create('avatarType') + ->value("none") + ->required() + ->options([ + "none" => WCF::getLanguage()->get('wcf.user.avatar.type.none'), + "custom" => WCF::getLanguage()->get('wcf.user.avatar.type.custom'), + ]), + FileProcessorFormField::create('avatarFileID') + ->objectType("com.woltlab.wcf.user.avatar") + ->required() + ->singleFileUpload() + ->bigPreview() + ->simpleReplace() + ->hideDeleteButton() + ->thumbnailSize('128') + ->addDependency( + ValueFormFieldDependency::create('avatarType') + ->fieldId('avatarType') + ->values(['custom']) + ), + ]); + $form->getDataHandler()->addProcessor( + new CustomFormDataProcessor( + 'avatarType', + null, + function (IFormDocument $document, array $data, IStorableObject $object) { + \assert($object instanceof UserProfile); + if ($object->avatarFileID === null) { + $data['avatarType'] = 'none'; + } else { + $data['avatarType'] = 'custom'; + } + + return $data; + } + ) + ); + + $form->markRequiredFields(false); + $form->updatedObject($user); + $form->build(); + + return $form; + } +} diff --git a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php index bd5f433c027..2af2ab0e179 100644 --- a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php +++ b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php @@ -7,10 +7,10 @@ use wcf\data\IThumbnailFile; use wcf\data\file\File; use wcf\data\file\thumbnail\FileThumbnail; -use wcf\data\file\thumbnail\FileThumbnailList; use wcf\data\object\type\ObjectTypeCache; use wcf\system\file\processor\IImageDataProvider; use wcf\system\file\processor\ImageData; +use wcf\system\cache\runtime\FileRuntimeCache; use wcf\system\request\IRouteController; use wcf\system\request\LinkHandler; use wcf\system\WCF; @@ -380,14 +380,7 @@ public function getFile(): ?File } if (!isset($this->file)) { - $this->file = new File($fileID); - - $thumbnailList = new FileThumbnailList(); - $thumbnailList->getConditionBuilder()->add("fileID = ?", [$this->file->fileID]); - $thumbnailList->readObjects(); - foreach ($thumbnailList as $thumbnail) { - $this->file->addThumbnail($thumbnail); - } + $this->file = FileRuntimeCache::getInstance()->getObject($fileID); } return $this->file; diff --git a/wcfsetup/install/files/lib/data/file/FileEditor.class.php b/wcfsetup/install/files/lib/data/file/FileEditor.class.php index 50564a6e8d8..d3e5ac3d578 100644 --- a/wcfsetup/install/files/lib/data/file/FileEditor.class.php +++ b/wcfsetup/install/files/lib/data/file/FileEditor.class.php @@ -117,7 +117,8 @@ public static function createFromTemporary(FileTemporary $fileTemporary): File public static function createFromExistingFile( string $pathname, string $originalFilename, - string $objectTypeName + string $objectTypeName, + bool $copy = false ): ?File { if (!\is_readable($pathname)) { return null; @@ -174,10 +175,11 @@ public static function createFromExistingFile( \mkdir($filePath, recursive: true); } - \rename( - $pathname, - $filePath . $file->getSourceFilename() - ); + if ($copy) { + \copy($pathname, $filePath . $file->getSourceFilename()); + } else { + \rename($pathname, $filePath . $file->getSourceFilename()); + } return $file; } diff --git a/wcfsetup/install/files/lib/data/user/TUserAvatarObjectList.class.php b/wcfsetup/install/files/lib/data/user/TUserAvatarObjectList.class.php new file mode 100644 index 00000000000..238c4a6eaab --- /dev/null +++ b/wcfsetup/install/files/lib/data/user/TUserAvatarObjectList.class.php @@ -0,0 +1,36 @@ + + * + * @property UserProfile[] $objects + * @mixin DatabaseObjectList + * + * @since 6.2 + */ +trait TUserAvatarObjectList +{ + protected function cacheAvatarFiles(): void + { + $avatarFileIDs = []; + foreach ($this->objects as $user) { + if ($user->avatarFileID !== null) { + $avatarFileIDs[] = $user->avatarFileID; + } + } + if ($avatarFileIDs === []) { + return; + } + + FileRuntimeCache::getInstance()->cacheObjectIDs($avatarFileIDs); + } +} diff --git a/wcfsetup/install/files/lib/data/user/User.class.php b/wcfsetup/install/files/lib/data/user/User.class.php index f3eecdfa3d6..dc96fb7086c 100644 --- a/wcfsetup/install/files/lib/data/user/User.class.php +++ b/wcfsetup/install/files/lib/data/user/User.class.php @@ -48,6 +48,7 @@ * @property-read int $reactivationCode code used for authenticating setting new email address or empty if no new email address has been set * @property-read string $registrationIpAddress ip address of the user at the time of registration or empty if user has been created manually or if no ip address are logged * @property-read int|null $avatarID id of the user's avatar or null if they have no avatar + * @property-read int|null $avatarFileID id of the user's avatar core file or null if they have no avatar * @property-read int $disableAvatar is `1` if the user's avatar has been disabled, otherwise `0` * @property-read string $disableAvatarReason reason why the user's avatar is disabled * @property-read int $disableAvatarExpires timestamp at which the user's avatar will automatically be enabled again diff --git a/wcfsetup/install/files/lib/data/user/UserAction.class.php b/wcfsetup/install/files/lib/data/user/UserAction.class.php index 7be5ec915b3..74522c11d09 100644 --- a/wcfsetup/install/files/lib/data/user/UserAction.class.php +++ b/wcfsetup/install/files/lib/data/user/UserAction.class.php @@ -4,10 +4,10 @@ use ParagonIE\ConstantTime\Hex; use wcf\data\AbstractDatabaseObjectAction; +use wcf\data\file\FileAction; use wcf\data\IClipboardAction; use wcf\data\ISearchAction; use wcf\data\object\type\ObjectTypeCache; -use wcf\data\user\avatar\UserAvatarAction; use wcf\data\user\group\UserGroup; use wcf\system\attachment\AttachmentHandler; use wcf\system\clipboard\ClipboardHandler; @@ -139,15 +139,14 @@ public function delete() } // delete avatars - $avatarIDs = []; + $avatarFileIDs = []; foreach ($this->getObjects() as $user) { - if ($user->avatarID) { - $avatarIDs[] = $user->avatarID; + if ($user->avatarFileID !== null) { + $avatarFileIDs[] = $user->avatarFileID; } } - if (!empty($avatarIDs)) { - $action = new UserAvatarAction($avatarIDs, 'delete'); - $action->executeAction(); + if (!empty($avatarFileIDs)) { + (new FileAction($avatarFileIDs, 'delete'))->executeAction(); } // delete profile comments and signature attachments diff --git a/wcfsetup/install/files/lib/data/user/UserProfile.class.php b/wcfsetup/install/files/lib/data/user/UserProfile.class.php index 9f25aa0a525..0b2083a4d35 100644 --- a/wcfsetup/install/files/lib/data/user/UserProfile.class.php +++ b/wcfsetup/install/files/lib/data/user/UserProfile.class.php @@ -3,13 +3,13 @@ namespace wcf\data\user; use wcf\data\DatabaseObjectDecorator; +use wcf\data\file\File; use wcf\data\ITitledLinkObject; use wcf\data\trophy\Trophy; use wcf\data\trophy\TrophyCache; use wcf\data\user\avatar\AvatarDecorator; use wcf\data\user\avatar\DefaultAvatar; use wcf\data\user\avatar\IUserAvatar; -use wcf\data\user\avatar\UserAvatar; use wcf\data\user\cover\photo\DefaultUserCoverPhoto; use wcf\data\user\cover\photo\IUserCoverPhoto; use wcf\data\user\cover\photo\UserCoverPhoto; @@ -20,6 +20,7 @@ use wcf\data\user\rank\UserRank; use wcf\system\cache\builder\UserGroupPermissionCacheBuilder; use wcf\system\cache\builder\UserRankCacheBuilder; +use wcf\system\cache\runtime\FileRuntimeCache; use wcf\system\cache\runtime\UserProfileRuntimeCache; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\email\Mailbox; @@ -348,21 +349,18 @@ public function getAvatar() if ($this->avatar === null) { if (!$this->disableAvatar) { if ($this->canSeeAvatar()) { - if ($this->avatarID) { - if (!$this->fileHash) { - $data = UserStorageHandler::getInstance()->getField('avatar', $this->userID); - if ($data === null) { - $this->avatar = new UserAvatar($this->avatarID); - UserStorageHandler::getInstance()->update( - $this->userID, - 'avatar', - \serialize($this->avatar) - ); - } else { - $this->avatar = \unserialize($data); - } + if ($this->avatarFileID !== null) { + $data = UserStorageHandler::getInstance()->getField('avatar', $this->userID); + if ($data === null) { + $this->avatar = FileRuntimeCache::getInstance()->getObject($this->avatarFileID); + + UserStorageHandler::getInstance()->update( + $this->userID, + 'avatar', + \serialize($this->avatar) + ); } else { - $this->avatar = new UserAvatar(null, $this->getDecoratedObject()->data); + $this->avatar = \unserialize($data); } } else { $parameters = ['avatar' => null]; @@ -393,6 +391,16 @@ public function getAvatar() return $this->avatar; } + /** + * Sets the user's avatar. + * + * @since 6.2 + */ + public function setFileAvatar(File $file): void + { + $this->avatar = new AvatarDecorator($file); + } + /** * Returns true if the active user can view the avatar of this user. * @@ -1190,4 +1198,27 @@ public function showTrophyPoints(): bool && $this->trophyPoints && ($this->isAccessible('canViewTrophies') || $this->userID == WCF::getSession()->userID); } + + /** + * @since 6.2 + */ + public function canEditAvatar(): bool + { + if ( + WCF::getSession()->getPermission('admin.user.canEditUser') + && UserGroup::isAccessibleGroup($this->getGroupIDs()) + ) { + return true; + } + + if ($this->userID !== WCF::getUser()->userID) { + return false; + } + + if ($this->disableAvatar) { + return false; + } + + return WCF::getSession()->getPermission('user.profile.avatar.canUploadAvatar'); + } } diff --git a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php index c9b1116dadf..b4a56b6b2d3 100644 --- a/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php +++ b/wcfsetup/install/files/lib/data/user/UserProfileAction.class.php @@ -3,9 +3,6 @@ namespace wcf\data\user; use wcf\data\object\type\ObjectTypeCache; -use wcf\data\user\avatar\UserAvatar; -use wcf\data\user\avatar\UserAvatarAction; -use wcf\data\user\avatar\UserAvatarEditor; use wcf\data\user\group\UserGroup; use wcf\system\bbcode\BBCodeHandler; use wcf\system\cache\runtime\UserProfileRuntimeCache; @@ -24,8 +21,6 @@ use wcf\system\user\storage\UserStorageHandler; use wcf\system\WCF; use wcf\util\ArrayUtil; -use wcf\util\FileUtil; -use wcf\util\ImageUtil; use wcf\util\MessageUtil; use wcf\util\StringUtil; @@ -511,94 +506,6 @@ public function updateSpecialTrophies() } } - /** - * Sets an avatar for a given user. The given file will be renamed and is gone after this method call. - * - * @throws UserInputException If none or more than one user is given. - * @throws \InvalidArgumentException If the given file is not an image or is incorrectly sized. - * @since 5.5 - */ - public function setAvatar(): array - { - $user = $this->getSingleObject(); - - $imageData = \getimagesize($this->parameters['fileLocation']); - - if (!$imageData) { - throw new \InvalidArgumentException("The given file is not an image."); - } - - if ( - ($imageData[0] != UserAvatar::AVATAR_SIZE || $imageData[1] != UserAvatar::AVATAR_SIZE) - && ($imageData[0] != UserAvatar::AVATAR_SIZE_2X || $imageData[1] != UserAvatar::AVATAR_SIZE_2X) - ) { - throw new \InvalidArgumentException( - \sprintf( - "The given file does not have the size of %dx%d", - UserAvatar::AVATAR_SIZE, - UserAvatar::AVATAR_SIZE - ) - ); - } - - $data = [ - 'avatarName' => $this->parameters['filename'] ?? \basename($this->parameters['fileLocation']), - 'avatarExtension' => ImageUtil::getExtensionByMimeType($imageData['mime']), - 'width' => $imageData[0], - 'height' => $imageData[1], - 'userID' => $user->userID, - 'fileHash' => \sha1_file($this->parameters['fileLocation']), - ]; - - // create avatar - $avatar = UserAvatarEditor::create($data); - - try { - // check avatar directory - // and create subdirectory if necessary - $dir = \dirname($avatar->getLocation(null, false)); - if (!\file_exists($dir)) { - FileUtil::makePath($dir); - } - - \rename($this->parameters['fileLocation'], $avatar->getLocation(null, false)); - - // Fix the permissions of the file in case the source file was created with restricted - // permissions (e.g. 0600 instead of 0644). Without this the file might not be readable - // for the web server if it runs with a different system user. - FileUtil::makeWritable($avatar->getLocation(null, false)); - - // Create the WebP variant or the JPEG fallback of the avatar. - $avatarEditor = new UserAvatarEditor($avatar); - if ($avatarEditor->createAvatarVariant()) { - $avatar = new UserAvatar($avatar->avatarID); - } - - // update user - $userEditor = new UserEditor($user->getDecoratedObject()); - $userEditor->update([ - 'avatarID' => $avatar->avatarID, - ]); - } catch (\Exception $e) { - $editor = new UserAvatarEditor($avatar); - $editor->delete(); - - throw $e; - } - - // delete old avatar - if ($user->avatarID) { - (new UserAvatarAction([$user->avatarID], 'delete'))->executeAction(); - } - - // reset user storage - UserStorageHandler::getInstance()->reset([$user->userID], 'avatar'); - - return [ - 'avatar' => $avatar, - ]; - } - /** * Validates the 'uploadCoverPhoto' method. * diff --git a/wcfsetup/install/files/lib/data/user/UserProfileList.class.php b/wcfsetup/install/files/lib/data/user/UserProfileList.class.php index ebe37f6b37f..bb362da65ef 100644 --- a/wcfsetup/install/files/lib/data/user/UserProfileList.class.php +++ b/wcfsetup/install/files/lib/data/user/UserProfileList.class.php @@ -17,6 +17,8 @@ */ class UserProfileList extends UserList { + use TUserAvatarObjectList; + /** * @inheritDoc */ @@ -37,13 +39,9 @@ public function __construct() if (!empty($this->sqlSelects)) { $this->sqlSelects .= ','; } - $this->sqlSelects .= "user_avatar.*"; - $this->sqlJoins .= " - LEFT JOIN wcf1_user_avatar user_avatar - ON user_avatar.avatarID = user_table.avatarID"; // get current location - $this->sqlSelects .= ", session.pageID, session.pageObjectID, session.lastActivityTime AS sessionLastActivityTime"; + $this->sqlSelects .= "session.pageID, session.pageObjectID, session.lastActivityTime AS sessionLastActivityTime"; $this->sqlJoins .= " LEFT JOIN wcf1_session session ON session.userID = user_table.userID"; @@ -59,5 +57,7 @@ public function readObjects() } parent::readObjects(); + + $this->cacheAvatarFiles(); } } diff --git a/wcfsetup/install/files/lib/data/user/avatar/AvatarDecorator.class.php b/wcfsetup/install/files/lib/data/user/avatar/AvatarDecorator.class.php index 1dcefff7177..85bd082fe78 100644 --- a/wcfsetup/install/files/lib/data/user/avatar/AvatarDecorator.class.php +++ b/wcfsetup/install/files/lib/data/user/avatar/AvatarDecorator.class.php @@ -2,21 +2,22 @@ namespace wcf\data\user\avatar; +use wcf\data\file\File; +use wcf\system\file\processor\UserAvatarFileProcessor; +use wcf\util\StringUtil; + /** * Wraps avatars to provide compatibility layers. * - * @author Tim Duesterhus - * @copyright 2001-2021 WoltLab GmbH + * @author Olaf Braun, Tim Duesterhus + * @copyright 2001-2024 WoltLab GmbH * @license GNU Lesser General Public License */ final class AvatarDecorator implements IUserAvatar, ISafeFormatAvatar { - /** - * @var IUserAvatar - */ - private $avatar; + private IUserAvatar | File $avatar; - public function __construct(IUserAvatar $avatar) + public function __construct(IUserAvatar | File $avatar) { $this->avatar = $avatar; } @@ -26,7 +27,9 @@ public function __construct(IUserAvatar $avatar) */ public function getSafeURL(?int $size = null): string { - if ($this->avatar instanceof ISafeFormatAvatar) { + if ($this->avatar instanceof File) { + return $this->getURL($size); + } elseif ($this->avatar instanceof ISafeFormatAvatar) { return $this->avatar->getSafeURL($size); } @@ -38,7 +41,9 @@ public function getSafeURL(?int $size = null): string */ public function getSafeImageTag(?int $size = null): string { - if ($this->avatar instanceof ISafeFormatAvatar) { + if ($this->avatar instanceof File) { + return $this->getImageTag($size); + } elseif ($this->avatar instanceof ISafeFormatAvatar) { return $this->avatar->getSafeImageTag($size); } @@ -50,7 +55,17 @@ public function getSafeImageTag(?int $size = null): string */ public function getURL($size = null) { - return $this->avatar->getURL(); + if ($this->avatar instanceof File) { + $thumbnail = $this->avatar->getThumbnail(UserAvatarFileProcessor::AVATAR_SIZE_2X) + ?? $this->avatar->getThumbnail(UserAvatarFileProcessor::AVATAR_SIZE); + if ($thumbnail !== null) { + return $thumbnail->getLink(); + } + + return $this->avatar->getFullSizeImageSource(); + } else { + return $this->avatar->getURL(); + } } /** @@ -58,7 +73,17 @@ public function getURL($size = null) */ public function getImageTag($size = null, bool $lazyLoading = true) { - return $this->avatar->getImageTag($size, $lazyLoading); + if ($this->avatar instanceof File) { + return \sprintf( + '', + StringUtil::encodeHTML($this->getSafeURL($size)), + $size, + $size, + $lazyLoading ? 'lazy' : 'eager' + ); + } else { + return $this->avatar->getImageTag($size, $lazyLoading); + } } /** @@ -66,7 +91,11 @@ public function getImageTag($size = null, bool $lazyLoading = true) */ public function getWidth() { - return $this->avatar->getWidth(); + if ($this->avatar instanceof File) { + return $this->avatar->width; + } else { + return $this->avatar->getWidth(); + } } /** @@ -74,6 +103,10 @@ public function getWidth() */ public function getHeight() { - return $this->avatar->getHeight(); + if ($this->avatar instanceof File) { + return $this->avatar->height; + } else { + return $this->avatar->getHeight(); + } } } diff --git a/wcfsetup/install/files/lib/data/user/avatar/DefaultAvatar.class.php b/wcfsetup/install/files/lib/data/user/avatar/DefaultAvatar.class.php index 12f4603a822..08f3cff8503 100644 --- a/wcfsetup/install/files/lib/data/user/avatar/DefaultAvatar.class.php +++ b/wcfsetup/install/files/lib/data/user/avatar/DefaultAvatar.class.php @@ -2,6 +2,7 @@ namespace wcf\data\user\avatar; +use wcf\system\file\processor\UserAvatarFileProcessor; use wcf\system\WCF; use wcf\util\StringUtil; @@ -18,7 +19,7 @@ class DefaultAvatar implements IUserAvatar, ISafeFormatAvatar * image size * @var int */ - public $size = UserAvatar::AVATAR_SIZE; + public $size = UserAvatarFileProcessor::AVATAR_SIZE; /** * content of the `src` attribute diff --git a/wcfsetup/install/files/lib/data/user/avatar/UserAvatar.class.php b/wcfsetup/install/files/lib/data/user/avatar/UserAvatar.class.php index 02620db1fd6..a7661557db7 100644 --- a/wcfsetup/install/files/lib/data/user/avatar/UserAvatar.class.php +++ b/wcfsetup/install/files/lib/data/user/avatar/UserAvatar.class.php @@ -22,6 +22,8 @@ * @property-read int|null $userID id of the user to which the user avatar belongs or null * @property-read string $fileHash SHA1 hash of the original avatar file * @property-read int $hasWebP `1` if there is a WebP variant, else `0` + * + * @deprecated 6.2 */ class UserAvatar extends DatabaseObject implements IUserAvatar, ISafeFormatAvatar { diff --git a/wcfsetup/install/files/lib/data/user/avatar/UserAvatarAction.class.php b/wcfsetup/install/files/lib/data/user/avatar/UserAvatarAction.class.php index abe9def589e..7879d2d3fad 100644 --- a/wcfsetup/install/files/lib/data/user/avatar/UserAvatarAction.class.php +++ b/wcfsetup/install/files/lib/data/user/avatar/UserAvatarAction.class.php @@ -3,14 +3,6 @@ namespace wcf\data\user\avatar; use wcf\data\AbstractDatabaseObjectAction; -use wcf\data\user\User; -use wcf\system\exception\IllegalLinkException; -use wcf\system\exception\PermissionDeniedException; -use wcf\system\exception\UserInputException; -use wcf\system\upload\AvatarUploadFileSaveStrategy; -use wcf\system\upload\AvatarUploadFileValidationStrategy; -use wcf\system\upload\UploadFile; -use wcf\system\WCF; /** * Executes avatar-related actions. @@ -22,69 +14,9 @@ * @method UserAvatar create() * @method UserAvatarEditor[] getObjects() * @method UserAvatarEditor getSingleObject() + * + * @deprecated 6.2 */ class UserAvatarAction extends AbstractDatabaseObjectAction { - /** - * currently edited avatar - * @var UserAvatarEditor - */ - public $avatar; - - /** - * Validates the upload action. - */ - public function validateUpload() - { - $this->readInteger('userID', true); - - if ($this->parameters['userID']) { - if (!WCF::getSession()->getPermission('admin.user.canEditUser')) { - throw new PermissionDeniedException(); - } - - $user = new User($this->parameters['userID']); - if (!$user->userID) { - throw new IllegalLinkException(); - } - } - - // check upload permissions - if (!WCF::getSession()->getPermission('user.profile.avatar.canUploadAvatar') || WCF::getUser()->disableAvatar) { - throw new PermissionDeniedException(); - } - - /** @noinspection PhpUndefinedMethodInspection */ - if (\count($this->parameters['__files']->getFiles()) != 1) { - throw new UserInputException('files'); - } - - // check max filesize, allowed file extensions etc. - /** @noinspection PhpUndefinedMethodInspection */ - $this->parameters['__files']->validateFiles(new AvatarUploadFileValidationStrategy( - \PHP_INT_MAX, - \explode("\n", WCF::getSession()->getPermission('user.profile.avatar.allowedFileExtensions')) - )); - } - - /** - * Handles uploaded attachments. - */ - public function upload() - { - /** @var UploadFile $file */ - $file = $this->parameters['__files']->getFiles()[0]; - $saveStrategy = new AvatarUploadFileSaveStrategy((!empty($this->parameters['userID']) ? \intval($this->parameters['userID']) : WCF::getUser()->userID)); - /** @noinspection PhpUndefinedMethodInspection */ - $this->parameters['__files']->saveFiles($saveStrategy); - - if ($file->getValidationErrorType()) { - return ['errorType' => $file->getValidationErrorType()]; - } else { - return [ - 'avatarID' => $saveStrategy->getAvatar()->avatarID, - 'url' => $saveStrategy->getAvatar()->getURL(96), - ]; - } - } } diff --git a/wcfsetup/install/files/lib/data/user/avatar/UserAvatarEditor.class.php b/wcfsetup/install/files/lib/data/user/avatar/UserAvatarEditor.class.php index f38fa1b7af1..6fb548a14be 100644 --- a/wcfsetup/install/files/lib/data/user/avatar/UserAvatarEditor.class.php +++ b/wcfsetup/install/files/lib/data/user/avatar/UserAvatarEditor.class.php @@ -16,6 +16,8 @@ * @method static UserAvatar create(array $parameters = []) * @method UserAvatar getDecoratedObject() * @mixin UserAvatar + * + * @deprecated 6.2 */ class UserAvatarEditor extends DatabaseObjectEditor { diff --git a/wcfsetup/install/files/lib/data/user/avatar/UserAvatarList.class.php b/wcfsetup/install/files/lib/data/user/avatar/UserAvatarList.class.php index 022d9c2f816..6ede57d0170 100644 --- a/wcfsetup/install/files/lib/data/user/avatar/UserAvatarList.class.php +++ b/wcfsetup/install/files/lib/data/user/avatar/UserAvatarList.class.php @@ -16,6 +16,8 @@ * @method UserAvatar|null getSingleObject() * @method UserAvatar|null search($objectID) * @property UserAvatar[] $objects + * + * @deprecated 6.2 */ class UserAvatarList extends DatabaseObjectList { diff --git a/wcfsetup/install/files/lib/data/user/follow/UserFollowerList.class.php b/wcfsetup/install/files/lib/data/user/follow/UserFollowerList.class.php index d98244c9959..232ed2c3202 100644 --- a/wcfsetup/install/files/lib/data/user/follow/UserFollowerList.class.php +++ b/wcfsetup/install/files/lib/data/user/follow/UserFollowerList.class.php @@ -2,6 +2,7 @@ namespace wcf\data\user\follow; +use wcf\data\user\TUserAvatarObjectList; use wcf\data\user\User; use wcf\data\user\UserProfile; @@ -20,6 +21,8 @@ */ class UserFollowerList extends UserFollowList { + use TUserAvatarObjectList; + /** * @inheritDoc */ @@ -48,12 +51,17 @@ public function __construct() parent::__construct(); $this->sqlSelects .= "user_table.username, user_table.email, user_table.disableAvatar"; - $this->sqlSelects .= ", user_avatar.*"; $this->sqlJoins .= " LEFT JOIN wcf1_user user_table - ON user_table.userID = user_follow.userID - LEFT JOIN wcf1_user_avatar user_avatar - ON user_avatar.avatarID = user_table.avatarID"; + ON user_table.userID = user_follow.userID"; + } + + #[\Override] + public function readObjects() + { + parent::readObjects(); + + $this->cacheAvatarFiles(); } } diff --git a/wcfsetup/install/files/lib/data/user/follow/UserFollowingList.class.php b/wcfsetup/install/files/lib/data/user/follow/UserFollowingList.class.php index 8d266ce42f0..04e5741b58c 100644 --- a/wcfsetup/install/files/lib/data/user/follow/UserFollowingList.class.php +++ b/wcfsetup/install/files/lib/data/user/follow/UserFollowingList.class.php @@ -25,15 +25,13 @@ public function __construct() { UserFollowList::__construct(); - $this->sqlSelects .= "user_avatar.*, user_follow.followID, user_option_value.*"; + $this->sqlSelects .= "user_follow.followID, user_option_value.*"; $this->sqlJoins .= " LEFT JOIN wcf1_user user_table ON user_table.userID = user_follow.followUserID LEFT JOIN wcf1_user_option_value user_option_value - ON user_option_value.userID = user_table.userID - LEFT JOIN wcf1_user_avatar user_avatar - ON user_avatar.avatarID = user_table.avatarID"; + ON user_option_value.userID = user_table.userID"; $this->sqlSelects .= ", user_table.*"; } diff --git a/wcfsetup/install/files/lib/data/user/ignore/ViewableUserIgnoreList.class.php b/wcfsetup/install/files/lib/data/user/ignore/ViewableUserIgnoreList.class.php index 3a682cd71cd..6a419c2c171 100644 --- a/wcfsetup/install/files/lib/data/user/ignore/ViewableUserIgnoreList.class.php +++ b/wcfsetup/install/files/lib/data/user/ignore/ViewableUserIgnoreList.class.php @@ -2,6 +2,7 @@ namespace wcf\data\user\ignore; +use wcf\data\user\TUserAvatarObjectList; use wcf\data\user\User; use wcf\data\user\UserProfile; @@ -14,6 +15,8 @@ */ class ViewableUserIgnoreList extends UserIgnoreList { + use TUserAvatarObjectList; + /** * @inheritDoc */ @@ -46,16 +49,21 @@ public function __construct() } $this->sqlSelects .= "user_ignore.ignoreID"; $this->sqlSelects .= ", user_option_value.*"; - $this->sqlSelects .= ", user_avatar.*"; $this->sqlJoins .= " LEFT JOIN wcf1_user user_table ON user_table.userID = user_ignore.ignoreUserID LEFT JOIN wcf1_user_option_value user_option_value - ON user_option_value.userID = user_table.userID - LEFT JOIN wcf1_user_avatar user_avatar - ON user_avatar.avatarID = user_table.avatarID"; + ON user_option_value.userID = user_table.userID"; $this->sqlSelects .= ", user_table.*"; } + + #[\Override] + public function readObjects() + { + parent::readObjects(); + + $this->cacheAvatarFiles(); + } } diff --git a/wcfsetup/install/files/lib/data/user/online/UsersOnlineList.class.php b/wcfsetup/install/files/lib/data/user/online/UsersOnlineList.class.php index 06b342d8737..a8cfb384622 100644 --- a/wcfsetup/install/files/lib/data/user/online/UsersOnlineList.class.php +++ b/wcfsetup/install/files/lib/data/user/online/UsersOnlineList.class.php @@ -5,6 +5,7 @@ use wcf\data\option\OptionAction; use wcf\data\session\SessionList; use wcf\data\user\group\UserGroup; +use wcf\data\user\TUserAvatarObjectList; use wcf\data\user\User; use wcf\data\user\UserProfile; use wcf\system\event\EventHandler; @@ -26,6 +27,8 @@ */ class UsersOnlineList extends SessionList { + use TUserAvatarObjectList; + /** * @inheritDoc */ @@ -55,7 +58,7 @@ public function __construct() { parent::__construct(); - $this->sqlSelects .= "user_avatar.*, user_option_value.*, user_group.userOnlineMarking, user_table.*"; + $this->sqlSelects .= "user_option_value.*, user_group.userOnlineMarking, user_table.*"; $this->sqlConditionJoins .= " LEFT JOIN wcf1_user user_table @@ -65,8 +68,6 @@ public function __construct() ON user_table.userID = session.userID LEFT JOIN wcf1_user_option_value user_option_value ON user_option_value.userID = user_table.userID - LEFT JOIN wcf1_user_avatar user_avatar - ON user_avatar.avatarID = user_table.avatarID LEFT JOIN wcf1_user_group user_group ON user_group.groupID = user_table.userOnlineGroupID"; @@ -92,6 +93,8 @@ public function readObjects() } $this->objectIDs = $this->indexToObject; $this->rewind(); + + $this->cacheAvatarFiles(); } /** diff --git a/wcfsetup/install/files/lib/data/user/profile/visitor/UserProfileVisitorList.class.php b/wcfsetup/install/files/lib/data/user/profile/visitor/UserProfileVisitorList.class.php index 39233b8bd90..1852452c4d1 100644 --- a/wcfsetup/install/files/lib/data/user/profile/visitor/UserProfileVisitorList.class.php +++ b/wcfsetup/install/files/lib/data/user/profile/visitor/UserProfileVisitorList.class.php @@ -3,6 +3,7 @@ namespace wcf\data\user\profile\visitor; use wcf\data\DatabaseObjectList; +use wcf\data\user\TUserAvatarObjectList; use wcf\data\user\User; use wcf\data\user\UserProfile; @@ -21,6 +22,8 @@ */ class UserProfileVisitorList extends DatabaseObjectList { + use TUserAvatarObjectList; + /** * @inheritDoc */ @@ -44,12 +47,17 @@ public function __construct() parent::__construct(); $this->sqlSelects .= "user_table.username, user_table.email, user_table.disableAvatar"; - $this->sqlSelects .= ", user_avatar.*"; $this->sqlJoins .= " LEFT JOIN wcf1_user user_table - ON user_table.userID = user_profile_visitor.userID - LEFT JOIN wcf1_user_avatar user_avatar - ON user_avatar.avatarID = user_table.avatarID"; + ON user_table.userID = user_profile_visitor.userID"; + } + + #[\Override] + public function readObjects() + { + parent::readObjects(); + + $this->cacheAvatarFiles(); } } diff --git a/wcfsetup/install/files/lib/form/AvatarEditForm.class.php b/wcfsetup/install/files/lib/form/AvatarEditForm.class.php deleted file mode 100644 index c458b6eea7b..00000000000 --- a/wcfsetup/install/files/lib/form/AvatarEditForm.class.php +++ /dev/null @@ -1,148 +0,0 @@ - - */ -class AvatarEditForm extends AbstractForm -{ - /** - * @inheritDoc - */ - public $loginRequired = true; - - /** - * @inheritDoc - */ - public $templateName = 'avatarEdit'; - - /** - * avatar type - * @var string - */ - public $avatarType = 'none'; - - /** - * @inheritDoc - */ - public function readFormParameters() - { - parent::readFormParameters(); - - if (isset($_POST['avatarType'])) { - $this->avatarType = $_POST['avatarType']; - } - } - - /** - * @inheritDoc - */ - public function validate() - { - parent::validate(); - - if (WCF::getUser()->disableAvatar) { - throw new PermissionDeniedException(); - } - - if ($this->avatarType != 'custom') { - $this->avatarType = 'none'; - } - - switch ($this->avatarType) { - case 'custom': - if (!WCF::getUser()->avatarID) { - throw new UserInputException('custom'); - } - break; - } - } - - /** - * @inheritDoc - */ - public function save() - { - parent::save(); - - if ($this->avatarType != 'custom') { - // delete custom avatar - if (WCF::getUser()->avatarID) { - $action = new UserAvatarAction([WCF::getUser()->avatarID], 'delete'); - $action->executeAction(); - } - } - - // update user - $data = []; - if ($this->avatarType === 'none') { - $data = [ - 'avatarID' => null, - ]; - } - $this->objectAction = new UserAction([WCF::getUser()], 'update', [ - 'data' => \array_merge($this->additionalFields, $data), - ]); - $this->objectAction->executeAction(); - - // check if the user will be automatically added to new user groups - // because of the changed avatar - UserGroupAssignmentHandler::getInstance()->checkUsers([WCF::getUser()->userID]); - - UserProfileHandler::getInstance()->reloadUserProfile(); - - $this->saved(); - WCF::getTPL()->assign('success', true); - } - - /** - * @inheritDoc - */ - public function readData() - { - parent::readData(); - - if (empty($_POST)) { - if (WCF::getUser()->avatarID) { - $this->avatarType = 'custom'; - } - } - } - - /** - * @inheritDoc - */ - public function assignVariables() - { - parent::assignVariables(); - - WCF::getTPL()->assign([ - 'avatarType' => $this->avatarType, - ]); - } - - /** - * @inheritDoc - */ - public function show() - { - // set active tab - UserMenu::getInstance()->setActiveMenuItem('wcf.user.menu.profile.avatar'); - - parent::show(); - } -} diff --git a/wcfsetup/install/files/lib/system/condition/UserAvatarCondition.class.php b/wcfsetup/install/files/lib/system/condition/UserAvatarCondition.class.php index 07e584e9606..5e386fe1083 100644 --- a/wcfsetup/install/files/lib/system/condition/UserAvatarCondition.class.php +++ b/wcfsetup/install/files/lib/system/condition/UserAvatarCondition.class.php @@ -61,11 +61,11 @@ public function addObjectListCondition(DatabaseObjectList $objectList, array $co switch ($conditionData['userAvatar']) { case self::NO_AVATAR: - $objectList->getConditionBuilder()->add('user_table.avatarID IS NULL'); + $objectList->getConditionBuilder()->add('user_table.avatarFileID IS NULL'); break; case self::AVATAR: - $objectList->getConditionBuilder()->add('user_table.avatarID IS NOT NULL'); + $objectList->getConditionBuilder()->add('user_table.avatarFileID IS NOT NULL'); break; case self::GRAVATAR: @@ -81,10 +81,10 @@ public function checkUser(Condition $condition, User $user) { switch ($condition->userAvatar) { case self::NO_AVATAR: - return !$user->avatarID; + return !$user->avatarFileID; case self::AVATAR: - return $user->avatarID != 0; + return $user->avatarFileID !== null; case self::GRAVATAR: return false; diff --git a/wcfsetup/install/files/lib/system/file/processor/UserAvatarFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/UserAvatarFileProcessor.class.php new file mode 100644 index 00000000000..1a1a9c88ca7 --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/UserAvatarFileProcessor.class.php @@ -0,0 +1,244 @@ + + * @since 6.2 + */ +final class UserAvatarFileProcessor extends AbstractFileProcessor +{ + private const SESSION_VARIABLE = 'wcf_user_avatar_processor_%d'; + + public const AVATAR_SIZE = 128; + + /** + * Size of HiDPI version + */ + public const AVATAR_SIZE_2X = 256; + + #[\Override] + public function getObjectTypeName(): string + { + return 'com.woltlab.wcf.user.avatar'; + } + + #[\Override] + public function getAllowedFileExtensions(array $context): array + { + return \explode("\n", WCF::getSession()->getPermission('user.profile.avatar.allowedFileExtensions')); + } + + #[\Override] + public function canAdopt(File $file, array $context): bool + { + $userFromContext = $this->getUser($context); + $userFromCoreFile = $this->getUserByFile($file); + + if ($userFromCoreFile === null) { + return true; + } + + if ($userFromContext->userID === $userFromCoreFile->userID) { + return true; + } + + return false; + } + + #[\Override] + public function adopt(File $file, array $context): void + { + $user = $this->getUser($context); + if ($user === null) { + return; + } + + // Save the `fileID` in the session variable so that the current user can delete the old avatar + if ($user->avatarFileID !== null) { + WCF::getSession()->register(\sprintf(self::SESSION_VARIABLE, $user->avatarFileID), TIME_NOW); + WCF::getSession()->update(); + } + + (new SetAvatar($user->getDecoratedObject(), $file))(); + } + + #[\Override] + public function acceptUpload(string $filename, int $fileSize, array $context): FileProcessorPreflightResult + { + $user = $this->getUser($context); + + if ($user === null) { + return FileProcessorPreflightResult::InvalidContext; + } + + if (!$user->canEditAvatar()) { + return FileProcessorPreflightResult::InsufficientPermissions; + } + + if ($fileSize > $this->getMaximumSize($context)) { + return FileProcessorPreflightResult::FileSizeTooLarge; + } + + if (!FileUtil::endsWithAllowedExtension($filename, $this->getAllowedFileExtensions($context))) { + return FileProcessorPreflightResult::FileExtensionNotPermitted; + } + + return FileProcessorPreflightResult::Passed; + } + + #[\Override] + public function validateUpload(File $file): void + { + $imageData = @\getimagesize($file->getPathname()); + if ($imageData === false) { + throw new UserInputException('file', 'noImage'); + } + + if ($imageData[0] !== $imageData[1]) { + throw new UserInputException('file', 'notSquare'); + } + + if ( + $imageData[0] != UserAvatarFileProcessor::AVATAR_SIZE + && $imageData[0] != UserAvatarFileProcessor::AVATAR_SIZE_2X + ) { + throw new UserInputException('file', 'wrongSize'); + } + } + + #[\Override] + public function canDelete(File $file): bool + { + $user = $this->getUserByFile($file); + if ($user === null) { + return WCF::getSession()->getVar( + \sprintf(self::SESSION_VARIABLE, $file->fileID) + ) !== null; + } + + return $user->canEditAvatar(); + } + + #[\Override] + public function canDownload(File $file): bool + { + $user = $this->getUserByFile($file); + if ($user === null) { + return false; + } + + return $user->canSeeAvatar(); + } + + #[\Override] + public function getThumbnailFormats(): array + { + return [ + new ThumbnailFormat( + '128', + UserAvatarFileProcessor::AVATAR_SIZE, + UserAvatarFileProcessor::AVATAR_SIZE, + false + ), + new ThumbnailFormat( + '256', + UserAvatarFileProcessor::AVATAR_SIZE_2X, + UserAvatarFileProcessor::AVATAR_SIZE_2X, + false + ), + ]; + } + + #[\Override] + public function delete(array $fileIDs, array $thumbnailIDs): void + { + \array_map( + static fn(int $fileID) => WCF::getSession()->unregister( + \sprintf(self::SESSION_VARIABLE, $fileID) + ), + $fileIDs + ); + + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('avatarFileID IN (?)', [$fileIDs]); + + $sql = "UPDATE wcf1_user + SET avatarFileID = ? + " . $conditionBuilder; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([null, ...$conditionBuilder->getParameters()]); + } + + #[\Override] + public function countExistingFiles(array $context): ?int + { + $user = $this->getUser($context); + if ($user === null) { + return null; + } + + return $user->avatarFileID === null ? 0 : 1; + } + + #[\Override] + public function getMaximumSize(array $context): ?int + { + /** + * Reject the file if it is larger than 750 kB after resizing. A worst-case + * completely-random 128x128 PNG is around 35 kB and JPEG is around 50 kB. + * + * Animated GIFs can be much larger depending on the length of animation, + * 750 kB seems to be a reasonable upper bound for anything that can be + * considered reasonable with regard to "distraction" and mobile data + * volume. + */ + return 750_000; + } + + #[\Override] + public function getImageCropperConfiguration(): ?ImageCropperConfiguration + { + return ImageCropperConfiguration::forExact( + new ImageCropSize(UserAvatarFileProcessor::AVATAR_SIZE, UserAvatarFileProcessor::AVATAR_SIZE), + new ImageCropSize(UserAvatarFileProcessor::AVATAR_SIZE_2X, UserAvatarFileProcessor::AVATAR_SIZE_2X) + ); + } + + private function getUser(array $context): ?UserProfile + { + $userID = $context['objectID'] ?? null; + if ($userID === null) { + return null; + } + + return UserProfileRuntimeCache::getInstance()->getObject($userID); + } + + private function getUserByFile(File $file): ?UserProfile + { + $sql = "SELECT userID + FROM wcf1_user + WHERE avatarFileID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$file->fileID]); + $userID = $statement->fetchSingleColumn(); + + if ($userID === false) { + return null; + } + + return UserProfileRuntimeCache::getInstance()->getObject($userID); + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php index 0d1c14f6ad7..a0e1ce520d3 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php @@ -45,6 +45,9 @@ final class FileProcessorFormField extends AbstractFormField private array $files = []; private bool $singleFileUpload = false; private bool $bigPreview = false; + private bool $simpleReplace = false; + private bool $hideDeleteButton = false; + private ?string $thumbnailSize = null; private array $actionButtons = []; #[\Override] @@ -79,6 +82,7 @@ public function getHtmlVariables() ), 'maxUploads' => $this->getFileProcessor()->getMaximumCount($this->context), 'actionButtons' => $this->actionButtons, + 'simpleReplace' => $this->simpleReplace, ]; } @@ -136,6 +140,12 @@ public function singleFileUpload(bool $singleFileUpload = true): self ); } + if (!$singleFileUpload && $this->simpleReplace) { + throw new \InvalidArgumentException( + "Single file upload can't be disabled if the simple replace is enabled for the field '{$this->getId()}'." + ); + } + $this->singleFileUpload = $singleFileUpload; return $this; @@ -302,4 +312,70 @@ public function bigPreview(bool $bigPreview = true): self return $this; } + + /** + * Returns whether the simple replace is enabled. + */ + public function isSimpleReplace(): bool + { + return $this->simpleReplace; + } + + /** + * Sets whether the simple replace is enabled. + * Simple replace can only be enabled if single file upload is true. + * If enabled, there is no replace button and the existing file is replaced when a new file is uploaded. + */ + public function simpleReplace(bool $simpleReplace = true): self + { + if ($simpleReplace && !$this->singleFileUpload) { + throw new \InvalidArgumentException( + "Simple replace can only be enabled for single file uploads for the field '{$this->getId()}'." + ); + } + + $this->simpleReplace = $simpleReplace; + + return $this; + } + + /** + * Sets whether the delete button should be hidden. + */ + public function hideDeleteButton(bool $hideDeleteButton = true): self + { + $this->hideDeleteButton = $hideDeleteButton; + + return $this; + } + + /** + * Returns whether the delete button is hidden. + */ + public function isHideDeleteButton(): bool + { + return $this->hideDeleteButton; + } + + /** + * Sets the thumbnail size for the preview. + * + * If no size is set: + * - And the big preview is enabled, the full size is used + * - Otherwise, the thumbnail size `tiny` is used + */ + public function thumbnailSize(?string $thumbnailSize): self + { + $this->thumbnailSize = $thumbnailSize; + + return $this; + } + + /** + * Returns the thumbnail size for the preview. + */ + public function getThumbnailSize(): ?string + { + return $this->thumbnailSize; + } } diff --git a/wcfsetup/install/files/lib/system/importer/AbstractFileImporter.class.php b/wcfsetup/install/files/lib/system/importer/AbstractFileImporter.class.php new file mode 100644 index 00000000000..54095e5089a --- /dev/null +++ b/wcfsetup/install/files/lib/system/importer/AbstractFileImporter.class.php @@ -0,0 +1,50 @@ + + */ +abstract class AbstractFileImporter extends AbstractImporter +{ + /** + * @inheritDoc + */ + protected $className = File::class; + + /** + * object type for `com.woltlab.wcf.file` + */ + protected string $objectType; + + + protected function importFile(string $fileLocation, ?string $filename = null): ?File + { + // check file location + if (!\is_readable($fileLocation)) { + return null; + } + + $filename = $filename ?: \basename($fileLocation); + $file = FileEditor::createFromExistingFile($fileLocation, $filename, $this->objectType, true); + + if ($file === null) { + return null; + } + + if ($this->isValidFile($file)) { + return $file; + } + + return null; + } + + abstract protected function isValidFile(File $file): bool; +} diff --git a/wcfsetup/install/files/lib/system/importer/UserAvatarImporter.class.php b/wcfsetup/install/files/lib/system/importer/UserAvatarImporter.class.php index c1bcb2ace09..d2c84897bd2 100644 --- a/wcfsetup/install/files/lib/system/importer/UserAvatarImporter.class.php +++ b/wcfsetup/install/files/lib/system/importer/UserAvatarImporter.class.php @@ -2,12 +2,8 @@ namespace wcf\system\importer; -use wcf\data\user\avatar\UserAvatar; -use wcf\data\user\avatar\UserAvatarEditor; -use wcf\system\exception\SystemException; +use wcf\data\file\File; use wcf\system\WCF; -use wcf\util\FileUtil; -use wcf\util\ImageUtil; /** * Imports user avatars. @@ -16,74 +12,41 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License */ -class UserAvatarImporter extends AbstractImporter +class UserAvatarImporter extends AbstractFileImporter { /** * @inheritDoc */ - protected $className = UserAvatar::class; + protected string $objectType = 'com.woltlab.wcf.user.avatar'; - /** - * @inheritDoc - */ + #[\Override] public function import($oldID, array $data, array $additionalData = []) { - // check file location - if (!\is_readable($additionalData['fileLocation'])) { - return 0; - } - - // get image size - $imageData = @\getimagesize($additionalData['fileLocation']); - if ($imageData === false) { - return 0; - } - $data['width'] = $imageData[0]; - $data['height'] = $imageData[1]; - $data['avatarExtension'] = ImageUtil::getExtensionByMimeType($imageData['mime']); - $data['fileHash'] = \sha1_file($additionalData['fileLocation']); - - // check image type - if ($imageData[2] != \IMAGETYPE_GIF && $imageData[2] != \IMAGETYPE_JPEG && $imageData[2] != \IMAGETYPE_PNG) { - return 0; - } - // get user id $data['userID'] = ImportHandler::getInstance()->getNewID('com.woltlab.wcf.user', $data['userID']); if (!$data['userID']) { return 0; } - // save avatar - $avatar = UserAvatarEditor::create($data); - - // check avatar directory - // and create subdirectory if necessary - $dir = \dirname($avatar->getLocation()); - if (!@\file_exists($dir)) { - FileUtil::makePath($dir); + $file = $this->importFile($additionalData['fileLocation'], $data['avatarName']); + if ($file === null) { + return 0; } - // copy file - try { - if (!\copy($additionalData['fileLocation'], $avatar->getLocation())) { - throw new SystemException(); - } + $sql = "UPDATE wcf1_user + SET avatarFileID = ? + WHERE userID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $file->fileID, + $data['userID'] + ]); - // update owner - $sql = "UPDATE wcf1_user - SET avatarID = ? - WHERE userID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([$avatar->avatarID, $data['userID']]); - - return $avatar->avatarID; - } catch (SystemException $e) { - // copy failed; delete avatar - $editor = new UserAvatarEditor($avatar); - $editor->delete(); - } + return $file->fileID; + } - return 0; + protected function isValidFile(File $file): bool + { + return $file->isImage(); } } diff --git a/wcfsetup/install/files/lib/system/upload/AvatarUploadFileSaveStrategy.class.php b/wcfsetup/install/files/lib/system/upload/AvatarUploadFileSaveStrategy.class.php deleted file mode 100644 index 838404e2dca..00000000000 --- a/wcfsetup/install/files/lib/system/upload/AvatarUploadFileSaveStrategy.class.php +++ /dev/null @@ -1,121 +0,0 @@ - - * @since 5.2 - */ -class AvatarUploadFileSaveStrategy implements IUploadFileSaveStrategy -{ - /** - * @var int - */ - protected $userID = 0; - - /** - * @var User - */ - protected $user; - - /** - * @var UserAvatar - */ - protected $avatar; - - /** - * Reject the file if it is larger than 750 kB after resizing. A worst-case - * completely-random 128x128 PNG is around 35 kB and JPEG is around 50 kB. - * - * Animated GIFs can be much larger depending on the length of animation, - * 750 kB seems to be a reasonable upper bound for anything that can be - * considered reasonable with regard to "distraction" and mobile data - * volume. - */ - private const MAXIMUM_FILESIZE = 750_000; - - /** - * Creates a new instance of AvatarUploadFileSaveStrategy. - * - * @param int $userID - */ - public function __construct($userID = null) - { - $this->userID = ($userID ?: WCF::getUser()->userID); - $this->user = ($this->userID != WCF::getUser()->userID ? new User($userID) : WCF::getUser()); - } - - /** - * @return UserAvatar - */ - public function getAvatar() - { - return $this->avatar; - } - - /** - * @inheritDoc - */ - public function save(UploadFile $uploadFile) - { - if (!$uploadFile->getValidationErrorType()) { - // rotate avatar if necessary - /** @noinspection PhpUnusedLocalVariableInspection */ - $fileLocation = ImageUtil::fixOrientation($uploadFile->getLocation()); - - // shrink avatar if necessary - try { - $newWidth = $newHeight = UserAvatar::AVATAR_SIZE; - - // Save HiDPI version if possible. - $imageData = \getimagesize($fileLocation); - if ($imageData[0] >= UserAvatar::AVATAR_SIZE_2X && $imageData[1] >= UserAvatar::AVATAR_SIZE_2X) { - $newWidth = $newHeight = UserAvatar::AVATAR_SIZE_2X; - } - - $fileLocation = ImageUtil::enforceDimensions( - $fileLocation, - $newWidth, - $newHeight, - false - ); - } - /** @noinspection PhpRedundantCatchClauseInspection */ - catch (SystemException $e) { - $uploadFile->setValidationErrorType('tooLarge'); - - return; - } - - if (\filesize($fileLocation) > self::MAXIMUM_FILESIZE) { - $uploadFile->setValidationErrorType('tooLarge'); - - return; - } - - try { - $returnValues = (new UserProfileAction([$this->userID], 'setAvatar', [ - 'fileLocation' => $fileLocation, - 'filename' => $uploadFile->getFilename(), - 'extension' => $uploadFile->getFileExtension(), - ]))->executeAction(); - - $this->avatar = $returnValues['returnValues']['avatar']; - } catch (RuntimeException $e) { - $uploadFile->setValidationErrorType('uploadFailed'); - } - } - } -} diff --git a/wcfsetup/install/files/lib/system/upload/AvatarUploadFileValidationStrategy.class.php b/wcfsetup/install/files/lib/system/upload/AvatarUploadFileValidationStrategy.class.php deleted file mode 100644 index 0def81e1553..00000000000 --- a/wcfsetup/install/files/lib/system/upload/AvatarUploadFileValidationStrategy.class.php +++ /dev/null @@ -1,68 +0,0 @@ - - */ -class AvatarUploadFileValidationStrategy extends DefaultUploadFileValidationStrategy -{ - /** - * @inheritDoc - */ - public function validate(UploadFile $uploadFile) - { - if (!parent::validate($uploadFile)) { - return false; - } - - // check image size - try { - $imageData = \getimagesize($uploadFile->getLocation()); - if ($imageData === false) { - $uploadFile->setValidationErrorType('badImage'); - - return false; - } - - if ($imageData[0] < UserAvatar::AVATAR_SIZE || $imageData[1] < UserAvatar::AVATAR_SIZE) { - $uploadFile->setValidationErrorType('tooSmall'); - - return false; - } else { - // Validate the mime type against the list of allowed extensions. - // - // We usually don't care about the extension, restricting allowed file extensions - // primarily exists to prevent users from uploaded clickable '.exe'. The software - // itself only ever uses the mime type. - // - // In the case of avatars, though, the administrator might want to disallow uploading - // GIF files to prevent the most common case of animated avatar, thus we specifically - // validate the mime type against the extension. - $extension = \image_type_to_extension($imageData[2], false); - if (!\in_array($extension, $this->fileExtensions)) { - $uploadFile->setValidationErrorType('invalidExtension'); - - return false; - } - } - } catch (SystemException $e) { - if (ENABLE_DEBUG_MODE) { - throw $e; - } - - $uploadFile->setValidationErrorType('badImage'); - - return false; - } - - return true; - } -} diff --git a/wcfsetup/install/files/lib/system/user/command/SetAvatar.class.php b/wcfsetup/install/files/lib/system/user/command/SetAvatar.class.php new file mode 100644 index 00000000000..b77ee30e6be --- /dev/null +++ b/wcfsetup/install/files/lib/system/user/command/SetAvatar.class.php @@ -0,0 +1,52 @@ + + * @since 6.2 + */ +final class SetAvatar +{ + public function __construct( + private readonly User $user, + private readonly ?File $file = null + ) { + } + + public function __invoke() + { + if ($this->file === null && $this->user->avatarFileID !== null) { + (new FileAction([$this->user->avatarFileID], 'delete'))->executeAction(); + } + + (new UserEditor($this->user))->update([ + 'avatarFileID' => $this->file?->fileID, + 'avatarID' => null, + ]); + + UserStorageHandler::getInstance()->reset([$this->user->userID], 'avatar'); + UserProfileRuntimeCache::getInstance()->removeObject($this->user->userID); + + // Setting an avatar could satisfy the condition to assign a user to user groups. + UserGroupAssignmentHandler::getInstance()->checkUsers([$this->user->userID]); + + if ($this->user->userID === WCF::getUser()->userID) { + UserProfileHandler::getInstance()->reloadUserProfile(); + } + } +} diff --git a/wcfsetup/install/files/lib/system/worker/UserRebuildDataWorker.class.php b/wcfsetup/install/files/lib/system/worker/UserRebuildDataWorker.class.php index 729f7104303..9409784bfeb 100644 --- a/wcfsetup/install/files/lib/system/worker/UserRebuildDataWorker.class.php +++ b/wcfsetup/install/files/lib/system/worker/UserRebuildDataWorker.class.php @@ -2,8 +2,8 @@ namespace wcf\system\worker; +use wcf\data\file\FileEditor; use wcf\data\reaction\type\ReactionTypeCache; -use wcf\data\user\avatar\UserAvatar; use wcf\data\user\avatar\UserAvatarEditor; use wcf\data\user\avatar\UserAvatarList; use wcf\data\user\cover\photo\DefaultUserCoverPhoto; @@ -16,6 +16,7 @@ use wcf\system\bbcode\BBCodeHandler; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\exception\SystemException; +use wcf\system\file\processor\UserAvatarFileProcessor; use wcf\system\html\input\HtmlInputProcessor; use wcf\system\image\ImageHandler; use wcf\system\user\storage\UserStorageHandler; @@ -214,20 +215,14 @@ public function execute() // update old/imported avatars $avatarList = new UserAvatarList(); $avatarList->getConditionBuilder()->add('user_avatar.userID IN (?)', [$userIDs]); - $avatarList->getConditionBuilder()->add( - '( - (user_avatar.width <> ? OR user_avatar.height <> ?) - OR (user_avatar.hasWebP = ? AND user_avatar.avatarExtension <> ?) - )', - [ - UserAvatar::AVATAR_SIZE, - UserAvatar::AVATAR_SIZE, - 0, - "gif", - ] - ); $avatarList->readObjects(); $resetAvatarCache = []; + + $sql = "UPDATE wcf1_user + SET avatarFileID = ? + WHERE userID = ?"; + $avatarUpdateStatement = WCF::getDB()->prepare($sql); + foreach ($avatarList as $avatar) { $resetAvatarCache[] = $avatar->userID; @@ -242,7 +237,11 @@ public function execute() $height = $avatar->height; if ($width != $height) { // make avatar quadratic - $width = $height = \min($width, $height, UserAvatar::AVATAR_SIZE); + // minimum size is 128x128, maximum size is 256x256 + $width = $height = \min( + \max($avatar->width, $avatar->height, UserAvatarFileProcessor::AVATAR_SIZE), + UserAvatarFileProcessor::AVATAR_SIZE_2X + ); $adapter = ImageHandler::getInstance()->getAdapter(); try { @@ -259,7 +258,10 @@ public function execute() $thumbnail = null; } - if ($width != UserAvatar::AVATAR_SIZE || $height != UserAvatar::AVATAR_SIZE) { + if ( + $width != UserAvatarFileProcessor::AVATAR_SIZE + && $width != UserAvatarFileProcessor::AVATAR_SIZE_2X + ) { // resize avatar $adapter = ImageHandler::getInstance()->getAdapter(); @@ -271,16 +273,42 @@ public function execute() continue; } - $adapter->resize(0, 0, $width, $height, UserAvatar::AVATAR_SIZE, UserAvatar::AVATAR_SIZE); + if ($width > UserAvatarFileProcessor::AVATAR_SIZE_2X) { + $adapter->resize( + 0, + 0, + $width, + $height, + UserAvatarFileProcessor::AVATAR_SIZE_2X, + UserAvatarFileProcessor::AVATAR_SIZE_2X + ); + } else { + $adapter->resize( + 0, + 0, + $width, + $height, + UserAvatarFileProcessor::AVATAR_SIZE, + UserAvatarFileProcessor::AVATAR_SIZE + ); + } $adapter->writeImage($adapter->getImage(), $avatar->getLocation()); - $width = $height = UserAvatar::AVATAR_SIZE; } - $editor->createAvatarVariant(); + $file = FileEditor::createFromExistingFile( + $avatar->getLocation(), + $avatar->avatarName, + 'com.woltlab.wcf.user.avatar' + ); + $editor->delete(); + + if ($file === null) { + continue; + } - $editor->update([ - 'width' => $width, - 'height' => $height, + $avatarUpdateStatement->execute([ + $file->fileID, + $avatar->userID ]); } diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 0647427a88f..3ec9fe7f752 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -4863,6 +4863,7 @@ sich{/if} nicht bei uns registriert {if LANGUAGE_USE_INFORMAL_VARIANT}hast{else} + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index c424ff3a02f..70c3e7fbde8 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -4865,6 +4865,7 @@ not register with us.]]> + diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index 1733a8c99cd..b2b8d4ff2b4 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -1558,6 +1558,7 @@ CREATE TABLE wcf1_user ( reactivationCode INT(10) NOT NULL DEFAULT 0, registrationIpAddress VARCHAR(39) NOT NULL DEFAULT '', avatarID INT(10), + avatarFileID INT(10) DEFAULT NULL, disableAvatar TINYINT(1) NOT NULL DEFAULT 0, disableAvatarReason TEXT, disableAvatarExpires INT(10) NOT NULL DEFAULT 0, @@ -2256,6 +2257,7 @@ ALTER TABLE wcf1_tracked_visit_type ADD FOREIGN KEY (userID) REFERENCES wcf1_use ALTER TABLE wcf1_unfurl_url ADD FOREIGN KEY (imageID) REFERENCES wcf1_unfurl_url_image (imageID) ON DELETE SET NULL; ALTER TABLE wcf1_user ADD FOREIGN KEY (avatarID) REFERENCES wcf1_user_avatar (avatarID) ON DELETE SET NULL; +ALTER TABLE wcf1_user ADD FOREIGN KEY (avatarFileID) REFERENCES wcf1_file (fileID) ON DELETE SET NULL; ALTER TABLE wcf1_user ADD FOREIGN KEY (rankID) REFERENCES wcf1_user_rank (rankID) ON DELETE SET NULL; ALTER TABLE wcf1_user ADD FOREIGN KEY (userOnlineGroupID) REFERENCES wcf1_user_group (groupID) ON DELETE SET NULL;