diff --git a/com.woltlab.wcf/cronjob.xml b/com.woltlab.wcf/cronjob.xml index f60b2607fbb..e1c76816e11 100644 --- a/com.woltlab.wcf/cronjob.xml +++ b/com.woltlab.wcf/cronjob.xml @@ -35,6 +35,12 @@ Löscht verwaiste Dateianhänge 0 2 * * * + + wcf\system\cronjob\FileCleanUpCronjob + Deletes orphaned files + Löscht verwaiste Dateien + 0 3 * * * + wcf\system\cronjob\BackgroundQueueCleanUpCronjob Requeues stuck queue items diff --git a/com.woltlab.wcf/objectType.xml b/com.woltlab.wcf/objectType.xml index 0b0764c64c0..9472239439d 100644 --- a/com.woltlab.wcf/objectType.xml +++ b/com.woltlab.wcf/objectType.xml @@ -1739,6 +1739,11 @@ com.woltlab.wcf.rescueMode com.woltlab.wcf.floodControl + + com.woltlab.wcf.attachment + com.woltlab.wcf.file + wcf\system\file\processor\AttachmentFileProcessor + com.woltlab.wcf.page.controller diff --git a/com.woltlab.wcf/objectTypeDefinition.xml b/com.woltlab.wcf/objectTypeDefinition.xml index 645472e205b..775710634fd 100644 --- a/com.woltlab.wcf/objectTypeDefinition.xml +++ b/com.woltlab.wcf/objectTypeDefinition.xml @@ -225,5 +225,9 @@ com.woltlab.wcf.multifactor wcf\system\user\multifactor\IMultifactorMethod + + com.woltlab.wcf.file + wcf\system\file\processor\IFileProcessor + diff --git a/com.woltlab.wcf/templates/attachments.tpl b/com.woltlab.wcf/templates/attachments.tpl index 4ddb0882fa5..f52a2c5db2d 100644 --- a/com.woltlab.wcf/templates/attachments.tpl +++ b/com.woltlab.wcf/templates/attachments.tpl @@ -50,10 +50,6 @@ {icon name='up-right-and-down-left-from-center'} {#$attachment->width} × {#$attachment->height} -
  • - {icon name='eye'} - {#$attachment->downloads} -
  • diff --git a/com.woltlab.wcf/templates/headIncludeJavaScript.tpl b/com.woltlab.wcf/templates/headIncludeJavaScript.tpl index 1c1abe30e6a..0f387821953 100644 --- a/com.woltlab.wcf/templates/headIncludeJavaScript.tpl +++ b/com.woltlab.wcf/templates/headIncludeJavaScript.tpl @@ -127,7 +127,6 @@ window.addEventListener('pageshow', function(event) { ); -{js application='wcf' file='WCF.Attachment' bundle='WCF.Combined' hasTiny=true} {js application='wcf' file='WCF.ColorPicker' bundle='WCF.Combined' hasTiny=true} {js application='wcf' file='WCF.ImageViewer' bundle='WCF.Combined' hasTiny=true} {js application='wcf' file='WCF.Label' bundle='WCF.Combined' hasTiny=true} diff --git a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl index 24ebf1ac058..74eeb668d4c 100644 --- a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl +++ b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl @@ -1,81 +1,29 @@ -
    -
      getAttachmentList()|count} style="display: none"{/if}> - {foreach from=$attachmentHandler->getAttachmentList() item=$attachment} -
    • - {if $attachment->tinyThumbnailType} - - {else} - {icon size=64 name=$attachment->getIconName()} - {/if} - -
      - - -
        -
      • - {if $attachment->isImage} - {if $attachment->thumbnailType}
      • {/if} -
      • - {else} -
      • - {/if} -
      -
      -
    • +
      + {unsafe:$attachmentHandler->getHtmlElement()} + +
      + {foreach from=$attachmentHandler->getAttachmentList() item=attachment} + {unsafe:$attachment->toHtmlElement()} {/foreach} -
    - +
    +
    -
    +
    {lang}wcf.attachment.upload.limits{/lang}
    + + {event name='fields'} - - - - diff --git a/com.woltlab.wcf/templates/shared_wysiwygAttachmentFormField.tpl b/com.woltlab.wcf/templates/shared_wysiwygAttachmentFormField.tpl index fc0867b6c11..c9ff38ec850 100644 --- a/com.woltlab.wcf/templates/shared_wysiwygAttachmentFormField.tpl +++ b/com.woltlab.wcf/templates/shared_wysiwygAttachmentFormField.tpl @@ -1,72 +1,27 @@ - -
    +
    + {unsafe:$field->getAttachmentHandler()->getHtmlElement()} - +
    + {foreach from=$field->getAttachmentHandler()->getAttachmentList() item=attachment} + {unsafe:$attachment->toHtmlElement()} + {/foreach} +
    + +
    +
    +
    +
    + {lang}wcf.attachment.upload.limits{/lang} +
    +
    - + +
    diff --git a/com.woltlab.wcf/userGroupOption.xml b/com.woltlab.wcf/userGroupOption.xml index e89dd453794..a89a60614c8 100644 --- a/com.woltlab.wcf/userGroupOption.xml +++ b/com.woltlab.wcf/userGroupOption.xml @@ -561,7 +561,7 @@ diff --git a/ts/WoltLabSuite/Core/Ajax/Backend.ts b/ts/WoltLabSuite/Core/Ajax/Backend.ts index 5f0e612ce7d..40b6a9cb6f8 100644 --- a/ts/WoltLabSuite/Core/Ajax/Backend.ts +++ b/ts/WoltLabSuite/Core/Ajax/Backend.ts @@ -24,7 +24,7 @@ const enum RequestType { POST, } -type Payload = FormData | Record; +type Payload = Blob | FormData | Record; class SetupRequest { private readonly url: string; @@ -50,6 +50,7 @@ let ignoreConnectionErrors = false; window.addEventListener("beforeunload", () => (ignoreConnectionErrors = true)); class BackendRequest { + readonly #headers = new Map(); readonly #url: string; readonly #type: RequestType; readonly #payload?: Payload; @@ -77,6 +78,12 @@ class BackendRequest { return this; } + withHeader(key: string, value: string): this { + this.#headers.set(key, value); + + return this; + } + protected allowCaching(): this { this.#allowCaching = true; @@ -117,12 +124,13 @@ class BackendRequest { async #fetch(requestOptions: RequestInit = {}): Promise { registerGlobalRejectionHandler(); + this.#headers.set("X-Requested-With", "XMLHttpRequest"); + this.#headers.set("X-XSRF-TOKEN", getXsrfToken()); + const headers = Object.fromEntries(this.#headers); + const init: RequestInit = extend( { - headers: { - "X-Requested-With": "XMLHttpRequest", - "X-XSRF-TOKEN": getXsrfToken(), - }, + headers, mode: "same-origin", credentials: "same-origin", cache: this.#allowCaching ? "default" : "no-store", @@ -135,7 +143,10 @@ class BackendRequest { init.method = "POST"; if (this.#payload) { - if (this.#payload instanceof FormData) { + if (this.#payload instanceof Blob) { + init.headers!["Content-Type"] = "application/octet-stream"; + init.body = this.#payload; + } else if (this.#payload instanceof FormData) { init.headers!["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"; init.body = this.#payload; } else { @@ -143,6 +154,8 @@ class BackendRequest { init.body = JSON.stringify(this.#payload); } } + } else if (this.#type === RequestType.DELETE) { + init.method = "DELETE"; } else { init.method = "GET"; } diff --git a/ts/WoltLabSuite/Core/Api/Error.ts b/ts/WoltLabSuite/Core/Api/Error.ts index 7ea2b4e81db..663bdf7e0a2 100644 --- a/ts/WoltLabSuite/Core/Api/Error.ts +++ b/ts/WoltLabSuite/Core/Api/Error.ts @@ -27,7 +27,7 @@ export class ApiError { } } -class ValidationError { +export class ValidationError { constructor( public readonly code: string, public readonly message: string, diff --git a/ts/WoltLabSuite/Core/Api/Files/Chunk/Chunk.ts b/ts/WoltLabSuite/Core/Api/Files/Chunk/Chunk.ts new file mode 100644 index 00000000000..683c6ae32a9 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Files/Chunk/Chunk.ts @@ -0,0 +1,38 @@ +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../../Result"; + +export type ResponseIncomplete = { + completed: false; +}; +export type ResponseCompleted = { + completed: true; + generateThumbnails: boolean; + fileID: number; + objectTypeID: number | null; + mimeType: string; + link: string; + data: Record; +}; + +export type Response = ResponseIncomplete | ResponseCompleted; + +export async function uploadChunk( + identifier: string, + sequenceNo: number, + checksum: string, + payload: Blob, +): Promise> { + const url = new URL(`${window.WSC_API_URL}index.php?api/rpc/core/files/upload/${identifier}/chunk/${sequenceNo}`); + + let response: Response; + try { + response = (await prepareRequest(url) + .post(payload) + .withHeader("chunk-checksum-sha256", checksum) + .fetchAsJson()) as Response; + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue(response); +} diff --git a/ts/WoltLabSuite/Core/Api/Files/DeleteFile.ts b/ts/WoltLabSuite/Core/Api/Files/DeleteFile.ts new file mode 100644 index 00000000000..b6a1b7f603a --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Files/DeleteFile.ts @@ -0,0 +1,12 @@ +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; + +export async function deleteFile(fileId: number): Promise> { + try { + await prepareRequest(`${window.WSC_API_URL}index.php?api/rpc/core/files/${fileId}`).delete().fetchAsJson(); + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue([]); +} diff --git a/ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts b/ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts new file mode 100644 index 00000000000..d93bf8e7d49 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts @@ -0,0 +1,21 @@ +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; + +type Thumbnail = { + identifier: string; + link: string; +}; +type Response = Thumbnail[]; + +export async function generateThumbnails(fileID: number): Promise> { + const url = new URL(`${window.WSC_API_URL}index.php?api/rpc/core/files/${fileID}/generatethumbnails`); + + let response: Response; + try { + response = (await prepareRequest(url).post().fetchAsJson()) as Response; + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue(response); +} diff --git a/ts/WoltLabSuite/Core/Api/Files/Upload.ts b/ts/WoltLabSuite/Core/Api/Files/Upload.ts new file mode 100644 index 00000000000..a0f76254f48 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Files/Upload.ts @@ -0,0 +1,34 @@ +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; + +type Response = { + identifier: string; + numberOfChunks: number; +}; + +export async function upload( + filename: string, + fileSize: number, + fileHash: string, + objectType: string, + context: string, +): Promise> { + const url = new URL(`${window.WSC_API_URL}index.php?api/rpc/core/files/upload`); + + const payload = { + filename, + fileSize, + fileHash, + objectType, + context, + }; + + let response: Response; + try { + response = (await prepareRequest(url).post(payload).fetchAsJson()) as Response; + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue(response); +} diff --git a/ts/WoltLabSuite/Core/Bootstrap.ts b/ts/WoltLabSuite/Core/Bootstrap.ts index 218d2e0aad6..3305619a490 100644 --- a/ts/WoltLabSuite/Core/Bootstrap.ts +++ b/ts/WoltLabSuite/Core/Bootstrap.ts @@ -168,6 +168,13 @@ export function setup(options: BoostrapOptions): void { whenFirstSeen("[data-google-maps-geocoding]", () => { void import("./Component/GoogleMaps/Geocoding").then(({ setup }) => setup()); }); + whenFirstSeen("woltlab-core-file", () => { + void import("./Component/File/woltlab-core-file"); + }); + whenFirstSeen("woltlab-core-file-upload", () => { + void import("./Component/File/woltlab-core-file"); + void import("./Component/File/Upload").then(({ setup }) => setup()); + }); // Move the reCAPTCHA widget overlay to the `pageOverlayContainer` // when widget form elements are placed in a dialog. diff --git a/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts b/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts new file mode 100644 index 00000000000..4c70da94e2c --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts @@ -0,0 +1,212 @@ +import { formatFilesize } from "WoltLabSuite/Core/FileUtil"; +import type WoltlabCoreFileElement from "../File/woltlab-core-file"; +import { initFragment, toggleDropdown } from "WoltLabSuite/Core/Ui/Dropdown/Simple"; +import DomChangeListener from "WoltLabSuite/Core/Dom/Change/Listener"; +import { dispatchToCkeditor } from "../Ckeditor/Event"; +import { deleteFile } from "WoltLabSuite/Core/Api/Files/DeleteFile"; +import { getPhrase } from "WoltLabSuite/Core/Language"; + +type FileProcessorData = { + attachmentID: number; +}; + +function fileInitializationCompleted(element: HTMLElement, file: WoltlabCoreFileElement, editor: HTMLElement): void { + const data = file.data; + if (data === undefined) { + throw new Error("No meta data was returned from the server.", { + cause: { + file, + }, + }); + } + + const fileId = file.fileId; + if (fileId === undefined) { + throw new Error("The file id is not set.", { + cause: { + file, + }, + }); + } + + const extraButtons: HTMLButtonElement[] = []; + + let insertButton: HTMLButtonElement; + if (file.isImage()) { + const thumbnail = file.thumbnails.find((thumbnail) => thumbnail.identifier === "tiny"); + if (thumbnail !== undefined) { + file.thumbnail = thumbnail; + } + + const url = file.thumbnails.find((thumbnail) => thumbnail.identifier === "")?.link; + if (url !== undefined) { + insertButton = getInsertButton((data as FileProcessorData).attachmentID, url, editor); + + const insertOriginalImage = getInsertButton((data as FileProcessorData).attachmentID, file.link!, editor); + insertOriginalImage.textContent = getPhrase("wcf.attachment.insertFull"); + extraButtons.push(insertOriginalImage); + } else { + insertButton = getInsertButton((data as FileProcessorData).attachmentID, file.link ? file.link : "", editor); + } + + if (file.link !== undefined && file.filename !== undefined) { + const link = document.createElement("a"); + link.href = file.link!; + link.classList.add("jsImageViewer"); + link.title = file.filename; + link.textContent = file.filename; + + const filename = element.querySelector(".attachment__item__filename")!; + filename.innerHTML = ""; + filename.append(link); + + DomChangeListener.trigger(); + } + } else { + insertButton = getInsertButton( + (data as FileProcessorData).attachmentID, + file.isImage() && file.link ? file.link : "", + editor, + ); + } + + const dropdownMenu = document.createElement("ul"); + dropdownMenu.classList.add("dropdownMenu"); + for (const button of extraButtons) { + const listItem = document.createElement("li"); + listItem.append(button); + dropdownMenu.append(listItem); + } + + if (dropdownMenu.childElementCount !== 0) { + const listItem = document.createElement("li"); + listItem.classList.add("dropdownDivider"); + dropdownMenu.append(listItem); + } + + const listItem = document.createElement("li"); + listItem.append(getDeleteAttachButton(fileId, (data as FileProcessorData).attachmentID, editor, element)); + dropdownMenu.append(listItem); + + const moreOptions = document.createElement("button"); + moreOptions.classList.add("button", "small"); + moreOptions.type = "button"; + moreOptions.setAttribute("aria-label", getPhrase("wcf.global.button.more")); + moreOptions.innerHTML = ''; + + const buttonList = document.createElement("div"); + buttonList.classList.add("attachment__item__buttons"); + insertButton.classList.add("button", "small"); + buttonList.append(insertButton, moreOptions); + + element.append(buttonList); + + initFragment(moreOptions, dropdownMenu); + moreOptions.addEventListener("click", (event) => { + event.stopPropagation(); + + toggleDropdown(moreOptions.id); + }); +} + +function getDeleteAttachButton( + fileId: number, + attachmentId: number, + editor: HTMLElement, + element: HTMLElement, +): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = getPhrase("wcf.global.button.delete"); + + button.addEventListener("click", () => { + void deleteFile(fileId).then((result) => { + result.unwrap(); + + dispatchToCkeditor(editor).removeAttachment({ + attachmentId, + }); + + element.remove(); + }); + }); + + return button; +} + +function getInsertButton(attachmentId: number, url: string, editor: HTMLElement): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = getPhrase("wcf.attachment.insert"); + + button.addEventListener("click", () => { + dispatchToCkeditor(editor).insertAttachment({ + attachmentId, + url, + }); + }); + + return button; +} + +function fileInitializationFailed(element: HTMLElement, file: WoltlabCoreFileElement, reason: unknown): void { + if (reason instanceof Error) { + throw reason; + } + + if (file.validationError === undefined) { + return; + } + + let errorMessage: string; + switch (file.validationError.param) { + case "preflight": + errorMessage = getPhrase(`wcf.upload.error.${file.validationError.code}`); + break; + + default: + errorMessage = "Unrecognized error type: " + JSON.stringify(file.validationError); + break; + } + + markElementAsErroneous(element, errorMessage); +} + +function markElementAsErroneous(element: HTMLElement, errorMessage: string): void { + element.classList.add("attachment__item--error"); + + const errorElement = document.createElement("div"); + errorElement.classList.add("attachemnt__item__errorMessage"); + errorElement.textContent = errorMessage; + + element.append(errorElement); +} + +export function createAttachmentFromFile(file: WoltlabCoreFileElement, editor: HTMLElement) { + const element = document.createElement("li"); + element.classList.add("attachment__item"); + + const fileWrapper = document.createElement("div"); + fileWrapper.classList.add("attachment__item__file"); + fileWrapper.append(file); + + const filename = document.createElement("div"); + filename.classList.add("attachment__item__filename"); + filename.textContent = file.filename || file.dataset.filename!; + + const fileSize = document.createElement("div"); + fileSize.classList.add("attachment__item__fileSize"); + fileSize.textContent = formatFilesize(file.fileSize || parseInt(file.dataset.fileSize!)); + + element.append(fileWrapper, filename, fileSize); + + void file.ready + .then(() => { + fileInitializationCompleted(element, file, editor); + }) + .catch((reason) => { + fileInitializationFailed(element, file, reason); + }); + + return element; +} diff --git a/ts/WoltLabSuite/Core/Component/Attachment/List.ts b/ts/WoltLabSuite/Core/Component/Attachment/List.ts new file mode 100644 index 00000000000..5e4c801e39c --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Attachment/List.ts @@ -0,0 +1,60 @@ +import WoltlabCoreFileElement from "../File/woltlab-core-file"; +import { CkeditorDropEvent } from "../File/Upload"; +import { createAttachmentFromFile } from "./Entry"; +import { listenToCkeditor } from "../Ckeditor/Event"; + +// This import has the side effect of registering the `` +// element. Do not remove! +import "../File/woltlab-core-file"; + +function fileToAttachment(fileList: HTMLElement, file: WoltlabCoreFileElement, editor: HTMLElement): void { + fileList.append(createAttachmentFromFile(file, editor)); +} + +export function setup(editorId: string): void { + const container = document.getElementById(`attachments_${editorId}`); + if (container === null) { + throw new Error(`The attachments container for '${editorId}' does not exist.`); + } + + const editor = document.getElementById(editorId); + if (editor === null) { + throw new Error(`The editor element for '${editorId}' does not exist.`); + } + + const uploadButton = container.querySelector("woltlab-core-file-upload"); + if (uploadButton === null) { + throw new Error("Expected the container to contain an upload button", { + cause: { + container, + }, + }); + } + + let fileList = container.querySelector(".attachment__list"); + if (fileList === null) { + fileList = document.createElement("ol"); + fileList.classList.add("attachment__list"); + uploadButton.insertAdjacentElement("afterend", fileList); + } + + uploadButton.addEventListener("uploadStart", (event: CustomEvent) => { + fileToAttachment(fileList!, event.detail, editor); + }); + + listenToCkeditor(editor).uploadAttachment((payload) => { + const event = new CustomEvent("ckeditorDrop", { + detail: payload, + }); + uploadButton.dispatchEvent(event); + }); + + const existingFiles = container.querySelector(".attachment__list__existingFiles"); + if (existingFiles !== null) { + existingFiles.querySelectorAll("woltlab-core-file").forEach((file) => { + fileToAttachment(fileList!, file, editor); + }); + + existingFiles.remove(); + } +} diff --git a/ts/WoltLabSuite/Core/Component/Ckeditor/Attachment.ts b/ts/WoltLabSuite/Core/Component/Ckeditor/Attachment.ts index faee715f870..26af63fc5fe 100644 --- a/ts/WoltLabSuite/Core/Component/Ckeditor/Attachment.ts +++ b/ts/WoltLabSuite/Core/Component/Ckeditor/Attachment.ts @@ -19,7 +19,7 @@ type UploadResult = { }; }; -type AttachmentData = { +export type AttachmentData = { attachmentId: number; url: string; }; @@ -36,14 +36,16 @@ function uploadAttachment(element: HTMLElement, file: File, abortController?: Ab dispatchToCkeditor(element).uploadAttachment(payload); return new Promise((resolve) => { - void payload.promise!.then(({ attachmentId, url }) => { - resolve({ - "data-attachment-id": attachmentId.toString(), - urls: { - default: url, - }, - }); - }); + void payload + .promise!.then(({ attachmentId, url }) => { + resolve({ + "data-attachment-id": attachmentId.toString(), + urls: { + default: url, + }, + }); + }) + .catch(() => {}); }); } diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts new file mode 100644 index 00000000000..eb29f2ac226 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -0,0 +1,302 @@ +import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; +import { upload as filesUpload } from "WoltLabSuite/Core/Api/Files/Upload"; +import WoltlabCoreFileElement from "./woltlab-core-file"; +import { + ResponseCompleted, + Response as UploadChunkResponse, + uploadChunk, +} from "WoltLabSuite/Core/Api/Files/Chunk/Chunk"; +import { generateThumbnails } from "WoltLabSuite/Core/Api/Files/GenerateThumbnails"; +import ImageResizer from "WoltLabSuite/Core/Image/Resizer"; +import { AttachmentData } from "../Ckeditor/Attachment"; +import { innerError } from "WoltLabSuite/Core/Dom/Util"; +import { getPhrase } from "WoltLabSuite/Core/Language"; + +export type CkeditorDropEvent = { + file: File; + promise?: Promise; +}; + +export type ThumbnailsGenerated = { + data: GenerateThumbnailsResponse; + fileID: number; +}; + +type ThumbnailData = { + identifier: string; + link: string; +}; + +type GenerateThumbnailsResponse = ThumbnailData[]; + +type ResizeConfiguration = { + maxWidth: number; + maxHeight: number; + fileType: "image/jpeg" | "image/webp" | "keep"; + quality: number; +}; + +async function upload(element: WoltlabCoreFileUploadElement, file: File): Promise { + const objectType = element.dataset.objectType!; + + const fileHash = await getSha256Hash(await file.arrayBuffer()); + + const fileElement = document.createElement("woltlab-core-file"); + fileElement.dataset.filename = file.name; + fileElement.dataset.fileSize = file.size.toString(); + + const event = new CustomEvent("uploadStart", { detail: fileElement }); + element.dispatchEvent(event); + + const response = await filesUpload(file.name, file.size, fileHash, objectType, element.dataset.context || ""); + if (!response.ok) { + const validationError = response.error.getValidationError(); + if (validationError === undefined) { + fileElement.uploadFailed(undefined); + + throw response.error; + } + + fileElement.uploadFailed(validationError); + return undefined; + } + + const { identifier, numberOfChunks } = response.value; + + const chunkSize = Math.ceil(file.size / numberOfChunks); + + // TODO: Can we somehow report any meaningful upload progress? + + for (let i = 0; i < numberOfChunks; i++) { + const start = i * chunkSize; + const end = start + chunkSize; + const chunk = file.slice(start, end); + + const checksum = await getSha256Hash(await chunk.arrayBuffer()); + + const response = await uploadChunk(identifier, i, checksum, chunk); + if (!response.ok) { + fileElement.uploadFailed(undefined); + + throw response.error; + } + + await chunkUploadCompleted(fileElement, response.value); + + if (response.value.completed) { + return response.value; + } + } +} + +async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, result: UploadChunkResponse): Promise { + if (!result.completed) { + return; + } + + fileElement.uploadCompleted(result.fileID, result.mimeType, result.link, result.data, result.generateThumbnails); + + if (result.generateThumbnails) { + const response = await generateThumbnails(result.fileID); + fileElement.setThumbnails(response.unwrap()); + } +} + +async function getSha256Hash(data: BufferSource): Promise { + const buffer = await window.crypto.subtle.digest("SHA-256", data); + + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function clearPreviousErrors(element: WoltlabCoreFileUploadElement): void { + element.parentElement?.querySelectorAll(".innerError").forEach((x) => x.remove()); +} + +async function resizeImage(element: WoltlabCoreFileUploadElement, file: File): Promise { + switch (file.type) { + case "image/jpeg": + case "image/png": + case "image/webp": + // Potential candidate for a resize operation. + break; + + default: + // Not an image or an unsupported file type. + return file; + } + + const timeout = new Promise((resolve) => { + window.setTimeout(() => resolve(file), 10_000); + }); + + const resizeConfiguration = JSON.parse(element.dataset.resizeConfiguration!) as ResizeConfiguration; + + const resizer = new ImageResizer(); + const { image, exif } = await resizer.loadFile(file); + + const maxHeight = resizeConfiguration.maxHeight; + let maxWidth = resizeConfiguration.maxWidth; + if (window.devicePixelRatio >= 2) { + const actualWidth = window.screen.width * window.devicePixelRatio; + const actualHeight = window.screen.height * window.devicePixelRatio; + + // If the dimensions are equal then this is a screenshot from a HiDPI + // device, thus we downscale this to the “natural” dimensions. + if (actualWidth === image.width && actualHeight === image.height) { + maxWidth = Math.min(maxWidth, window.screen.width); + } + } + + const canvas = await resizer.resize(image, maxWidth, maxHeight, resizeConfiguration.quality, true, timeout); + if (canvas === undefined) { + // The resize operation failed, timed out or there was no need to perform + // any scaling whatsoever. + return file; + } + + let fileType: string = resizeConfiguration.fileType; + if (fileType === "image/jpeg" || fileType === "image/webp") { + fileType = "image/webp"; + } else { + fileType = file.type; + } + + const resizedFile = await resizer.saveFile( + { + exif, + image: canvas, + }, + file.name, + fileType, + resizeConfiguration.quality, + ); + + return resizedFile; +} + +function validateFileLimit(element: WoltlabCoreFileUploadElement): boolean { + const maximumCount = element.maximumCount; + if (maximumCount === -1) { + return true; + } else if (maximumCount > 0) { + return true; + } + + const files = Array.from(element.parentElement!.querySelectorAll("woltlab-core-file")); + const numberOfUploadedFiles = files.filter((file) => !file.isFailedUpload()).length; + if (numberOfUploadedFiles + 1 <= maximumCount) { + return true; + } + + innerError(element, getPhrase("wcf.upload.error.maximumCountReached", { maximumCount })); + + return false; +} + +function validateFileSize(element: WoltlabCoreFileUploadElement, file: File): boolean { + let isImage = false; + switch (file.type) { + case "image/gif": + case "image/jpeg": + case "image/png": + case "image/webp": + isImage = true; + break; + } + + // Skip the file size validation for images, they can potentially be resized. + if (isImage) { + return true; + } + + const maximumSize = element.maximumSize; + if (maximumSize === -1) { + return true; + } + + if (file.size <= maximumSize) { + return true; + } + + innerError(element, getPhrase("wcf.upload.error.fileSizeTooLarge", { filename: file.name })); + + return false; +} + +function validateFileExtension(element: WoltlabCoreFileUploadElement, file: File): boolean { + const fileExtensions = (element.dataset.fileExtensions || "*").split(","); + for (const fileExtension of fileExtensions) { + if (fileExtension === "*") { + return true; + } else if (file.name.endsWith(fileExtension)) { + return true; + } + } + + innerError(element, getPhrase("wcf.upload.error.fileExtensionNotPermitted", { filename: file.name })); + + return false; +} + +export function setup(): void { + wheneverFirstSeen("woltlab-core-file-upload", (element: WoltlabCoreFileUploadElement) => { + element.addEventListener("upload", (event: CustomEvent) => { + const file = event.detail; + + clearPreviousErrors(element); + + if (!validateFileLimit(element)) { + return; + } else if (!validateFileExtension(element, file)) { + return; + } else if (!validateFileSize(element, file)) { + return; + } + + void resizeImage(element, file).then((resizedFile) => { + void upload(element, resizedFile); + }); + }); + + element.addEventListener("ckeditorDrop", (event: CustomEvent) => { + const { file } = event.detail; + + let promiseResolve: (data: AttachmentData) => void; + let promiseReject: () => void; + event.detail.promise = new Promise((resolve, reject) => { + promiseResolve = resolve; + promiseReject = reject; + }); + + clearPreviousErrors(element); + + if (!validateFileExtension(element, file)) { + promiseReject!(); + + return; + } + + void resizeImage(element, file).then(async (resizeFile) => { + try { + const data = await upload(element, resizeFile); + if (data === undefined || typeof data.data.attachmentID !== "number") { + promiseReject(); + } else { + const attachmentData: AttachmentData = { + attachmentId: data.data.attachmentID, + url: data.link, + }; + + promiseResolve(attachmentData); + } + } catch (e) { + promiseReject(); + + throw e; + } + }); + }); + }); +} diff --git a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts new file mode 100644 index 00000000000..2aedd992436 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts @@ -0,0 +1,345 @@ +import type { ValidationError } from "WoltLabSuite/Core/Api/Error"; +import { getExtensionByMimeType, getIconNameByFilename } from "WoltLabSuite/Core/FileUtil"; + +const enum State { + Initial, + Uploading, + GeneratingThumbnails, + Ready, + Failed, +} + +export type ThumbnailData = { + identifier: string; + link: string; +}; + +export class Thumbnail { + readonly #identifier: string; + readonly #link: string; + + constructor(identifier: string, link: string) { + this.#identifier = identifier; + this.#link = link; + } + + get identifier(): string { + return this.#identifier; + } + + get link(): string { + return this.#link; + } +} + +export class WoltlabCoreFileElement extends HTMLElement { + #data: Record | undefined = undefined; + #filename: string = ""; + #fileId: number | undefined = undefined; + #fileSize: number | undefined = undefined; + #link: string | undefined = undefined; + #mimeType: string | undefined = undefined; + #state: State = State.Initial; + #validationError: ValidationError | undefined = undefined; + readonly #thumbnails: Thumbnail[] = []; + + #readyReject!: () => void; + #readyResolve!: () => void; + readonly #readyPromise: Promise; + + constructor() { + super(); + + this.#readyPromise = new Promise((resolve, reject) => { + this.#readyResolve = resolve; + this.#readyReject = reject; + }); + } + + connectedCallback() { + let wasAlreadyReady = false; + if (this.#state === State.Initial) { + wasAlreadyReady = this.#initializeState(); + } + + this.#rebuildElement(); + + if (wasAlreadyReady) { + this.#readyResolve(); + } + } + + #initializeState(): boolean { + // Files that exist at page load have a valid file id, otherwise a new + // file element can only be the result of an upload attempt. + if (this.#fileId === undefined) { + this.#filename = this.dataset.filename || "unknown.bin"; + delete this.dataset.filename; + + this.#fileSize = parseInt(this.dataset.fileSize || "0"); + delete this.dataset.fileSize; + + this.#mimeType = this.dataset.mimeType || "application/octet-stream"; + delete this.dataset.mimeType; + + const fileId = parseInt(this.getAttribute("file-id") || "0"); + if (fileId) { + this.#fileId = fileId; + } else { + this.#state = State.Uploading; + + return false; + } + } + + // Initialize the list of thumbnails from the data attribute. + if (this.dataset.thumbnails) { + const thumbnails = JSON.parse(this.dataset.thumbnails) as ThumbnailData[]; + for (const thumbnail of thumbnails) { + this.#thumbnails.push(new Thumbnail(thumbnail.identifier, thumbnail.link)); + } + + delete this.dataset.thumbnails; + } + + if (this.dataset.metaData) { + this.#data = JSON.parse(this.dataset.metaData); + delete this.dataset.metaData; + } + + this.#link = this.dataset.link!; + delete this.dataset.link; + + this.#state = State.Ready; + + return true; + } + + #rebuildElement(): void { + switch (this.#state) { + case State.Uploading: + this.#replaceWithIcon("spinner"); + break; + + case State.GeneratingThumbnails: + this.#replaceWithIcon("spinner"); + break; + + case State.Ready: + if (this.previewUrl) { + this.#replaceWithImage(this.previewUrl); + } else { + const iconName = this.iconName || "file"; + this.#replaceWithIcon(iconName); + } + break; + + case State.Failed: + this.#replaceWithIcon("triangle-exclamation"); + break; + + default: + throw new Error("Unreachable", { + cause: { + state: this.#state, + }, + }); + } + } + + #replaceWithImage(src: string): void { + let img = this.querySelector("img"); + + if (img === null) { + this.innerHTML = ""; + + img = document.createElement("img"); + img.alt = ""; + this.append(img); + } + + img.src = src; + + if (this.unbounded) { + img.removeAttribute("height"); + img.removeAttribute("width"); + } else { + img.height = 64; + img.width = 64; + } + } + + #replaceWithIcon(iconName: string): FaIcon { + let icon = this.querySelector("fa-icon"); + if (icon === null) { + this.innerHTML = ""; + + icon = document.createElement("fa-icon"); + icon.size = 64; + icon.setIcon(iconName); + this.append(icon); + } else { + icon.setIcon(iconName); + } + + return icon; + } + + get fileId(): number | undefined { + return this.#fileId; + } + + get iconName(): string | undefined { + if (this.mimeType === undefined) { + return undefined; + } + + const fileExtension = getExtensionByMimeType(this.mimeType); + if (fileExtension === "") { + return undefined; + } + + const iconName = getIconNameByFilename(fileExtension); + if (iconName === "") { + return undefined; + } + + return `file-${iconName}`; + } + + get previewUrl(): string | undefined { + return this.dataset.previewUrl; + } + + get unbounded(): boolean { + return this.getAttribute("dimensions") === "unbounded"; + } + + set unbounded(unbounded: boolean) { + if (unbounded) { + this.setAttribute("dimensions", "unbounded"); + } else { + this.removeAttribute("dimensions"); + } + + this.#rebuildElement(); + } + + get filename(): string | undefined { + return this.#filename; + } + + get fileSize(): number | undefined { + return this.#fileSize; + } + + get mimeType(): string | undefined { + return this.#mimeType; + } + + get data(): Record | undefined { + return this.#data; + } + + get link(): string | undefined { + return this.#link; + } + + isImage(): boolean { + if (this.mimeType === undefined) { + return false; + } + + switch (this.mimeType) { + case "image/gif": + case "image/jpeg": + case "image/png": + case "image/webp": + return true; + + default: + return false; + } + } + + uploadFailed(validationError: ValidationError | undefined): void { + if (this.#state !== State.Uploading) { + return; + } + + this.#state = State.Failed; + this.#validationError = validationError; + this.#rebuildElement(); + + this.#readyReject(); + } + + uploadCompleted( + fileId: number, + mimeType: string, + link: string, + data: Record, + hasThumbnails: boolean, + ): void { + if (this.#state === State.Uploading) { + this.#data = data; + this.#fileId = fileId; + this.#link = link; + this.#mimeType = mimeType; + this.setAttribute("file-id", fileId.toString()); + + if (hasThumbnails) { + this.#state = State.GeneratingThumbnails; + this.#rebuildElement(); + } else { + this.#state = State.Ready; + this.#rebuildElement(); + + this.#readyResolve(); + } + } + } + + setThumbnails(thumbnails: ThumbnailData[]): void { + if (this.#state !== State.GeneratingThumbnails) { + return; + } + + for (const thumbnail of thumbnails) { + this.#thumbnails.push(new Thumbnail(thumbnail.identifier, thumbnail.link)); + } + + this.#state = State.Ready; + this.#rebuildElement(); + + this.#readyResolve(); + } + + isFailedUpload(): boolean { + return this.#state === State.Failed; + } + + set thumbnail(thumbnail: Thumbnail) { + if (!this.#thumbnails.includes(thumbnail)) { + return; + } + + this.#replaceWithImage(thumbnail.link); + } + + get thumbnails(): Thumbnail[] { + return [...this.#thumbnails]; + } + + get ready(): Promise { + return this.#readyPromise; + } + + get validationError(): ValidationError | undefined { + return this.#validationError; + } +} + +export default WoltlabCoreFileElement; + +window.customElements.define("woltlab-core-file", WoltlabCoreFileElement); diff --git a/ts/WoltLabSuite/WebComponent/Template.grammar.jison b/ts/WoltLabSuite/WebComponent/Template.grammar.jison index 803a05b8627..461c748f019 100644 --- a/ts/WoltLabSuite/WebComponent/Template.grammar.jison +++ b/ts/WoltLabSuite/WebComponent/Template.grammar.jison @@ -131,7 +131,7 @@ COMMAND: $$ = "h.selectPlural({" var needsComma = false; for (var key in $2) { - if (objOwns($2, key)) { + if (Object.hasOwn($2, key)) { $$ += (needsComma ? ',' : '') + key + ': ' + $2[key]; needsComma = true; } diff --git a/ts/WoltLabSuite/WebComponent/Template.grammar.js b/ts/WoltLabSuite/WebComponent/Template.grammar.js index 1ff6245aa35..af087f51777 100644 --- a/ts/WoltLabSuite/WebComponent/Template.grammar.js +++ b/ts/WoltLabSuite/WebComponent/Template.grammar.js @@ -159,7 +159,7 @@ case 12: this.$ = "h.selectPlural({" var needsComma = false; for (var key in $$[$0-1]) { - if (objOwns($$[$0-1], key)) { + if (Object.hasOwn($$[$0-1], key)) { this.$ += (needsComma ? ',' : '') + key + ': ' + $$[$0-1][key]; needsComma = true; } diff --git a/ts/WoltLabSuite/WebComponent/index.ts b/ts/WoltLabSuite/WebComponent/index.ts index 83df211df56..3abdba8e2f8 100644 --- a/ts/WoltLabSuite/WebComponent/index.ts +++ b/ts/WoltLabSuite/WebComponent/index.ts @@ -12,6 +12,7 @@ import "./fa-metadata.js"; import "./fa-brand.ts"; import "./fa-icon.ts"; import "./woltlab-core-date-time.ts"; +import "./woltlab-core-file-upload.ts" import "./woltlab-core-loading-indicator.ts"; import "./woltlab-core-notice.ts"; import "./woltlab-core-pagination.ts"; diff --git a/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts b/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts new file mode 100644 index 00000000000..7aadf87536a --- /dev/null +++ b/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts @@ -0,0 +1,85 @@ +{ + class WoltlabCoreFileUploadElement extends HTMLElement { + readonly #element: HTMLInputElement; + + constructor() { + super(); + + this.#element = document.createElement("input"); + this.#element.type = "file"; + + this.#element.addEventListener("change", () => { + const { files } = this.#element; + if (files === null || files.length === 0) { + return; + } + + for (const file of files) { + const event = new CustomEvent("shouldUpload", { + cancelable: true, + detail: file, + }); + this.dispatchEvent(event); + + if (event.defaultPrevented) { + continue; + } + + const uploadEvent = new CustomEvent("upload", { + detail: file, + }); + this.dispatchEvent(uploadEvent); + } + + // Reset the selected file. + this.#element.value = ""; + }); + } + + connectedCallback() { + const allowedFileExtensions = this.dataset.fileExtensions || ""; + if (allowedFileExtensions !== "") { + this.#element.accept = allowedFileExtensions; + } + + const maximumCount = this.maximumCount; + if (maximumCount > 1 || maximumCount === -1) { + this.#element.multiple = true; + } + + const shadow = this.attachShadow({ mode: "open" }); + shadow.append(this.#element); + + const style = document.createElement("style"); + style.textContent = ` + :host { + position: relative; + } + + input { + inset: 0; + position: absolute; + visibility: hidden; + } + `; + } + + get maximumCount(): number { + return parseInt(this.dataset.maximumCount || "1"); + } + + get maximumSize(): number { + return parseInt(this.dataset.maximumSize || "-1"); + } + + get disabled(): boolean { + return this.#element.disabled; + } + + set disabled(disabled: boolean) { + this.#element.disabled = Boolean(disabled); + } + } + + window.customElements.define("woltlab-core-file-upload", WoltlabCoreFileUploadElement); +} diff --git a/ts/global.d.ts b/ts/global.d.ts index 29daeeab06e..a4b7648c0b9 100644 --- a/ts/global.d.ts +++ b/ts/global.d.ts @@ -10,6 +10,7 @@ import { Reaction } from "WoltLabSuite/Core/Ui/Reaction/Data"; import type WoltlabCoreDialogElement from "WoltLabSuite/Core/Element/woltlab-core-dialog"; import type WoltlabCoreDialogControlElement from "WoltLabSuite/Core/Element/woltlab-core-dialog-control"; import type WoltlabCoreGoogleMapsElement from "WoltLabSuite/Core/Component/GoogleMaps/woltlab-core-google-maps"; +import type WoltlabCoreFileElement from "WoltLabSuite/Core/Component/File/woltlab-core-file"; type Codepoint = string; type HasRegularVariant = boolean; @@ -91,6 +92,13 @@ declare global { set date(date: Date); } + interface WoltlabCoreFileUploadElement extends HTMLElement { + get disabled(): boolean; + set disabled(disabled: boolean); + get maximumCount(): number; + get maximumSize(): number; + } + interface WoltlabCoreLoadingIndicatorElement extends HTMLElement { get size(): LoadingIndicatorIconSize; set size(size: LoadingIndicatorIconSize); @@ -121,6 +129,8 @@ declare global { "woltlab-core-dialog": WoltlabCoreDialogElement; "woltlab-core-dialog-control": WoltlabCoreDialogControlElement; "woltlab-core-date-time": WoltlabCoreDateTime; + "woltlab-core-file": WoltlabCoreFileElement; + "woltlab-core-file-upload": WoltlabCoreFileUploadElement; "woltlab-core-loading-indicator": WoltlabCoreLoadingIndicatorElement; "woltlab-core-pagination": WoltlabCorePaginationElement; "woltlab-core-google-maps": WoltlabCoreGoogleMapsElement; diff --git a/wcfsetup/install/files/_data/.htaccess b/wcfsetup/install/files/_data/.htaccess new file mode 100644 index 00000000000..5a928f6da25 --- /dev/null +++ b/wcfsetup/install/files/_data/.htaccess @@ -0,0 +1 @@ +Options -Indexes diff --git a/wcfsetup/install/files/_data/private/.htaccess b/wcfsetup/install/files/_data/private/.htaccess new file mode 100644 index 00000000000..b66e8088296 --- /dev/null +++ b/wcfsetup/install/files/_data/private/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/wcfsetup/install/files/_data/public/.htaccess b/wcfsetup/install/files/_data/public/.htaccess new file mode 100644 index 00000000000..a8364c85a0f --- /dev/null +++ b/wcfsetup/install/files/_data/public/.htaccess @@ -0,0 +1 @@ +Require all granted diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.1.php index 26e50c3d5fb..988e920e3a6 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.1.php @@ -8,13 +8,21 @@ * @license GNU Lesser General Public License */ +use wcf\system\database\table\column\BigintDatabaseTableColumn; +use wcf\system\database\table\column\CharDatabaseTableColumn; use wcf\system\database\table\column\DefaultFalseBooleanDatabaseTableColumn; +use wcf\system\database\table\column\IntDatabaseTableColumn; use wcf\system\database\table\column\NotNullInt10DatabaseTableColumn; use wcf\system\database\table\column\NotNullVarchar191DatabaseTableColumn; +use wcf\system\database\table\column\NotNullVarchar255DatabaseTableColumn; +use wcf\system\database\table\column\ObjectIdDatabaseTableColumn; +use wcf\system\database\table\column\TextDatabaseTableColumn; +use wcf\system\database\table\column\VarbinaryDatabaseTableColumn; use wcf\system\database\table\column\VarcharDatabaseTableColumn; use wcf\system\database\table\DatabaseTable; use wcf\system\database\table\index\DatabaseTableForeignKey; use wcf\system\database\table\index\DatabaseTableIndex; +use wcf\system\database\table\index\DatabaseTablePrimaryIndex; use wcf\system\database\table\PartialDatabaseTable; return [ @@ -52,5 +60,104 @@ ->indices([ DatabaseTableIndex::create('identifier') ->columns(['identifier']), + ]), + DatabaseTable::create('wcf1_file') + ->columns([ + ObjectIdDatabaseTableColumn::create('fileID'), + NotNullVarchar255DatabaseTableColumn::create('filename'), + BigintDatabaseTableColumn::create('fileSize') + ->notNull(), + CharDatabaseTableColumn::create('fileHash') + ->length(64) + ->notNull(), + VarcharDatabaseTableColumn::create('fileExtension') + ->length(10) + ->notNull(), + CharDatabaseTableColumn::create('secret') + ->length(32) + ->notNull(), + IntDatabaseTableColumn::create('objectTypeID'), + NotNullVarchar255DatabaseTableColumn::create('mimeType'), + IntDatabaseTableColumn::create('width'), + IntDatabaseTableColumn::create('height'), + CharDatabaseTableColumn::create('fileHashWebp') + ->length(64), + ]) + ->indices([ + DatabaseTablePrimaryIndex::create() + ->columns(['fileID']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['objectTypeID']) + ->referencedTable('wcf1_object_type') + ->referencedColumns(['objectTypeID']) + ->onDelete('SET NULL'), + ]), + DatabaseTable::create('wcf1_file_temporary') + ->columns([ + CharDatabaseTableColumn::create('identifier') + ->length(40) + ->notNull(), + NotNullInt10DatabaseTableColumn::create('time'), + NotNullVarchar255DatabaseTableColumn::create('filename'), + BigintDatabaseTableColumn::create('fileSize') + ->notNull(), + CharDatabaseTableColumn::create('fileHash') + ->length(64) + ->notNull(), + VarcharDatabaseTableColumn::create('fileExtension') + ->length(10) + ->notNull(), + CharDatabaseTableColumn::create('secret') + ->length(32) + ->notNull(), + IntDatabaseTableColumn::create('objectTypeID'), + TextDatabaseTableColumn::create('context'), + VarbinaryDatabaseTableColumn::create('chunks') + ->length(255) + ->notNull(), + ]) + ->indices([ + DatabaseTablePrimaryIndex::create() + ->columns(['identifier']), ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['objectTypeID']) + ->referencedTable('wcf1_object_type') + ->referencedColumns(['objectTypeID']) + ->onDelete('SET NULL'), + ]), + DatabaseTable::create('wcf1_file_thumbnail') + ->columns([ + ObjectIdDatabaseTableColumn::create('thumbnailID'), + NotNullInt10DatabaseTableColumn::create('fileID'), + VarcharDatabaseTableColumn::create('identifier') + ->length(50) + ->notNull(), + CharDatabaseTableColumn::create('fileHash') + ->length(64) + ->notNull(), + VarcharDatabaseTableColumn::create('fileExtension') + ->length(10) + ->notNull(), + IntDatabaseTableColumn::create('width') + ->notNull(), + IntDatabaseTableColumn::create('height') + ->notNull(), + CharDatabaseTableColumn::create('formatChecksum') + ->length(12), + ]) + ->indices([ + DatabaseTablePrimaryIndex::create() + ->columns(['thumbnailID']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['fileID']) + ->referencedTable('wcf1_file') + ->referencedColumns(['fileID']) + ->onDelete('CASCADE'), + ]), ]; diff --git a/wcfsetup/install/files/acp/templates/attachmentList.tpl b/wcfsetup/install/files/acp/templates/attachmentList.tpl index 03c1c44f709..df3f858bc35 100644 --- a/wcfsetup/install/files/acp/templates/attachmentList.tpl +++ b/wcfsetup/install/files/acp/templates/attachmentList.tpl @@ -130,7 +130,7 @@ {@$attachment->uploadTime|time} {@$attachment->filesize|filesize} - {#$attachment->downloads} + {if $attachment->downloads}{#$attachment->downloads}{/if} {if $attachment->lastDownloadTime}{@$attachment->lastDownloadTime|time}{/if} {event name='columns'} diff --git a/wcfsetup/install/files/acp/templates/header.tpl b/wcfsetup/install/files/acp/templates/header.tpl index 0b3073e69d1..1429f386838 100644 --- a/wcfsetup/install/files/acp/templates/header.tpl +++ b/wcfsetup/install/files/acp/templates/header.tpl @@ -129,7 +129,6 @@ {if $__wcf->user->userID}'{@$__wcf->user->username|encodeJS}'{else}''{/if} ); - {js application='wcf' file='WCF.Attachment' bundle='WCF.Combined'} {js application='wcf' file='WCF.Message' bundle='WCF.Combined'} {js application='wcf' file='WCF.Label' bundle='WCF.Combined'}