From 8852c426241ec5765350bce46a5f22346cc63ee6 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 25 Dec 2023 13:18:13 +0100 Subject: [PATCH 01/97] Add the data structure for file chunks --- wcfsetup/setup/db/install.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index 4d547c3baf7..a32722b7fc5 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -593,6 +593,24 @@ CREATE TABLE wcf1_event_listener ( UNIQUE KEY listenerName (listenerName, packageID) ); +DROP TABLE IF EXISTS wcf1_file_temporary; +CREATE TABLE wcf1_file_temporary ( + uuidv4 CHAR(36) NOT NULL PRIMARY KEY, + prefix CHAR(40) NOT NULL, + lastModified INT NOT NULL, + filename VARCHAR(255) NOT NULL, + filesize BIGINT NOT NULL, + chunks SMALLINT NOT NULL +); + +DROP TABLE IF EXISTS wcf1_file_chunk; +CREATE TABLE wcf1_file_chunk ( + uuidv4 CHAR(36) NOT NULL, + sequenceNo SMALLINT NOT NULL, + + PRIMARY KEY chunk (uuidv4, sequenceNo) +); + /* As the flood control table can be a high traffic table and as it is periodically emptied, there is no foreign key on the `objectTypeID` to speed up insertions. */ DROP TABLE IF EXISTS wcf1_flood_control; From bc1f7d7db9330da0320415566d509644d66ebdd6 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sun, 26 Nov 2023 15:36:59 +0100 Subject: [PATCH 02/97] Add the basic directory structure for the data storage --- wcfsetup/install/files/_data/.htaccess | 1 + wcfsetup/install/files/_data/private/.htaccess | 1 + wcfsetup/install/files/_data/public/.htaccess | 1 + 3 files changed, 3 insertions(+) create mode 100644 wcfsetup/install/files/_data/.htaccess create mode 100644 wcfsetup/install/files/_data/private/.htaccess create mode 100644 wcfsetup/install/files/_data/public/.htaccess 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 From fbb135f09a1affa805c8318c8badee347be1539a Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 25 Dec 2023 19:51:18 +0100 Subject: [PATCH 03/97] Add support for blob requests --- ts/WoltLabSuite/Core/Ajax/Backend.ts | 7 +++++-- .../install/files/js/WoltLabSuite/Core/Ajax/Backend.js | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ts/WoltLabSuite/Core/Ajax/Backend.ts b/ts/WoltLabSuite/Core/Ajax/Backend.ts index 5f0e612ce7d..ad3fa8cdd97 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; @@ -135,7 +135,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 { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js index 38b61308d51..46f8132f269 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js @@ -95,7 +95,11 @@ define(["require", "exports", "tslib", "./Status", "./Error", "../Core"], functi if (this.#type === 2 /* RequestType.POST */) { 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; } From aa91e8bffaf82e86d6ff4d4d677b6fd4a4aadbe9 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 25 Dec 2023 19:51:53 +0100 Subject: [PATCH 04/97] Add PoC implementation for chunked uploads --- .../shared_messageFormAttachments.tpl | 77 ++----------------- ts/WoltLabSuite/Core/Bootstrap.ts | 3 + ts/WoltLabSuite/Core/Component/File/Upload.ts | 22 ++++++ ts/WoltLabSuite/WebComponent/index.ts | 1 + .../WebComponent/woltlab-core-file-upload.ts | 56 ++++++++++++++ ts/global.d.ts | 3 + .../files/js/WoltLabSuite/Core/Bootstrap.js | 3 + .../Core/Component/File/Upload.js | 22 ++++++ .../files/js/WoltLabSuite/WebComponent.min.js | 32 +++++--- .../lib/action/FileUploadAction.class.php | 17 ++++ 10 files changed, 154 insertions(+), 82 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Component/File/Upload.ts create mode 100644 ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js create mode 100644 wcfsetup/install/files/lib/action/FileUploadAction.class.php diff --git a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl index 24ebf1ac058..b68bfdb2158 100644 --- a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl +++ b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl @@ -1,33 +1,9 @@ -
-
    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} -
    -
    -
  • - {/foreach} -
- +
+
@@ -38,44 +14,3 @@ {event name='fields'}
- - - - diff --git a/ts/WoltLabSuite/Core/Bootstrap.ts b/ts/WoltLabSuite/Core/Bootstrap.ts index 218d2e0aad6..53a30e2f511 100644 --- a/ts/WoltLabSuite/Core/Bootstrap.ts +++ b/ts/WoltLabSuite/Core/Bootstrap.ts @@ -168,6 +168,9 @@ export function setup(options: BoostrapOptions): void { whenFirstSeen("[data-google-maps-geocoding]", () => { void import("./Component/GoogleMaps/Geocoding").then(({ setup }) => setup()); }); + whenFirstSeen("woltlab-core-file-upload", () => { + 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/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts new file mode 100644 index 00000000000..d886849d3bd --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -0,0 +1,22 @@ +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; + +async function upload(element: WoltlabCoreFileUploadElement, file: File): Promise { + const chunkSize = 2_000_000; + const chunks = Math.ceil(file.size / chunkSize); + + for (let i = 0; i < chunks; i++) { + const chunk = file.slice(i * chunkSize, i * chunkSize + chunkSize + 1); + + const response = await prepareRequest(element.dataset.endpoint!).post(chunk).fetchAsResponse(); + console.log(response); + } +} + +export function setup(): void { + wheneverFirstSeen("woltlab-core-file-upload", (element) => { + element.addEventListener("upload", (event: CustomEvent) => { + void upload(element, event.detail); + }); + }); +} 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..df0752c19e7 --- /dev/null +++ b/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts @@ -0,0 +1,56 @@ +{ + 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); + } + }); + } + + connectedCallback() { + 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; + } + `; + } + } + + window.customElements.define("woltlab-core-file-upload", WoltlabCoreFileUploadElement); +} diff --git a/ts/global.d.ts b/ts/global.d.ts index 29daeeab06e..997c0faf380 100644 --- a/ts/global.d.ts +++ b/ts/global.d.ts @@ -91,6 +91,8 @@ declare global { set date(date: Date); } + interface WoltlabCoreFileUploadElement extends HTMLElement {} + interface WoltlabCoreLoadingIndicatorElement extends HTMLElement { get size(): LoadingIndicatorIconSize; set size(size: LoadingIndicatorIconSize); @@ -121,6 +123,7 @@ declare global { "woltlab-core-dialog": WoltlabCoreDialogElement; "woltlab-core-dialog-control": WoltlabCoreDialogControlElement; "woltlab-core-date-time": WoltlabCoreDateTime; + "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/js/WoltLabSuite/Core/Bootstrap.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js index a825090cb4b..62177dd0e76 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js @@ -133,6 +133,9 @@ define(["require", "exports", "tslib", "./Core", "./Date/Picker", "./Devtools", (0, LazyLoader_1.whenFirstSeen)("[data-google-maps-geocoding]", () => { void new Promise((resolve_5, reject_5) => { require(["./Component/GoogleMaps/Geocoding"], resolve_5, reject_5); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); + (0, LazyLoader_1.whenFirstSeen)("woltlab-core-file-upload", () => { + void new Promise((resolve_6, reject_6) => { require(["./Component/File/Upload"], resolve_6, reject_6); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + }); // Move the reCAPTCHA widget overlay to the `pageOverlayContainer` // when widget form elements are placed in a dialog. const observer = new MutationObserver((mutations) => { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js new file mode 100644 index 00000000000..24b61c94e6f --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -0,0 +1,22 @@ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector"], function (require, exports, Backend_1, Selector_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = void 0; + async function upload(element, file) { + const chunkSize = 2000000; + const chunks = Math.ceil(file.size / chunkSize); + for (let i = 0; i < chunks; i++) { + const chunk = file.slice(i * chunkSize, i * chunkSize + chunkSize + 1); + const response = await (0, Backend_1.prepareRequest)(element.dataset.endpoint).post(chunk).fetchAsResponse(); + console.log(response); + } + } + function setup() { + (0, Selector_1.wheneverFirstSeen)("woltlab-core-file-upload", (element) => { + element.addEventListener("upload", (event) => { + void upload(element, event.detail); + }); + }); + } + exports.setup = setup; +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js b/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js index 460eacfc999..5b4f135b95b 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js +++ b/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js @@ -1,20 +1,20 @@ -"use strict";(()=>{var ke=Object.create;var te=Object.defineProperty;var ye=Object.getOwnPropertyDescriptor;var ve=Object.getOwnPropertyNames;var Ee=Object.getPrototypeOf,xe=Object.prototype.hasOwnProperty;var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(r,n)=>(typeof require<"u"?require:r)[n]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});var _e=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports),ce=(e,r)=>{for(var n in r)te(e,n,{get:r[n],enumerable:!0})},Te=(e,r,n,f)=>{if(r&&typeof r=="object"||typeof r=="function")for(let t of ve(r))!xe.call(e,t)&&t!==n&&te(e,t,{get:()=>r[t],enumerable:!(f=ye(r,t))||f.enumerable});return e};var Le=(e,r,n)=>(n=e!=null?ke(Ee(e)):{},Te(r||!e||!e.__esModule?te(n,"default",{value:e,enumerable:!0}):n,e));var fe=_e((O,re)=>{"use strict";var Q=function(){var e=function(R,o,c,u){for(c=c||{},u=R.length;u--;c[R[u]]=o);return c},r=[2,44],n=[5,9,11,12,13,18,19,21,22,23,25,26,28,29,30,32,33,34,35,37,39,41],f=[1,25],t=[1,27],a=[1,33],l=[1,31],d=[1,32],g=[1,28],w=[1,29],q=[1,26],S=[1,35],z=[1,41],P=[1,40],h=[11,12,15,42,43,47,49,51,52,54,55],m=[9,11,12,13,18,19,21,23,26,28,30,32,33,34,35,37,39],p=[11,12,15,42,43,46,47,48,49,51,52,54,55],E=[1,64],k=[1,65],x=[18,37,39],D=[12,15],W={trace:function(){},yy:{},symbols_:{error:2,TEMPLATE:3,CHUNK_STAR:4,EOF:5,CHUNK_STAR_repetition0:6,CHUNK:7,PLAIN_ANY:8,T_LITERAL:9,COMMAND:10,T_ANY:11,T_WS:12,"{if":13,COMMAND_PARAMETERS:14,"}":15,COMMAND_repetition0:16,COMMAND_option0:17,"{/if}":18,"{include":19,COMMAND_PARAMETER_LIST:20,"{implode":21,"{/implode}":22,"{foreach":23,COMMAND_option1:24,"{/foreach}":25,"{plural":26,PLURAL_PARAMETER_LIST:27,"{lang}":28,"{/lang}":29,"{":30,VARIABLE:31,"{#":32,"{@":33,"{ldelim}":34,"{rdelim}":35,ELSE:36,"{else}":37,ELSE_IF:38,"{elseif":39,FOREACH_ELSE:40,"{foreachelse}":41,T_VARIABLE:42,T_VARIABLE_NAME:43,VARIABLE_repetition0:44,VARIABLE_SUFFIX:45,"[":46,"]":47,".":48,"(":49,VARIABLE_SUFFIX_option0:50,")":51,"=":52,COMMAND_PARAMETER_VALUE:53,T_QUOTED_STRING:54,T_DIGITS:55,COMMAND_PARAMETERS_repetition_plus0:56,COMMAND_PARAMETER:57,T_PLURAL_PARAMETER_NAME:58,$accept:0,$end:1},terminals_:{2:"error",5:"EOF",9:"T_LITERAL",11:"T_ANY",12:"T_WS",13:"{if",15:"}",18:"{/if}",19:"{include",21:"{implode",22:"{/implode}",23:"{foreach",25:"{/foreach}",26:"{plural",28:"{lang}",29:"{/lang}",30:"{",32:"{#",33:"{@",34:"{ldelim}",35:"{rdelim}",37:"{else}",39:"{elseif",41:"{foreachelse}",42:"T_VARIABLE",43:"T_VARIABLE_NAME",46:"[",47:"]",48:".",49:"(",51:")",52:"=",54:"T_QUOTED_STRING",55:"T_DIGITS"},productions_:[0,[3,2],[4,1],[7,1],[7,1],[7,1],[8,1],[8,1],[10,7],[10,3],[10,5],[10,6],[10,3],[10,3],[10,3],[10,3],[10,3],[10,1],[10,1],[36,2],[38,4],[40,2],[31,3],[45,3],[45,2],[45,3],[20,5],[20,3],[53,1],[53,1],[53,1],[14,1],[57,1],[57,1],[57,1],[57,1],[57,1],[57,1],[57,1],[57,3],[27,5],[27,3],[58,1],[58,1],[6,0],[6,2],[16,0],[16,2],[17,0],[17,1],[24,0],[24,1],[44,0],[44,2],[50,0],[50,1],[56,1],[56,2]],performAction:function(o,c,u,y,b,s,U){var i=s.length-1;switch(b){case 1:return s[i-1]+";";case 2:var C=s[i].reduce(function(T,L){return L.encode&&!T[1]?T[0]+=" + '"+L.value:L.encode&&T[1]?T[0]+=L.value:!L.encode&&T[1]?T[0]+="' + "+L.value:!L.encode&&!T[1]&&(T[0]+=" + "+L.value),T[1]=L.encode,T},["''",!1]);C[1]&&(C[0]+="'"),this.$=C[0];break;case 3:case 4:this.$={encode:!0,value:s[i].replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/(\r\n|\n|\r)/g,"\\n")};break;case 5:this.$={encode:!1,value:s[i]};break;case 8:this.$="(function() { if ("+s[i-5]+") { return "+s[i-3]+"; } "+s[i-2].join(" ")+" "+(s[i-1]||"")+" return ''; })()";break;case 9:if(!s[i-1].file)throw new Error("Missing parameter file");this.$=s[i-1].file+".fetch(v)";break;case 10:if(!s[i-3].from)throw new Error("Missing parameter from");if(!s[i-3].item)throw new Error("Missing parameter item");s[i-3].glue||(s[i-3].glue="', '"),this.$="(function() { return "+s[i-3].from+".map(function(item) { v["+s[i-3].item+"] = item; return "+s[i-1]+"; }).join("+s[i-3].glue+"); })()";break;case 11:if(!s[i-4].from)throw new Error("Missing parameter from");if(!s[i-4].item)throw new Error("Missing parameter item");this.$="(function() {var looped = false, result = '';if ("+s[i-4].from+" instanceof Array) {for (var i = 0; i < "+s[i-4].from+".length; i++) { looped = true;v["+s[i-4].key+"] = i;v["+s[i-4].item+"] = "+s[i-4].from+"[i];result += "+s[i-2]+";}} else {for (var key in "+s[i-4].from+") {if (!"+s[i-4].from+".hasOwnProperty(key)) continue;looped = true;v["+s[i-4].key+"] = key;v["+s[i-4].item+"] = "+s[i-4].from+"[key];result += "+s[i-2]+";}}return (looped ? result : "+(s[i-1]||"''")+"); })()";break;case 12:this.$="h.selectPlural({";var B=!1;for(var F in s[i-1])objOwns(s[i-1],F)&&(this.$+=(B?",":"")+F+": "+s[i-1][F],B=!0);this.$+="})";break;case 13:this.$="Language.get("+s[i-1]+", v)";break;case 14:this.$="h.escapeHTML("+s[i-1]+")";break;case 15:this.$="h.formatNumeric("+s[i-1]+")";break;case 16:this.$=s[i-1];break;case 17:this.$="'{'";break;case 18:this.$="'}'";break;case 19:this.$="else { return "+s[i]+"; }";break;case 20:this.$="else if ("+s[i-2]+") { return "+s[i]+"; }";break;case 21:this.$=s[i];break;case 22:this.$="v['"+s[i-1]+"']"+s[i].join("");break;case 23:this.$=s[i-2]+s[i-1]+s[i];break;case 24:this.$="['"+s[i]+"']";break;case 25:case 39:this.$=s[i-2]+(s[i-1]||"")+s[i];break;case 26:case 40:this.$=s[i],this.$[s[i-4]]=s[i-2];break;case 27:case 41:this.$={},this.$[s[i-2]]=s[i];break;case 31:this.$=s[i].join("");break;case 44:case 46:case 52:this.$=[];break;case 45:case 47:case 53:case 57:s[i-1].push(s[i]);break;case 56:this.$=[s[i]];break}},table:[e([5,9,11,12,13,19,21,23,26,28,30,32,33,34,35],r,{3:1,4:2,6:3}),{1:[3]},{5:[1,4]},e([5,18,22,25,29,37,39,41],[2,2],{7:5,8:6,10:8,9:[1,7],11:[1,9],12:[1,10],13:[1,11],19:[1,12],21:[1,13],23:[1,14],26:[1,15],28:[1,16],30:[1,17],32:[1,18],33:[1,19],34:[1,20],35:[1,21]}),{1:[2,1]},e(n,[2,45]),e(n,[2,3]),e(n,[2,4]),e(n,[2,5]),e(n,[2,6]),e(n,[2,7]),{11:f,12:t,14:22,31:30,42:a,43:l,49:d,52:g,54:w,55:q,56:23,57:24},{20:34,43:S},{20:36,43:S},{20:37,43:S},{27:38,43:z,55:P,58:39},e([9,11,12,13,19,21,23,26,28,29,30,32,33,34,35],r,{6:3,4:42}),{31:43,42:a},{31:44,42:a},{31:45,42:a},e(n,[2,17]),e(n,[2,18]),{15:[1,46]},e([15,47,51],[2,31],{31:30,57:47,11:f,12:t,42:a,43:l,49:d,52:g,54:w,55:q}),e(h,[2,56]),e(h,[2,32]),e(h,[2,33]),e(h,[2,34]),e(h,[2,35]),e(h,[2,36]),e(h,[2,37]),e(h,[2,38]),{11:f,12:t,14:48,31:30,42:a,43:l,49:d,52:g,54:w,55:q,56:23,57:24},{43:[1,49]},{15:[1,50]},{52:[1,51]},{15:[1,52]},{15:[1,53]},{15:[1,54]},{52:[1,55]},{52:[2,42]},{52:[2,43]},{29:[1,56]},{15:[1,57]},{15:[1,58]},{15:[1,59]},e(m,r,{6:3,4:60}),e(h,[2,57]),{51:[1,61]},e(p,[2,52],{44:62}),e(n,[2,9]),{31:66,42:a,53:63,54:E,55:k},e([9,11,12,13,19,21,22,23,26,28,30,32,33,34,35],r,{6:3,4:67}),e([9,11,12,13,19,21,23,25,26,28,30,32,33,34,35,41],r,{6:3,4:68}),e(n,[2,12]),{31:66,42:a,53:69,54:E,55:k},e(n,[2,13]),e(n,[2,14]),e(n,[2,15]),e(n,[2,16]),e(x,[2,46],{16:70}),e(h,[2,39]),e([11,12,15,42,43,47,51,52,54,55],[2,22],{45:71,46:[1,72],48:[1,73],49:[1,74]}),{12:[1,75],15:[2,27]},e(D,[2,28]),e(D,[2,29]),e(D,[2,30]),{22:[1,76]},{24:77,25:[2,50],40:78,41:[1,79]},{12:[1,80],15:[2,41]},{17:81,18:[2,48],36:83,37:[1,85],38:82,39:[1,84]},e(p,[2,53]),{11:f,12:t,14:86,31:30,42:a,43:l,49:d,52:g,54:w,55:q,56:23,57:24},{43:[1,87]},{11:f,12:t,14:89,31:30,42:a,43:l,49:d,50:88,51:[2,54],52:g,54:w,55:q,56:23,57:24},{20:90,43:S},e(n,[2,10]),{25:[1,91]},{25:[2,51]},e([9,11,12,13,19,21,23,25,26,28,30,32,33,34,35],r,{6:3,4:92}),{27:93,43:z,55:P,58:39},{18:[1,94]},e(x,[2,47]),{18:[2,49]},{11:f,12:t,14:95,31:30,42:a,43:l,49:d,52:g,54:w,55:q,56:23,57:24},e([9,11,12,13,18,19,21,23,26,28,30,32,33,34,35],r,{6:3,4:96}),{47:[1,97]},e(p,[2,24]),{51:[1,98]},{51:[2,55]},{15:[2,26]},e(n,[2,11]),{25:[2,21]},{15:[2,40]},e(n,[2,8]),{15:[1,99]},{18:[2,19]},e(p,[2,23]),e(p,[2,25]),e(m,r,{6:3,4:100}),e(x,[2,20])],defaultActions:{4:[2,1],40:[2,42],41:[2,43],78:[2,51],83:[2,49],89:[2,55],90:[2,26],92:[2,21],93:[2,40],96:[2,19]},parseError:function(o,c){if(c.recoverable)this.trace(o);else{var u=new Error(o);throw u.hash=c,u}},parse:function(o){var c=this,u=[0],y=[],b=[null],s=[],U=this.table,i="",C=0,B=0,F=0,T=2,L=1,me=s.slice.call(arguments,1),v=Object.create(this.lexer),j={yy:{}};for(var X in this.yy)Object.prototype.hasOwnProperty.call(this.yy,X)&&(j.yy[X]=this.yy[X]);v.setInput(o,j.yy),j.yy.lexer=v,j.yy.parser=this,typeof v.yylloc>"u"&&(v.yylloc={});var J=v.yylloc;s.push(J);var be=v.options&&v.options.ranges;typeof j.yy.parseError=="function"?this.parseError=j.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Re(M){u.length=u.length-2*M,b.length=b.length-M,s.length=s.length-M}for(var we=function(){var M;return M=v.lex()||L,typeof M!="number"&&(M=c.symbols_[M]||M),M},_,$,H,A,Ce,ee,N={},Y,I,oe,Z;;){if(H=u[u.length-1],this.defaultActions[H]?A=this.defaultActions[H]:((_===null||typeof _>"u")&&(_=we()),A=U[H]&&U[H][_]),typeof A>"u"||!A.length||!A[0]){var ae="";Z=[];for(Y in U[H])this.terminals_[Y]&&Y>T&&Z.push("'"+this.terminals_[Y]+"'");v.showPosition?ae="Parse error on line "+(C+1)+`: +"use strict";(()=>{var ke=Object.create;var te=Object.defineProperty;var ye=Object.getOwnPropertyDescriptor;var ve=Object.getOwnPropertyNames;var Ee=Object.getPrototypeOf,xe=Object.prototype.hasOwnProperty;var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(r,i)=>(typeof require<"u"?require:r)[i]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});var Te=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports),ce=(e,r)=>{for(var i in r)te(e,i,{get:r[i],enumerable:!0})},_e=(e,r,i,c)=>{if(r&&typeof r=="object"||typeof r=="function")for(let t of ve(r))!xe.call(e,t)&&t!==i&&te(e,t,{get:()=>r[t],enumerable:!(c=ye(r,t))||c.enumerable});return e};var Le=(e,r,i)=>(i=e!=null?ke(Ee(e)):{},_e(r||!e||!e.__esModule?te(i,"default",{value:e,enumerable:!0}):i,e));var fe=Te((O,re)=>{"use strict";var Q=function(){var e=function(R,o,f,u){for(f=f||{},u=R.length;u--;f[R[u]]=o);return f},r=[2,44],i=[5,9,11,12,13,18,19,21,22,23,25,26,28,29,30,32,33,34,35,37,39,41],c=[1,25],t=[1,27],a=[1,33],l=[1,31],d=[1,32],g=[1,28],w=[1,29],q=[1,26],S=[1,35],z=[1,41],P=[1,40],h=[11,12,15,42,43,47,49,51,52,54,55],m=[9,11,12,13,18,19,21,23,26,28,30,32,33,34,35,37,39],p=[11,12,15,42,43,46,47,48,49,51,52,54,55],E=[1,64],k=[1,65],x=[18,37,39],D=[12,15],W={trace:function(){},yy:{},symbols_:{error:2,TEMPLATE:3,CHUNK_STAR:4,EOF:5,CHUNK_STAR_repetition0:6,CHUNK:7,PLAIN_ANY:8,T_LITERAL:9,COMMAND:10,T_ANY:11,T_WS:12,"{if":13,COMMAND_PARAMETERS:14,"}":15,COMMAND_repetition0:16,COMMAND_option0:17,"{/if}":18,"{include":19,COMMAND_PARAMETER_LIST:20,"{implode":21,"{/implode}":22,"{foreach":23,COMMAND_option1:24,"{/foreach}":25,"{plural":26,PLURAL_PARAMETER_LIST:27,"{lang}":28,"{/lang}":29,"{":30,VARIABLE:31,"{#":32,"{@":33,"{ldelim}":34,"{rdelim}":35,ELSE:36,"{else}":37,ELSE_IF:38,"{elseif":39,FOREACH_ELSE:40,"{foreachelse}":41,T_VARIABLE:42,T_VARIABLE_NAME:43,VARIABLE_repetition0:44,VARIABLE_SUFFIX:45,"[":46,"]":47,".":48,"(":49,VARIABLE_SUFFIX_option0:50,")":51,"=":52,COMMAND_PARAMETER_VALUE:53,T_QUOTED_STRING:54,T_DIGITS:55,COMMAND_PARAMETERS_repetition_plus0:56,COMMAND_PARAMETER:57,T_PLURAL_PARAMETER_NAME:58,$accept:0,$end:1},terminals_:{2:"error",5:"EOF",9:"T_LITERAL",11:"T_ANY",12:"T_WS",13:"{if",15:"}",18:"{/if}",19:"{include",21:"{implode",22:"{/implode}",23:"{foreach",25:"{/foreach}",26:"{plural",28:"{lang}",29:"{/lang}",30:"{",32:"{#",33:"{@",34:"{ldelim}",35:"{rdelim}",37:"{else}",39:"{elseif",41:"{foreachelse}",42:"T_VARIABLE",43:"T_VARIABLE_NAME",46:"[",47:"]",48:".",49:"(",51:")",52:"=",54:"T_QUOTED_STRING",55:"T_DIGITS"},productions_:[0,[3,2],[4,1],[7,1],[7,1],[7,1],[8,1],[8,1],[10,7],[10,3],[10,5],[10,6],[10,3],[10,3],[10,3],[10,3],[10,3],[10,1],[10,1],[36,2],[38,4],[40,2],[31,3],[45,3],[45,2],[45,3],[20,5],[20,3],[53,1],[53,1],[53,1],[14,1],[57,1],[57,1],[57,1],[57,1],[57,1],[57,1],[57,1],[57,3],[27,5],[27,3],[58,1],[58,1],[6,0],[6,2],[16,0],[16,2],[17,0],[17,1],[24,0],[24,1],[44,0],[44,2],[50,0],[50,1],[56,1],[56,2]],performAction:function(o,f,u,y,b,s,U){var n=s.length-1;switch(b){case 1:return s[n-1]+";";case 2:var C=s[n].reduce(function(_,L){return L.encode&&!_[1]?_[0]+=" + '"+L.value:L.encode&&_[1]?_[0]+=L.value:!L.encode&&_[1]?_[0]+="' + "+L.value:!L.encode&&!_[1]&&(_[0]+=" + "+L.value),_[1]=L.encode,_},["''",!1]);C[1]&&(C[0]+="'"),this.$=C[0];break;case 3:case 4:this.$={encode:!0,value:s[n].replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/(\r\n|\n|\r)/g,"\\n")};break;case 5:this.$={encode:!1,value:s[n]};break;case 8:this.$="(function() { if ("+s[n-5]+") { return "+s[n-3]+"; } "+s[n-2].join(" ")+" "+(s[n-1]||"")+" return ''; })()";break;case 9:if(!s[n-1].file)throw new Error("Missing parameter file");this.$=s[n-1].file+".fetch(v)";break;case 10:if(!s[n-3].from)throw new Error("Missing parameter from");if(!s[n-3].item)throw new Error("Missing parameter item");s[n-3].glue||(s[n-3].glue="', '"),this.$="(function() { return "+s[n-3].from+".map(function(item) { v["+s[n-3].item+"] = item; return "+s[n-1]+"; }).join("+s[n-3].glue+"); })()";break;case 11:if(!s[n-4].from)throw new Error("Missing parameter from");if(!s[n-4].item)throw new Error("Missing parameter item");this.$="(function() {var looped = false, result = '';if ("+s[n-4].from+" instanceof Array) {for (var i = 0; i < "+s[n-4].from+".length; i++) { looped = true;v["+s[n-4].key+"] = i;v["+s[n-4].item+"] = "+s[n-4].from+"[i];result += "+s[n-2]+";}} else {for (var key in "+s[n-4].from+") {if (!"+s[n-4].from+".hasOwnProperty(key)) continue;looped = true;v["+s[n-4].key+"] = key;v["+s[n-4].item+"] = "+s[n-4].from+"[key];result += "+s[n-2]+";}}return (looped ? result : "+(s[n-1]||"''")+"); })()";break;case 12:this.$="h.selectPlural({";var B=!1;for(var F in s[n-1])objOwns(s[n-1],F)&&(this.$+=(B?",":"")+F+": "+s[n-1][F],B=!0);this.$+="})";break;case 13:this.$="Language.get("+s[n-1]+", v)";break;case 14:this.$="h.escapeHTML("+s[n-1]+")";break;case 15:this.$="h.formatNumeric("+s[n-1]+")";break;case 16:this.$=s[n-1];break;case 17:this.$="'{'";break;case 18:this.$="'}'";break;case 19:this.$="else { return "+s[n]+"; }";break;case 20:this.$="else if ("+s[n-2]+") { return "+s[n]+"; }";break;case 21:this.$=s[n];break;case 22:this.$="v['"+s[n-1]+"']"+s[n].join("");break;case 23:this.$=s[n-2]+s[n-1]+s[n];break;case 24:this.$="['"+s[n]+"']";break;case 25:case 39:this.$=s[n-2]+(s[n-1]||"")+s[n];break;case 26:case 40:this.$=s[n],this.$[s[n-4]]=s[n-2];break;case 27:case 41:this.$={},this.$[s[n-2]]=s[n];break;case 31:this.$=s[n].join("");break;case 44:case 46:case 52:this.$=[];break;case 45:case 47:case 53:case 57:s[n-1].push(s[n]);break;case 56:this.$=[s[n]];break}},table:[e([5,9,11,12,13,19,21,23,26,28,30,32,33,34,35],r,{3:1,4:2,6:3}),{1:[3]},{5:[1,4]},e([5,18,22,25,29,37,39,41],[2,2],{7:5,8:6,10:8,9:[1,7],11:[1,9],12:[1,10],13:[1,11],19:[1,12],21:[1,13],23:[1,14],26:[1,15],28:[1,16],30:[1,17],32:[1,18],33:[1,19],34:[1,20],35:[1,21]}),{1:[2,1]},e(i,[2,45]),e(i,[2,3]),e(i,[2,4]),e(i,[2,5]),e(i,[2,6]),e(i,[2,7]),{11:c,12:t,14:22,31:30,42:a,43:l,49:d,52:g,54:w,55:q,56:23,57:24},{20:34,43:S},{20:36,43:S},{20:37,43:S},{27:38,43:z,55:P,58:39},e([9,11,12,13,19,21,23,26,28,29,30,32,33,34,35],r,{6:3,4:42}),{31:43,42:a},{31:44,42:a},{31:45,42:a},e(i,[2,17]),e(i,[2,18]),{15:[1,46]},e([15,47,51],[2,31],{31:30,57:47,11:c,12:t,42:a,43:l,49:d,52:g,54:w,55:q}),e(h,[2,56]),e(h,[2,32]),e(h,[2,33]),e(h,[2,34]),e(h,[2,35]),e(h,[2,36]),e(h,[2,37]),e(h,[2,38]),{11:c,12:t,14:48,31:30,42:a,43:l,49:d,52:g,54:w,55:q,56:23,57:24},{43:[1,49]},{15:[1,50]},{52:[1,51]},{15:[1,52]},{15:[1,53]},{15:[1,54]},{52:[1,55]},{52:[2,42]},{52:[2,43]},{29:[1,56]},{15:[1,57]},{15:[1,58]},{15:[1,59]},e(m,r,{6:3,4:60}),e(h,[2,57]),{51:[1,61]},e(p,[2,52],{44:62}),e(i,[2,9]),{31:66,42:a,53:63,54:E,55:k},e([9,11,12,13,19,21,22,23,26,28,30,32,33,34,35],r,{6:3,4:67}),e([9,11,12,13,19,21,23,25,26,28,30,32,33,34,35,41],r,{6:3,4:68}),e(i,[2,12]),{31:66,42:a,53:69,54:E,55:k},e(i,[2,13]),e(i,[2,14]),e(i,[2,15]),e(i,[2,16]),e(x,[2,46],{16:70}),e(h,[2,39]),e([11,12,15,42,43,47,51,52,54,55],[2,22],{45:71,46:[1,72],48:[1,73],49:[1,74]}),{12:[1,75],15:[2,27]},e(D,[2,28]),e(D,[2,29]),e(D,[2,30]),{22:[1,76]},{24:77,25:[2,50],40:78,41:[1,79]},{12:[1,80],15:[2,41]},{17:81,18:[2,48],36:83,37:[1,85],38:82,39:[1,84]},e(p,[2,53]),{11:c,12:t,14:86,31:30,42:a,43:l,49:d,52:g,54:w,55:q,56:23,57:24},{43:[1,87]},{11:c,12:t,14:89,31:30,42:a,43:l,49:d,50:88,51:[2,54],52:g,54:w,55:q,56:23,57:24},{20:90,43:S},e(i,[2,10]),{25:[1,91]},{25:[2,51]},e([9,11,12,13,19,21,23,25,26,28,30,32,33,34,35],r,{6:3,4:92}),{27:93,43:z,55:P,58:39},{18:[1,94]},e(x,[2,47]),{18:[2,49]},{11:c,12:t,14:95,31:30,42:a,43:l,49:d,52:g,54:w,55:q,56:23,57:24},e([9,11,12,13,18,19,21,23,26,28,30,32,33,34,35],r,{6:3,4:96}),{47:[1,97]},e(p,[2,24]),{51:[1,98]},{51:[2,55]},{15:[2,26]},e(i,[2,11]),{25:[2,21]},{15:[2,40]},e(i,[2,8]),{15:[1,99]},{18:[2,19]},e(p,[2,23]),e(p,[2,25]),e(m,r,{6:3,4:100}),e(x,[2,20])],defaultActions:{4:[2,1],40:[2,42],41:[2,43],78:[2,51],83:[2,49],89:[2,55],90:[2,26],92:[2,21],93:[2,40],96:[2,19]},parseError:function(o,f){if(f.recoverable)this.trace(o);else{var u=new Error(o);throw u.hash=f,u}},parse:function(o){var f=this,u=[0],y=[],b=[null],s=[],U=this.table,n="",C=0,B=0,F=0,_=2,L=1,me=s.slice.call(arguments,1),v=Object.create(this.lexer),j={yy:{}};for(var X in this.yy)Object.prototype.hasOwnProperty.call(this.yy,X)&&(j.yy[X]=this.yy[X]);v.setInput(o,j.yy),j.yy.lexer=v,j.yy.parser=this,typeof v.yylloc>"u"&&(v.yylloc={});var J=v.yylloc;s.push(J);var be=v.options&&v.options.ranges;typeof j.yy.parseError=="function"?this.parseError=j.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Re(M){u.length=u.length-2*M,b.length=b.length-M,s.length=s.length-M}for(var we=function(){var M;return M=v.lex()||L,typeof M!="number"&&(M=f.symbols_[M]||M),M},T,$,H,A,Ce,ee,N={},Y,I,oe,Z;;){if(H=u[u.length-1],this.defaultActions[H]?A=this.defaultActions[H]:((T===null||typeof T>"u")&&(T=we()),A=U[H]&&U[H][T]),typeof A>"u"||!A.length||!A[0]){var ae="";Z=[];for(Y in U[H])this.terminals_[Y]&&Y>_&&Z.push("'"+this.terminals_[Y]+"'");v.showPosition?ae="Parse error on line "+(C+1)+`: `+v.showPosition()+` -Expecting `+Z.join(", ")+", got '"+(this.terminals_[_]||_)+"'":ae="Parse error on line "+(C+1)+": Unexpected "+(_==L?"end of input":"'"+(this.terminals_[_]||_)+"'"),this.parseError(ae,{text:v.match,token:this.terminals_[_]||_,line:v.yylineno,loc:J,expected:Z})}if(A[0]instanceof Array&&A.length>1)throw new Error("Parse Error: multiple actions possible at state: "+H+", token: "+_);switch(A[0]){case 1:u.push(_),b.push(v.yytext),s.push(v.yylloc),u.push(A[1]),_=null,$?(_=$,$=null):(B=v.yyleng,i=v.yytext,C=v.yylineno,J=v.yylloc,F>0&&F--);break;case 2:if(I=this.productions_[A[1]][1],N.$=b[b.length-I],N._$={first_line:s[s.length-(I||1)].first_line,last_line:s[s.length-1].last_line,first_column:s[s.length-(I||1)].first_column,last_column:s[s.length-1].last_column},be&&(N._$.range=[s[s.length-(I||1)].range[0],s[s.length-1].range[1]]),ee=this.performAction.apply(N,[i,B,C,j.yy,A[1],b,s].concat(me)),typeof ee<"u")return ee;I&&(u=u.slice(0,-1*I*2),b=b.slice(0,-1*I),s=s.slice(0,-1*I)),u.push(this.productions_[A[1]][0]),b.push(N.$),s.push(N._$),oe=U[u[u.length-2]][u[u.length-1]],u.push(oe);break;case 3:return!0}}return!0}},ge=function(){var R={EOF:1,parseError:function(c,u){if(this.yy.parser)this.yy.parser.parseError(c,u);else throw new Error(c)},setInput:function(o,c){return this.yy=c||this.yy||{},this._input=o,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},input:function(){var o=this._input[0];this.yytext+=o,this.yyleng++,this.offset++,this.match+=o,this.matched+=o;var c=o.match(/(?:\r\n?|\n).*/g);return c?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),o},unput:function(o){var c=o.length,u=o.split(/(?:\r\n?|\n)/g);this._input=o+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-c),this.offset-=c;var y=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),u.length-1&&(this.yylineno-=u.length-1);var b=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:u?(u.length===y.length?this.yylloc.first_column:0)+y[y.length-u.length].length-u[0].length:this.yylloc.first_column-c},this.options.ranges&&(this.yylloc.range=[b[0],b[0]+this.yyleng-c]),this.yyleng=this.yytext.length,this},more:function(){return this._more=!0,this},reject:function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). -`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},less:function(o){this.unput(this.match.slice(o))},pastInput:function(){var o=this.matched.substr(0,this.matched.length-this.match.length);return(o.length>20?"...":"")+o.substr(-20).replace(/\n/g,"")},upcomingInput:function(){var o=this.match;return o.length<20&&(o+=this._input.substr(0,20-o.length)),(o.substr(0,20)+(o.length>20?"...":"")).replace(/\n/g,"")},showPosition:function(){var o=this.pastInput(),c=new Array(o.length+1).join("-");return o+this.upcomingInput()+` -`+c+"^"},test_match:function(o,c){var u,y,b;if(this.options.backtrack_lexer&&(b={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(b.yylloc.range=this.yylloc.range.slice(0))),y=o[0].match(/(?:\r\n?|\n).*/g),y&&(this.yylineno+=y.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:y?y[y.length-1].length-y[y.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+o[0].length},this.yytext+=o[0],this.match+=o[0],this.matches=o,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(o[0].length),this.matched+=o[0],u=this.performAction.call(this,this.yy,this,c,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),u)return u;if(this._backtrack){for(var s in b)this[s]=b[s];return!1}return!1},next:function(){if(this.done)return this.EOF;this._input||(this.done=!0);var o,c,u,y;this._more||(this.yytext="",this.match="");for(var b=this._currentRules(),s=0;sc[0].length)){if(c=u,y=s,this.options.backtrack_lexer){if(o=this.test_match(u,b[s]),o!==!1)return o;if(this._backtrack){c=!1;continue}else return!1}else if(!this.options.flex)break}return c?(o=this.test_match(c,b[y]),o!==!1?o:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. -`+this.showPosition(),{text:"",token:null,line:this.yylineno})},lex:function(){var c=this.next();return c||this.lex()},begin:function(c){this.conditionStack.push(c)},popState:function(){var c=this.conditionStack.length-1;return c>0?this.conditionStack.pop():this.conditionStack[0]},_currentRules:function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},topState:function(c){return c=this.conditionStack.length-1-Math.abs(c||0),c>=0?this.conditionStack[c]:"INITIAL"},pushState:function(c){this.begin(c)},stateStackSize:function(){return this.conditionStack.length},options:{},performAction:function(c,u,y,b){var s=b;switch(y){case 0:break;case 1:return u.yytext=u.yytext.substring(9,u.yytext.length-10),9;break;case 2:return 54;case 3:return 54;case 4:return 42;case 5:return 55;case 6:return 43;case 7:return 48;case 8:return 46;case 9:return 47;case 10:return 49;case 11:return 51;case 12:return 52;case 13:return 34;case 14:return 35;case 15:return this.begin("command"),32;break;case 16:return this.begin("command"),33;break;case 17:return this.begin("command"),13;break;case 18:return this.begin("command"),39;break;case 19:return this.begin("command"),39;break;case 20:return 37;case 21:return 18;case 22:return 28;case 23:return 29;case 24:return this.begin("command"),19;break;case 25:return this.begin("command"),21;break;case 26:return this.begin("command"),26;break;case 27:return 22;case 28:return this.begin("command"),23;break;case 29:return 41;case 30:return 25;case 31:return this.begin("command"),30;break;case 32:return this.popState(),15;break;case 33:return 12;case 34:return 5;case 35:return 11}},rules:[/^(?:\{\*[\s\S]*?\*\})/,/^(?:\{literal\}[\s\S]*?\{\/literal\})/,/^(?:"([^"]|\\\.)*")/,/^(?:'([^']|\\\.)*')/,/^(?:\$)/,/^(?:[0-9]+)/,/^(?:[_a-zA-Z][_a-zA-Z0-9]*)/,/^(?:\.)/,/^(?:\[)/,/^(?:\])/,/^(?:\()/,/^(?:\))/,/^(?:=)/,/^(?:\{ldelim\})/,/^(?:\{rdelim\})/,/^(?:\{#)/,/^(?:\{@)/,/^(?:\{if )/,/^(?:\{else if )/,/^(?:\{elseif )/,/^(?:\{else\})/,/^(?:\{\/if\})/,/^(?:\{lang\})/,/^(?:\{\/lang\})/,/^(?:\{include )/,/^(?:\{implode )/,/^(?:\{plural )/,/^(?:\{\/implode\})/,/^(?:\{foreach )/,/^(?:\{foreachelse\})/,/^(?:\{\/foreach\})/,/^(?:\{(?!\s))/,/^(?:\})/,/^(?:\s+)/,/^(?:$)/,/^(?:[^{])/],conditions:{command:{rules:[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35],inclusive:!0},INITIAL:{rules:[0,1,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,33,34,35],inclusive:!0}}};return R}();W.lexer=ge;function K(){this.yy={}}return K.prototype=W,W.Parser=K,new K}();typeof se<"u"&&typeof O<"u"&&(O.parser=Q,O.Parser=Q.Parser,O.parse=function(){return Q.parse.apply(Q,arguments)},O.main=!0,typeof re<"u"&&se.main===re&&O.main(process.argv.slice(1)))});var ne={};ce(ne,{getPhrase:()=>le,registerPhrase:()=>Ie});var he=Le(fe());var ie={};ce(ie,{add:()=>G,get:()=>le});var ue=new Map;function le(e,r={}){let n=ue.get(e);return n===void 0?e:n(r)}function G(e,r){ue.set(e,r)}function Ae(e){return String(e).replace(/&/g,"&").replace(/"/g,""").replace(//g,">")}function de(e){return Number(e).toLocaleString(document.documentElement.lang,{maximumFractionDigits:2}).replace("-","\u2212")}var qe=new Intl.PluralRules(document.documentElement.lang);function Se(e){if(!Object.hasOwn(e,"value"))throw new Error("Missing parameter value");if(!e.other)throw new Error("Missing parameter other");let r=e.value;if(Array.isArray(r)&&(r=r.length),Object.hasOwn(e,r.toString()))return e[r];let n=qe.select(r);e[n]===void 0&&(n="other");let f=e[n];return f.includes("#")?f.replace("#",de(r)):f}function Me(e){let r=`var tmp = {}; +Expecting `+Z.join(", ")+", got '"+(this.terminals_[T]||T)+"'":ae="Parse error on line "+(C+1)+": Unexpected "+(T==L?"end of input":"'"+(this.terminals_[T]||T)+"'"),this.parseError(ae,{text:v.match,token:this.terminals_[T]||T,line:v.yylineno,loc:J,expected:Z})}if(A[0]instanceof Array&&A.length>1)throw new Error("Parse Error: multiple actions possible at state: "+H+", token: "+T);switch(A[0]){case 1:u.push(T),b.push(v.yytext),s.push(v.yylloc),u.push(A[1]),T=null,$?(T=$,$=null):(B=v.yyleng,n=v.yytext,C=v.yylineno,J=v.yylloc,F>0&&F--);break;case 2:if(I=this.productions_[A[1]][1],N.$=b[b.length-I],N._$={first_line:s[s.length-(I||1)].first_line,last_line:s[s.length-1].last_line,first_column:s[s.length-(I||1)].first_column,last_column:s[s.length-1].last_column},be&&(N._$.range=[s[s.length-(I||1)].range[0],s[s.length-1].range[1]]),ee=this.performAction.apply(N,[n,B,C,j.yy,A[1],b,s].concat(me)),typeof ee<"u")return ee;I&&(u=u.slice(0,-1*I*2),b=b.slice(0,-1*I),s=s.slice(0,-1*I)),u.push(this.productions_[A[1]][0]),b.push(N.$),s.push(N._$),oe=U[u[u.length-2]][u[u.length-1]],u.push(oe);break;case 3:return!0}}return!0}},ge=function(){var R={EOF:1,parseError:function(f,u){if(this.yy.parser)this.yy.parser.parseError(f,u);else throw new Error(f)},setInput:function(o,f){return this.yy=f||this.yy||{},this._input=o,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},input:function(){var o=this._input[0];this.yytext+=o,this.yyleng++,this.offset++,this.match+=o,this.matched+=o;var f=o.match(/(?:\r\n?|\n).*/g);return f?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),o},unput:function(o){var f=o.length,u=o.split(/(?:\r\n?|\n)/g);this._input=o+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-f),this.offset-=f;var y=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),u.length-1&&(this.yylineno-=u.length-1);var b=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:u?(u.length===y.length?this.yylloc.first_column:0)+y[y.length-u.length].length-u[0].length:this.yylloc.first_column-f},this.options.ranges&&(this.yylloc.range=[b[0],b[0]+this.yyleng-f]),this.yyleng=this.yytext.length,this},more:function(){return this._more=!0,this},reject:function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},less:function(o){this.unput(this.match.slice(o))},pastInput:function(){var o=this.matched.substr(0,this.matched.length-this.match.length);return(o.length>20?"...":"")+o.substr(-20).replace(/\n/g,"")},upcomingInput:function(){var o=this.match;return o.length<20&&(o+=this._input.substr(0,20-o.length)),(o.substr(0,20)+(o.length>20?"...":"")).replace(/\n/g,"")},showPosition:function(){var o=this.pastInput(),f=new Array(o.length+1).join("-");return o+this.upcomingInput()+` +`+f+"^"},test_match:function(o,f){var u,y,b;if(this.options.backtrack_lexer&&(b={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(b.yylloc.range=this.yylloc.range.slice(0))),y=o[0].match(/(?:\r\n?|\n).*/g),y&&(this.yylineno+=y.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:y?y[y.length-1].length-y[y.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+o[0].length},this.yytext+=o[0],this.match+=o[0],this.matches=o,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(o[0].length),this.matched+=o[0],u=this.performAction.call(this,this.yy,this,f,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),u)return u;if(this._backtrack){for(var s in b)this[s]=b[s];return!1}return!1},next:function(){if(this.done)return this.EOF;this._input||(this.done=!0);var o,f,u,y;this._more||(this.yytext="",this.match="");for(var b=this._currentRules(),s=0;sf[0].length)){if(f=u,y=s,this.options.backtrack_lexer){if(o=this.test_match(u,b[s]),o!==!1)return o;if(this._backtrack){f=!1;continue}else return!1}else if(!this.options.flex)break}return f?(o=this.test_match(f,b[y]),o!==!1?o:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},lex:function(){var f=this.next();return f||this.lex()},begin:function(f){this.conditionStack.push(f)},popState:function(){var f=this.conditionStack.length-1;return f>0?this.conditionStack.pop():this.conditionStack[0]},_currentRules:function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},topState:function(f){return f=this.conditionStack.length-1-Math.abs(f||0),f>=0?this.conditionStack[f]:"INITIAL"},pushState:function(f){this.begin(f)},stateStackSize:function(){return this.conditionStack.length},options:{},performAction:function(f,u,y,b){var s=b;switch(y){case 0:break;case 1:return u.yytext=u.yytext.substring(9,u.yytext.length-10),9;break;case 2:return 54;case 3:return 54;case 4:return 42;case 5:return 55;case 6:return 43;case 7:return 48;case 8:return 46;case 9:return 47;case 10:return 49;case 11:return 51;case 12:return 52;case 13:return 34;case 14:return 35;case 15:return this.begin("command"),32;break;case 16:return this.begin("command"),33;break;case 17:return this.begin("command"),13;break;case 18:return this.begin("command"),39;break;case 19:return this.begin("command"),39;break;case 20:return 37;case 21:return 18;case 22:return 28;case 23:return 29;case 24:return this.begin("command"),19;break;case 25:return this.begin("command"),21;break;case 26:return this.begin("command"),26;break;case 27:return 22;case 28:return this.begin("command"),23;break;case 29:return 41;case 30:return 25;case 31:return this.begin("command"),30;break;case 32:return this.popState(),15;break;case 33:return 12;case 34:return 5;case 35:return 11}},rules:[/^(?:\{\*[\s\S]*?\*\})/,/^(?:\{literal\}[\s\S]*?\{\/literal\})/,/^(?:"([^"]|\\\.)*")/,/^(?:'([^']|\\\.)*')/,/^(?:\$)/,/^(?:[0-9]+)/,/^(?:[_a-zA-Z][_a-zA-Z0-9]*)/,/^(?:\.)/,/^(?:\[)/,/^(?:\])/,/^(?:\()/,/^(?:\))/,/^(?:=)/,/^(?:\{ldelim\})/,/^(?:\{rdelim\})/,/^(?:\{#)/,/^(?:\{@)/,/^(?:\{if )/,/^(?:\{else if )/,/^(?:\{elseif )/,/^(?:\{else\})/,/^(?:\{\/if\})/,/^(?:\{lang\})/,/^(?:\{\/lang\})/,/^(?:\{include )/,/^(?:\{implode )/,/^(?:\{plural )/,/^(?:\{\/implode\})/,/^(?:\{foreach )/,/^(?:\{foreachelse\})/,/^(?:\{\/foreach\})/,/^(?:\{(?!\s))/,/^(?:\})/,/^(?:\s+)/,/^(?:$)/,/^(?:[^{])/],conditions:{command:{rules:[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35],inclusive:!0},INITIAL:{rules:[0,1,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,33,34,35],inclusive:!0}}};return R}();W.lexer=ge;function K(){this.yy={}}return K.prototype=W,W.Parser=K,new K}();typeof se<"u"&&typeof O<"u"&&(O.parser=Q,O.Parser=Q.Parser,O.parse=function(){return Q.parse.apply(Q,arguments)},O.main=!0,typeof re<"u"&&se.main===re&&O.main(process.argv.slice(1)))});var ne={};ce(ne,{getPhrase:()=>le,registerPhrase:()=>Ie});var he=Le(fe());var ie={};ce(ie,{add:()=>G,get:()=>le});var ue=new Map;function le(e,r={}){let i=ue.get(e);return i===void 0?e:i(r)}function G(e,r){ue.set(e,r)}function Ae(e){return String(e).replace(/&/g,"&").replace(/"/g,""").replace(//g,">")}function de(e){return Number(e).toLocaleString(document.documentElement.lang,{maximumFractionDigits:2}).replace("-","\u2212")}var qe=new Intl.PluralRules(document.documentElement.lang);function Se(e){if(!Object.hasOwn(e,"value"))throw new Error("Missing parameter value");if(!e.other)throw new Error("Missing parameter other");let r=e.value;if(Array.isArray(r)&&(r=r.length),Object.hasOwn(e,r.toString()))return e[r];let i=qe.select(r);e[i]===void 0&&(i="other");let c=e[i];return c.includes("#")?c.replace("#",de(r)):c}function Me(e){let r=`var tmp = {}; for (var key in v) tmp[key] = v[key]; v = tmp; v.__wcf = window.WCF; v.__window = window; return ${he.parse(e)} - `;return new Function("Language","h","v",r)}var V=class{compiled;constructor(r){try{this.compiled=Me(r)}catch(n){throw n instanceof Error&&console.debug(n.message),n}}fetch(r){return this.compiled(ie,{selectPlural:Se,escapeHTML:Ae,formatNumeric:de},r)}};function Ie(e,r){typeof r=="string"?G(e,ze(r)):G(e,function(){return r})}function ze(e){if(!e.includes("{"))return function(){return e};try{let r=new V(e);return r.fetch.bind(r)}catch{return function(){return e}}}var Pe=(()=>{let e="DOMContentLoaded",r=new WeakMap,n=[],f=l=>{do if(l.nextSibling)return!0;while(l=l.parentNode);return!1},t=()=>{n.splice(0).forEach(l=>{r.get(l[0])!==!0&&(r.set(l[0],!0),l[0][l[1]]())})};document.addEventListener(e,t);class a extends HTMLElement{static withParsedCallback(d,g="parsed"){let{prototype:w}=d,{connectedCallback:q}=w,S=g+"Callback",z=(h,m,p,E)=>{m.disconnect(),p.removeEventListener(e,E),P(h)},P=h=>{n.length||requestAnimationFrame(t),n.push([h,S])};return Object.defineProperties(w,{connectedCallback:{configurable:!0,writable:!0,value(){if(q&&q.apply(this,arguments),S in this&&!r.has(this)){let h=this,{ownerDocument:m}=h;if(r.set(h,!1),m.readyState==="complete"||f(h))P(h);else{let p=()=>z(h,E,m,p);m.addEventListener(e,p);let E=new MutationObserver(()=>{f(h)&&z(h,E,m,p)});E.observe(h.parentNode,{childList:!0,subtree:!0})}}}},[g]:{configurable:!0,get(){return r.get(this)===!0}}}),d}}return a.withParsedCallback(a)})(),pe=Pe;(()=>{let e=new Map([["contact-book","address-book"],["contact-card","address-card"],["vcard","address-card"],["angle-double-down","angles-down"],["angle-double-left","angles-left"],["angle-double-right","angles-right"],["angle-double-up","angles-up"],["apple-alt","apple-whole"],["sort-numeric-asc","arrow-down-1-9"],["sort-numeric-down","arrow-down-1-9"],["sort-numeric-desc","arrow-down-9-1"],["sort-numeric-down-alt","arrow-down-9-1"],["sort-alpha-asc","arrow-down-a-z"],["sort-alpha-down","arrow-down-a-z"],["long-arrow-down","arrow-down-long"],["sort-amount-desc","arrow-down-short-wide"],["sort-amount-down-alt","arrow-down-short-wide"],["sort-amount-asc","arrow-down-wide-short"],["sort-amount-down","arrow-down-wide-short"],["sort-alpha-desc","arrow-down-z-a"],["sort-alpha-down-alt","arrow-down-z-a"],["long-arrow-left","arrow-left-long"],["mouse-pointer","arrow-pointer"],["exchange","arrow-right-arrow-left"],["sign-out","arrow-right-from-bracket"],["long-arrow-right","arrow-right-long"],["sign-in","arrow-right-to-bracket"],["arrow-left-rotate","arrow-rotate-left"],["arrow-rotate-back","arrow-rotate-left"],["arrow-rotate-backward","arrow-rotate-left"],["undo","arrow-rotate-left"],["arrow-right-rotate","arrow-rotate-right"],["arrow-rotate-forward","arrow-rotate-right"],["redo","arrow-rotate-right"],["level-down","arrow-turn-down"],["level-up","arrow-turn-up"],["sort-numeric-up","arrow-up-1-9"],["sort-numeric-up-alt","arrow-up-9-1"],["sort-alpha-up","arrow-up-a-z"],["long-arrow-up","arrow-up-long"],["external-link","arrow-up-right-from-square"],["sort-amount-up-alt","arrow-up-short-wide"],["sort-amount-up","arrow-up-wide-short"],["sort-alpha-up-alt","arrow-up-z-a"],["arrows-h","arrows-left-right"],["refresh","arrows-rotate"],["sync","arrows-rotate"],["arrows-v","arrows-up-down"],["arrows","arrows-up-down-left-right"],["carriage-baby","baby-carriage"],["fast-backward","backward-fast"],["step-backward","backward-step"],["shopping-bag","bag-shopping"],["haykal","bahai"],["cancel","ban"],["smoking-ban","ban-smoking"],["band-aid","bandage"],["navicon","bars"],["tasks-alt","bars-progress"],["reorder","bars-staggered"],["stream","bars-staggered"],["baseball-ball","baseball"],["shopping-basket","basket-shopping"],["basketball-ball","basketball"],["bathtub","bath"],["battery-0","battery-empty"],["battery","battery-full"],["battery-5","battery-full"],["battery-3","battery-half"],["battery-2","battery-quarter"],["battery-4","battery-three-quarters"],["procedures","bed-pulse"],["beer","beer-mug-empty"],["concierge-bell","bell-concierge"],["zap","bolt"],["atlas","book-atlas"],["bible","book-bible"],["journal-whills","book-journal-whills"],["book-reader","book-open-reader"],["quran","book-quran"],["book-dead","book-skull"],["tanakh","book-tanakh"],["border-style","border-top-left"],["archive","box-archive"],["boxes","boxes-stacked"],["boxes-alt","boxes-stacked"],["quidditch","broom-ball"],["quidditch-broom-ball","broom-ball"],["bank","building-columns"],["institution","building-columns"],["museum","building-columns"],["university","building-columns"],["hamburger","burger"],["bus-alt","bus-simple"],["briefcase-clock","business-time"],["tram","cable-car"],["birthday-cake","cake-candles"],["cake","cake-candles"],["calendar-alt","calendar-days"],["calendar-times","calendar-xmark"],["camera-alt","camera"],["automobile","car"],["battery-car","car-battery"],["car-crash","car-burst"],["car-alt","car-rear"],["dolly-flatbed","cart-flatbed"],["luggage-cart","cart-flatbed-suitcase"],["shopping-cart","cart-shopping"],["blackboard","chalkboard"],["chalkboard-teacher","chalkboard-user"],["glass-cheers","champagne-glasses"],["area-chart","chart-area"],["bar-chart","chart-bar"],["line-chart","chart-line"],["pie-chart","chart-pie"],["vote-yea","check-to-slot"],["child-rifle","child-combatant"],["arrow-circle-down","circle-arrow-down"],["arrow-circle-left","circle-arrow-left"],["arrow-circle-right","circle-arrow-right"],["arrow-circle-up","circle-arrow-up"],["check-circle","circle-check"],["chevron-circle-down","circle-chevron-down"],["chevron-circle-left","circle-chevron-left"],["chevron-circle-right","circle-chevron-right"],["chevron-circle-up","circle-chevron-up"],["donate","circle-dollar-to-slot"],["dot-circle","circle-dot"],["arrow-alt-circle-down","circle-down"],["exclamation-circle","circle-exclamation"],["hospital-symbol","circle-h"],["adjust","circle-half-stroke"],["info-circle","circle-info"],["arrow-alt-circle-left","circle-left"],["minus-circle","circle-minus"],["pause-circle","circle-pause"],["play-circle","circle-play"],["plus-circle","circle-plus"],["question-circle","circle-question"],["radiation-alt","circle-radiation"],["arrow-alt-circle-right","circle-right"],["stop-circle","circle-stop"],["arrow-alt-circle-up","circle-up"],["user-circle","circle-user"],["times-circle","circle-xmark"],["xmark-circle","circle-xmark"],["clock-four","clock"],["history","clock-rotate-left"],["cloud-download","cloud-arrow-down"],["cloud-download-alt","cloud-arrow-down"],["cloud-upload","cloud-arrow-up"],["cloud-upload-alt","cloud-arrow-up"],["thunderstorm","cloud-bolt"],["commenting","comment-dots"],["sms","comment-sms"],["drafting-compass","compass-drafting"],["mouse","computer-mouse"],["credit-card-alt","credit-card"],["crop-alt","crop-simple"],["backspace","delete-left"],["desktop-alt","desktop"],["project-diagram","diagram-project"],["directions","diamond-turn-right"],["dollar","dollar-sign"],["usd","dollar-sign"],["dolly-box","dolly"],["compress-alt","down-left-and-up-right-to-center"],["long-arrow-alt-down","down-long"],["tint","droplet"],["tint-slash","droplet-slash"],["deaf","ear-deaf"],["deafness","ear-deaf"],["hard-of-hearing","ear-deaf"],["assistive-listening-systems","ear-listen"],["globe-africa","earth-africa"],["earth","earth-americas"],["earth-america","earth-americas"],["globe-americas","earth-americas"],["globe-asia","earth-asia"],["globe-europe","earth-europe"],["globe-oceania","earth-oceania"],["ellipsis-h","ellipsis"],["ellipsis-v","ellipsis-vertical"],["mail-bulk","envelopes-bulk"],["eur","euro-sign"],["euro","euro-sign"],["eye-dropper-empty","eye-dropper"],["eyedropper","eye-dropper"],["low-vision","eye-low-vision"],["angry","face-angry"],["dizzy","face-dizzy"],["flushed","face-flushed"],["frown","face-frown"],["frown-open","face-frown-open"],["grimace","face-grimace"],["grin","face-grin"],["grin-beam","face-grin-beam"],["grin-beam-sweat","face-grin-beam-sweat"],["grin-hearts","face-grin-hearts"],["grin-squint","face-grin-squint"],["grin-squint-tears","face-grin-squint-tears"],["grin-stars","face-grin-stars"],["grin-tears","face-grin-tears"],["grin-tongue","face-grin-tongue"],["grin-tongue-squint","face-grin-tongue-squint"],["grin-tongue-wink","face-grin-tongue-wink"],["grin-alt","face-grin-wide"],["grin-wink","face-grin-wink"],["kiss","face-kiss"],["kiss-beam","face-kiss-beam"],["kiss-wink-heart","face-kiss-wink-heart"],["laugh","face-laugh"],["laugh-beam","face-laugh-beam"],["laugh-squint","face-laugh-squint"],["laugh-wink","face-laugh-wink"],["meh","face-meh"],["meh-blank","face-meh-blank"],["meh-rolling-eyes","face-rolling-eyes"],["sad-cry","face-sad-cry"],["sad-tear","face-sad-tear"],["smile","face-smile"],["smile-beam","face-smile-beam"],["smile-wink","face-smile-wink"],["surprise","face-surprise"],["tired","face-tired"],["feather-alt","feather-pointed"],["file-download","file-arrow-down"],["file-upload","file-arrow-up"],["arrow-right-from-file","file-export"],["arrow-right-to-file","file-import"],["file-alt","file-lines"],["file-text","file-lines"],["file-edit","file-pen"],["file-medical-alt","file-waveform"],["file-archive","file-zipper"],["funnel-dollar","filter-circle-dollar"],["fire-alt","fire-flame-curved"],["burn","fire-flame-simple"],["save","floppy-disk"],["folder-blank","folder"],["football-ball","football"],["fast-forward","forward-fast"],["step-forward","forward-step"],["futbol-ball","futbol"],["soccer-ball","futbol"],["dashboard","gauge"],["gauge-med","gauge"],["tachometer-alt-average","gauge"],["tachometer-alt","gauge-high"],["tachometer-alt-fast","gauge-high"],["gauge-simple-med","gauge-simple"],["tachometer-average","gauge-simple"],["tachometer","gauge-simple-high"],["tachometer-fast","gauge-simple-high"],["legal","gavel"],["cog","gear"],["cogs","gears"],["golf-ball","golf-ball-tee"],["mortar-board","graduation-cap"],["grip-horizontal","grip"],["hand-paper","hand"],["hand-rock","hand-back-fist"],["allergies","hand-dots"],["fist-raised","hand-fist"],["hand-holding-usd","hand-holding-dollar"],["hand-holding-water","hand-holding-droplet"],["sign-language","hands"],["signing","hands"],["american-sign-language-interpreting","hands-asl-interpreting"],["asl-interpreting","hands-asl-interpreting"],["hands-american-sign-language-interpreting","hands-asl-interpreting"],["hands-wash","hands-bubbles"],["praying-hands","hands-praying"],["hands-helping","handshake-angle"],["handshake-alt","handshake-simple"],["handshake-alt-slash","handshake-simple-slash"],["hdd","hard-drive"],["header","heading"],["headphones-alt","headphones-simple"],["heart-broken","heart-crack"],["heartbeat","heart-pulse"],["hard-hat","helmet-safety"],["hat-hard","helmet-safety"],["hospital-alt","hospital"],["hospital-wide","hospital"],["hot-tub","hot-tub-person"],["hourglass-empty","hourglass"],["hourglass-3","hourglass-end"],["hourglass-2","hourglass-half"],["hourglass-1","hourglass-start"],["home","house"],["home-alt","house"],["home-lg-alt","house"],["home-lg","house-chimney"],["house-damage","house-chimney-crack"],["clinic-medical","house-chimney-medical"],["laptop-house","house-laptop"],["home-user","house-user"],["hryvnia","hryvnia-sign"],["heart-music-camera-bolt","icons"],["drivers-license","id-card"],["id-card-alt","id-card-clip"],["portrait","image-portrait"],["indian-rupee","indian-rupee-sign"],["inr","indian-rupee-sign"],["fighter-jet","jet-fighter"],["first-aid","kit-medical"],["landmark-alt","landmark-dome"],["long-arrow-alt-left","left-long"],["arrows-alt-h","left-right"],["chain","link"],["chain-broken","link-slash"],["chain-slash","link-slash"],["unlink","link-slash"],["list-squares","list"],["tasks","list-check"],["list-1-2","list-ol"],["list-numeric","list-ol"],["list-dots","list-ul"],["location","location-crosshairs"],["map-marker-alt","location-dot"],["map-marker","location-pin"],["search","magnifying-glass"],["search-dollar","magnifying-glass-dollar"],["search-location","magnifying-glass-location"],["search-minus","magnifying-glass-minus"],["search-plus","magnifying-glass-plus"],["map-marked","map-location"],["map-marked-alt","map-location-dot"],["mars-stroke-h","mars-stroke-right"],["mars-stroke-v","mars-stroke-up"],["glass-martini-alt","martini-glass"],["cocktail","martini-glass-citrus"],["glass-martini","martini-glass-empty"],["theater-masks","masks-theater"],["expand-arrows-alt","maximize"],["comment-alt","message"],["microphone-alt","microphone-lines"],["microphone-alt-slash","microphone-lines-slash"],["compress-arrows-alt","minimize"],["subtract","minus"],["mobile-android","mobile"],["mobile-phone","mobile"],["mobile-android-alt","mobile-screen"],["mobile-alt","mobile-screen-button"],["money-bill-alt","money-bill-1"],["money-bill-wave-alt","money-bill-1-wave"],["money-check-alt","money-check-dollar"],["coffee","mug-saucer"],["sticky-note","note-sticky"],["dedent","outdent"],["paint-brush","paintbrush"],["file-clipboard","paste"],["pen-alt","pen-clip"],["pencil-ruler","pen-ruler"],["edit","pen-to-square"],["pencil-alt","pencil"],["people-arrows-left-right","people-arrows"],["people-carry","people-carry-box"],["percentage","percent"],["male","person"],["biking","person-biking"],["digging","person-digging"],["diagnoses","person-dots-from-line"],["female","person-dress"],["hiking","person-hiking"],["pray","person-praying"],["running","person-running"],["skating","person-skating"],["skiing","person-skiing"],["skiing-nordic","person-skiing-nordic"],["snowboarding","person-snowboarding"],["swimmer","person-swimming"],["walking","person-walking"],["blind","person-walking-with-cane"],["phone-alt","phone-flip"],["volume-control-phone","phone-volume"],["photo-video","photo-film"],["add","plus"],["poo-bolt","poo-storm"],["prescription-bottle-alt","prescription-bottle-medical"],["quote-left-alt","quote-left"],["quote-right-alt","quote-right"],["ad","rectangle-ad"],["list-alt","rectangle-list"],["rectangle-times","rectangle-xmark"],["times-rectangle","rectangle-xmark"],["window-close","rectangle-xmark"],["mail-reply","reply"],["mail-reply-all","reply-all"],["sign-out-alt","right-from-bracket"],["exchange-alt","right-left"],["long-arrow-alt-right","right-long"],["sign-in-alt","right-to-bracket"],["sync-alt","rotate"],["rotate-back","rotate-left"],["rotate-backward","rotate-left"],["undo-alt","rotate-left"],["redo-alt","rotate-right"],["rotate-forward","rotate-right"],["feed","rss"],["rouble","ruble-sign"],["rub","ruble-sign"],["ruble","ruble-sign"],["rupee","rupee-sign"],["balance-scale","scale-balanced"],["balance-scale-left","scale-unbalanced"],["balance-scale-right","scale-unbalanced-flip"],["cut","scissors"],["tools","screwdriver-wrench"],["torah","scroll-torah"],["sprout","seedling"],["triangle-circle-square","shapes"],["mail-forward","share"],["share-square","share-from-square"],["share-alt","share-nodes"],["ils","shekel-sign"],["shekel","shekel-sign"],["sheqel","shekel-sign"],["sheqel-sign","shekel-sign"],["shield-blank","shield"],["shield-alt","shield-halved"],["t-shirt","shirt"],["tshirt","shirt"],["store-alt","shop"],["store-alt-slash","shop-slash"],["random","shuffle"],["space-shuttle","shuttle-space"],["sign","sign-hanging"],["signal-5","signal"],["signal-perfect","signal"],["map-signs","signs-post"],["sliders-h","sliders"],["unsorted","sort"],["sort-desc","sort-down"],["sort-asc","sort-up"],["pastafarianism","spaghetti-monster-flying"],["utensil-spoon","spoon"],["air-freshener","spray-can-sparkles"],["external-link-square","square-arrow-up-right"],["caret-square-down","square-caret-down"],["caret-square-left","square-caret-left"],["caret-square-right","square-caret-right"],["caret-square-up","square-caret-up"],["check-square","square-check"],["envelope-square","square-envelope"],["h-square","square-h"],["minus-square","square-minus"],["parking","square-parking"],["pen-square","square-pen"],["pencil-square","square-pen"],["phone-square","square-phone"],["phone-square-alt","square-phone-flip"],["plus-square","square-plus"],["poll-h","square-poll-horizontal"],["poll","square-poll-vertical"],["square-root-alt","square-root-variable"],["rss-square","square-rss"],["share-alt-square","square-share-nodes"],["external-link-square-alt","square-up-right"],["times-square","square-xmark"],["xmark-square","square-xmark"],["rod-asclepius","staff-snake"],["rod-snake","staff-snake"],["staff-aesculapius","staff-snake"],["star-half-alt","star-half-stroke"],["gbp","sterling-sign"],["pound-sign","sterling-sign"],["medkit","suitcase-medical"],["th","table-cells"],["th-large","table-cells-large"],["columns","table-columns"],["th-list","table-list"],["ping-pong-paddle-ball","table-tennis-paddle-ball"],["table-tennis","table-tennis-paddle-ball"],["tablet-android","tablet"],["tablet-alt","tablet-screen-button"],["digital-tachograph","tachograph-digital"],["cab","taxi"],["temperature-down","temperature-arrow-down"],["temperature-up","temperature-arrow-up"],["temperature-0","temperature-empty"],["thermometer-0","temperature-empty"],["thermometer-empty","temperature-empty"],["temperature-4","temperature-full"],["thermometer-4","temperature-full"],["thermometer-full","temperature-full"],["temperature-2","temperature-half"],["thermometer-2","temperature-half"],["thermometer-half","temperature-half"],["temperature-1","temperature-quarter"],["thermometer-1","temperature-quarter"],["thermometer-quarter","temperature-quarter"],["temperature-3","temperature-three-quarters"],["thermometer-3","temperature-three-quarters"],["thermometer-three-quarters","temperature-three-quarters"],["tenge","tenge-sign"],["remove-format","text-slash"],["thumb-tack","thumbtack"],["ticket-alt","ticket-simple"],["broadcast-tower","tower-broadcast"],["subway","train-subway"],["transgender-alt","transgender"],["trash-restore","trash-arrow-up"],["trash-alt","trash-can"],["trash-restore-alt","trash-can-arrow-up"],["exclamation-triangle","triangle-exclamation"],["warning","triangle-exclamation"],["shipping-fast","truck-fast"],["ambulance","truck-medical"],["truck-loading","truck-ramp-box"],["teletype","tty"],["try","turkish-lira-sign"],["turkish-lira","turkish-lira-sign"],["level-down-alt","turn-down"],["level-up-alt","turn-up"],["television","tv"],["tv-alt","tv"],["unlock-alt","unlock-keyhole"],["arrows-alt-v","up-down"],["arrows-alt","up-down-left-right"],["long-arrow-alt-up","up-long"],["expand-alt","up-right-and-down-left-from-center"],["external-link-alt","up-right-from-square"],["user-md","user-doctor"],["user-cog","user-gear"],["user-friends","user-group"],["user-alt","user-large"],["user-alt-slash","user-large-slash"],["user-edit","user-pen"],["user-times","user-xmark"],["users-cog","users-gear"],["cutlery","utensils"],["shuttle-van","van-shuttle"],["video-camera","video"],["volleyball-ball","volleyball"],["volume-up","volume-high"],["volume-down","volume-low"],["volume-mute","volume-xmark"],["volume-times","volume-xmark"],["magic","wand-magic"],["magic-wand-sparkles","wand-magic-sparkles"],["ladder-water","water-ladder"],["swimming-pool","water-ladder"],["weight","weight-scale"],["wheat-alt","wheat-awn"],["wheelchair-alt","wheelchair-move"],["glass-whiskey","whiskey-glass"],["wifi-3","wifi"],["wifi-strong","wifi"],["wine-glass-alt","wine-glass-empty"],["krw","won-sign"],["won","won-sign"],["close","xmark"],["multiply","xmark"],["remove","xmark"],["times","xmark"],["cny","yen-sign"],["jpy","yen-sign"],["rmb","yen-sign"],["yen","yen-sign"]]),r=new Map([["0",["0",!1]],["1",["1",!1]],["2",["2",!1]],["3",["3",!1]],["4",["4",!1]],["5",["5",!1]],["6",["6",!1]],["7",["7",!1]],["8",["8",!1]],["9",["9",!1]],["a",["A",!1]],["address-book",["\uF2B9",!0]],["address-card",["\uF2BB",!0]],["align-center",["\uF037",!1]],["align-justify",["\uF039",!1]],["align-left",["\uF036",!1]],["align-right",["\uF038",!1]],["anchor",["\uF13D",!1]],["anchor-circle-check",["\uE4AA",!1]],["anchor-circle-exclamation",["\uE4AB",!1]],["anchor-circle-xmark",["\uE4AC",!1]],["anchor-lock",["\uE4AD",!1]],["angle-down",["\uF107",!1]],["angle-left",["\uF104",!1]],["angle-right",["\uF105",!1]],["angle-up",["\uF106",!1]],["angles-down",["\uF103",!1]],["angles-left",["\uF100",!1]],["angles-right",["\uF101",!1]],["angles-up",["\uF102",!1]],["ankh",["\uF644",!1]],["apple-whole",["\uF5D1",!1]],["archway",["\uF557",!1]],["arrow-down",["\uF063",!1]],["arrow-down-1-9",["\uF162",!1]],["arrow-down-9-1",["\uF886",!1]],["arrow-down-a-z",["\uF15D",!1]],["arrow-down-long",["\uF175",!1]],["arrow-down-short-wide",["\uF884",!1]],["arrow-down-up-across-line",["\uE4AF",!1]],["arrow-down-up-lock",["\uE4B0",!1]],["arrow-down-wide-short",["\uF160",!1]],["arrow-down-z-a",["\uF881",!1]],["arrow-left",["\uF060",!1]],["arrow-left-long",["\uF177",!1]],["arrow-pointer",["\uF245",!1]],["arrow-right",["\uF061",!1]],["arrow-right-arrow-left",["\uF0EC",!1]],["arrow-right-from-bracket",["\uF08B",!1]],["arrow-right-long",["\uF178",!1]],["arrow-right-to-bracket",["\uF090",!1]],["arrow-right-to-city",["\uE4B3",!1]],["arrow-rotate-left",["\uF0E2",!1]],["arrow-rotate-right",["\uF01E",!1]],["arrow-trend-down",["\uE097",!1]],["arrow-trend-up",["\uE098",!1]],["arrow-turn-down",["\uF149",!1]],["arrow-turn-up",["\uF148",!1]],["arrow-up",["\uF062",!1]],["arrow-up-1-9",["\uF163",!1]],["arrow-up-9-1",["\uF887",!1]],["arrow-up-a-z",["\uF15E",!1]],["arrow-up-from-bracket",["\uE09A",!1]],["arrow-up-from-ground-water",["\uE4B5",!1]],["arrow-up-from-water-pump",["\uE4B6",!1]],["arrow-up-long",["\uF176",!1]],["arrow-up-right-dots",["\uE4B7",!1]],["arrow-up-right-from-square",["\uF08E",!1]],["arrow-up-short-wide",["\uF885",!1]],["arrow-up-wide-short",["\uF161",!1]],["arrow-up-z-a",["\uF882",!1]],["arrows-down-to-line",["\uE4B8",!1]],["arrows-down-to-people",["\uE4B9",!1]],["arrows-left-right",["\uF07E",!1]],["arrows-left-right-to-line",["\uE4BA",!1]],["arrows-rotate",["\uF021",!1]],["arrows-spin",["\uE4BB",!1]],["arrows-split-up-and-left",["\uE4BC",!1]],["arrows-to-circle",["\uE4BD",!1]],["arrows-to-dot",["\uE4BE",!1]],["arrows-to-eye",["\uE4BF",!1]],["arrows-turn-right",["\uE4C0",!1]],["arrows-turn-to-dots",["\uE4C1",!1]],["arrows-up-down",["\uF07D",!1]],["arrows-up-down-left-right",["\uF047",!1]],["arrows-up-to-line",["\uE4C2",!1]],["asterisk",["*",!1]],["at",["@",!1]],["atom",["\uF5D2",!1]],["audio-description",["\uF29E",!1]],["austral-sign",["\uE0A9",!1]],["award",["\uF559",!1]],["b",["B",!1]],["baby",["\uF77C",!1]],["baby-carriage",["\uF77D",!1]],["backward",["\uF04A",!1]],["backward-fast",["\uF049",!1]],["backward-step",["\uF048",!1]],["bacon",["\uF7E5",!1]],["bacteria",["\uE059",!1]],["bacterium",["\uE05A",!1]],["bag-shopping",["\uF290",!1]],["bahai",["\uF666",!1]],["baht-sign",["\uE0AC",!1]],["ban",["\uF05E",!1]],["ban-smoking",["\uF54D",!1]],["bandage",["\uF462",!1]],["bangladeshi-taka-sign",["\uE2E6",!1]],["barcode",["\uF02A",!1]],["bars",["\uF0C9",!1]],["bars-progress",["\uF828",!1]],["bars-staggered",["\uF550",!1]],["baseball",["\uF433",!1]],["baseball-bat-ball",["\uF432",!1]],["basket-shopping",["\uF291",!1]],["basketball",["\uF434",!1]],["bath",["\uF2CD",!1]],["battery-empty",["\uF244",!1]],["battery-full",["\uF240",!1]],["battery-half",["\uF242",!1]],["battery-quarter",["\uF243",!1]],["battery-three-quarters",["\uF241",!1]],["bed",["\uF236",!1]],["bed-pulse",["\uF487",!1]],["beer-mug-empty",["\uF0FC",!1]],["bell",["\uF0F3",!0]],["bell-concierge",["\uF562",!1]],["bell-slash",["\uF1F6",!0]],["bezier-curve",["\uF55B",!1]],["bicycle",["\uF206",!1]],["binoculars",["\uF1E5",!1]],["biohazard",["\uF780",!1]],["bitcoin-sign",["\uE0B4",!1]],["blender",["\uF517",!1]],["blender-phone",["\uF6B6",!1]],["blog",["\uF781",!1]],["bold",["\uF032",!1]],["bolt",["\uF0E7",!1]],["bolt-lightning",["\uE0B7",!1]],["bomb",["\uF1E2",!1]],["bone",["\uF5D7",!1]],["bong",["\uF55C",!1]],["book",["\uF02D",!1]],["book-atlas",["\uF558",!1]],["book-bible",["\uF647",!1]],["book-bookmark",["\uE0BB",!1]],["book-journal-whills",["\uF66A",!1]],["book-medical",["\uF7E6",!1]],["book-open",["\uF518",!1]],["book-open-reader",["\uF5DA",!1]],["book-quran",["\uF687",!1]],["book-skull",["\uF6B7",!1]],["book-tanakh",["\uF827",!1]],["bookmark",["\uF02E",!0]],["border-all",["\uF84C",!1]],["border-none",["\uF850",!1]],["border-top-left",["\uF853",!1]],["bore-hole",["\uE4C3",!1]],["bottle-droplet",["\uE4C4",!1]],["bottle-water",["\uE4C5",!1]],["bowl-food",["\uE4C6",!1]],["bowl-rice",["\uE2EB",!1]],["bowling-ball",["\uF436",!1]],["box",["\uF466",!1]],["box-archive",["\uF187",!1]],["box-open",["\uF49E",!1]],["box-tissue",["\uE05B",!1]],["boxes-packing",["\uE4C7",!1]],["boxes-stacked",["\uF468",!1]],["braille",["\uF2A1",!1]],["brain",["\uF5DC",!1]],["brazilian-real-sign",["\uE46C",!1]],["bread-slice",["\uF7EC",!1]],["bridge",["\uE4C8",!1]],["bridge-circle-check",["\uE4C9",!1]],["bridge-circle-exclamation",["\uE4CA",!1]],["bridge-circle-xmark",["\uE4CB",!1]],["bridge-lock",["\uE4CC",!1]],["bridge-water",["\uE4CE",!1]],["briefcase",["\uF0B1",!1]],["briefcase-medical",["\uF469",!1]],["broom",["\uF51A",!1]],["broom-ball",["\uF458",!1]],["brush",["\uF55D",!1]],["bucket",["\uE4CF",!1]],["bug",["\uF188",!1]],["bug-slash",["\uE490",!1]],["bugs",["\uE4D0",!1]],["building",["\uF1AD",!0]],["building-circle-arrow-right",["\uE4D1",!1]],["building-circle-check",["\uE4D2",!1]],["building-circle-exclamation",["\uE4D3",!1]],["building-circle-xmark",["\uE4D4",!1]],["building-columns",["\uF19C",!1]],["building-flag",["\uE4D5",!1]],["building-lock",["\uE4D6",!1]],["building-ngo",["\uE4D7",!1]],["building-shield",["\uE4D8",!1]],["building-un",["\uE4D9",!1]],["building-user",["\uE4DA",!1]],["building-wheat",["\uE4DB",!1]],["bullhorn",["\uF0A1",!1]],["bullseye",["\uF140",!1]],["burger",["\uF805",!1]],["burst",["\uE4DC",!1]],["bus",["\uF207",!1]],["bus-simple",["\uF55E",!1]],["business-time",["\uF64A",!1]],["c",["C",!1]],["cable-car",["\uF7DA",!1]],["cake-candles",["\uF1FD",!1]],["calculator",["\uF1EC",!1]],["calendar",["\uF133",!0]],["calendar-check",["\uF274",!0]],["calendar-day",["\uF783",!1]],["calendar-days",["\uF073",!0]],["calendar-minus",["\uF272",!0]],["calendar-plus",["\uF271",!0]],["calendar-week",["\uF784",!1]],["calendar-xmark",["\uF273",!0]],["camera",["\uF030",!1]],["camera-retro",["\uF083",!1]],["camera-rotate",["\uE0D8",!1]],["campground",["\uF6BB",!1]],["candy-cane",["\uF786",!1]],["cannabis",["\uF55F",!1]],["capsules",["\uF46B",!1]],["car",["\uF1B9",!1]],["car-battery",["\uF5DF",!1]],["car-burst",["\uF5E1",!1]],["car-on",["\uE4DD",!1]],["car-rear",["\uF5DE",!1]],["car-side",["\uF5E4",!1]],["car-tunnel",["\uE4DE",!1]],["caravan",["\uF8FF",!1]],["caret-down",["\uF0D7",!1]],["caret-left",["\uF0D9",!1]],["caret-right",["\uF0DA",!1]],["caret-up",["\uF0D8",!1]],["carrot",["\uF787",!1]],["cart-arrow-down",["\uF218",!1]],["cart-flatbed",["\uF474",!1]],["cart-flatbed-suitcase",["\uF59D",!1]],["cart-plus",["\uF217",!1]],["cart-shopping",["\uF07A",!1]],["cash-register",["\uF788",!1]],["cat",["\uF6BE",!1]],["cedi-sign",["\uE0DF",!1]],["cent-sign",["\uE3F5",!1]],["certificate",["\uF0A3",!1]],["chair",["\uF6C0",!1]],["chalkboard",["\uF51B",!1]],["chalkboard-user",["\uF51C",!1]],["champagne-glasses",["\uF79F",!1]],["charging-station",["\uF5E7",!1]],["chart-area",["\uF1FE",!1]],["chart-bar",["\uF080",!0]],["chart-column",["\uE0E3",!1]],["chart-gantt",["\uE0E4",!1]],["chart-line",["\uF201",!1]],["chart-pie",["\uF200",!1]],["chart-simple",["\uE473",!1]],["check",["\uF00C",!1]],["check-double",["\uF560",!1]],["check-to-slot",["\uF772",!1]],["cheese",["\uF7EF",!1]],["chess",["\uF439",!1]],["chess-bishop",["\uF43A",!0]],["chess-board",["\uF43C",!1]],["chess-king",["\uF43F",!0]],["chess-knight",["\uF441",!0]],["chess-pawn",["\uF443",!0]],["chess-queen",["\uF445",!0]],["chess-rook",["\uF447",!0]],["chevron-down",["\uF078",!1]],["chevron-left",["\uF053",!1]],["chevron-right",["\uF054",!1]],["chevron-up",["\uF077",!1]],["child",["\uF1AE",!1]],["child-combatant",["\uE4E0",!1]],["child-dress",["\uE59C",!1]],["child-reaching",["\uE59D",!1]],["children",["\uE4E1",!1]],["church",["\uF51D",!1]],["circle",["\uF111",!0]],["circle-arrow-down",["\uF0AB",!1]],["circle-arrow-left",["\uF0A8",!1]],["circle-arrow-right",["\uF0A9",!1]],["circle-arrow-up",["\uF0AA",!1]],["circle-check",["\uF058",!0]],["circle-chevron-down",["\uF13A",!1]],["circle-chevron-left",["\uF137",!1]],["circle-chevron-right",["\uF138",!1]],["circle-chevron-up",["\uF139",!1]],["circle-dollar-to-slot",["\uF4B9",!1]],["circle-dot",["\uF192",!0]],["circle-down",["\uF358",!0]],["circle-exclamation",["\uF06A",!1]],["circle-h",["\uF47E",!1]],["circle-half-stroke",["\uF042",!1]],["circle-info",["\uF05A",!1]],["circle-left",["\uF359",!0]],["circle-minus",["\uF056",!1]],["circle-nodes",["\uE4E2",!1]],["circle-notch",["\uF1CE",!1]],["circle-pause",["\uF28B",!0]],["circle-play",["\uF144",!0]],["circle-plus",["\uF055",!1]],["circle-question",["\uF059",!0]],["circle-radiation",["\uF7BA",!1]],["circle-right",["\uF35A",!0]],["circle-stop",["\uF28D",!0]],["circle-up",["\uF35B",!0]],["circle-user",["\uF2BD",!0]],["circle-xmark",["\uF057",!0]],["city",["\uF64F",!1]],["clapperboard",["\uE131",!1]],["clipboard",["\uF328",!0]],["clipboard-check",["\uF46C",!1]],["clipboard-list",["\uF46D",!1]],["clipboard-question",["\uE4E3",!1]],["clipboard-user",["\uF7F3",!1]],["clock",["\uF017",!0]],["clock-rotate-left",["\uF1DA",!1]],["clone",["\uF24D",!0]],["closed-captioning",["\uF20A",!0]],["cloud",["\uF0C2",!1]],["cloud-arrow-down",["\uF0ED",!1]],["cloud-arrow-up",["\uF0EE",!1]],["cloud-bolt",["\uF76C",!1]],["cloud-meatball",["\uF73B",!1]],["cloud-moon",["\uF6C3",!1]],["cloud-moon-rain",["\uF73C",!1]],["cloud-rain",["\uF73D",!1]],["cloud-showers-heavy",["\uF740",!1]],["cloud-showers-water",["\uE4E4",!1]],["cloud-sun",["\uF6C4",!1]],["cloud-sun-rain",["\uF743",!1]],["clover",["\uE139",!1]],["code",["\uF121",!1]],["code-branch",["\uF126",!1]],["code-commit",["\uF386",!1]],["code-compare",["\uE13A",!1]],["code-fork",["\uE13B",!1]],["code-merge",["\uF387",!1]],["code-pull-request",["\uE13C",!1]],["coins",["\uF51E",!1]],["colon-sign",["\uE140",!1]],["comment",["\uF075",!0]],["comment-dollar",["\uF651",!1]],["comment-dots",["\uF4AD",!0]],["comment-medical",["\uF7F5",!1]],["comment-slash",["\uF4B3",!1]],["comment-sms",["\uF7CD",!1]],["comments",["\uF086",!0]],["comments-dollar",["\uF653",!1]],["compact-disc",["\uF51F",!1]],["compass",["\uF14E",!0]],["compass-drafting",["\uF568",!1]],["compress",["\uF066",!1]],["computer",["\uE4E5",!1]],["computer-mouse",["\uF8CC",!1]],["cookie",["\uF563",!1]],["cookie-bite",["\uF564",!1]],["copy",["\uF0C5",!0]],["copyright",["\uF1F9",!0]],["couch",["\uF4B8",!1]],["cow",["\uF6C8",!1]],["credit-card",["\uF09D",!0]],["crop",["\uF125",!1]],["crop-simple",["\uF565",!1]],["cross",["\uF654",!1]],["crosshairs",["\uF05B",!1]],["crow",["\uF520",!1]],["crown",["\uF521",!1]],["crutch",["\uF7F7",!1]],["cruzeiro-sign",["\uE152",!1]],["cube",["\uF1B2",!1]],["cubes",["\uF1B3",!1]],["cubes-stacked",["\uE4E6",!1]],["d",["D",!1]],["database",["\uF1C0",!1]],["delete-left",["\uF55A",!1]],["democrat",["\uF747",!1]],["desktop",["\uF390",!1]],["dharmachakra",["\uF655",!1]],["diagram-next",["\uE476",!1]],["diagram-predecessor",["\uE477",!1]],["diagram-project",["\uF542",!1]],["diagram-successor",["\uE47A",!1]],["diamond",["\uF219",!1]],["diamond-turn-right",["\uF5EB",!1]],["dice",["\uF522",!1]],["dice-d20",["\uF6CF",!1]],["dice-d6",["\uF6D1",!1]],["dice-five",["\uF523",!1]],["dice-four",["\uF524",!1]],["dice-one",["\uF525",!1]],["dice-six",["\uF526",!1]],["dice-three",["\uF527",!1]],["dice-two",["\uF528",!1]],["disease",["\uF7FA",!1]],["display",["\uE163",!1]],["divide",["\uF529",!1]],["dna",["\uF471",!1]],["dog",["\uF6D3",!1]],["dollar-sign",["$",!1]],["dolly",["\uF472",!1]],["dong-sign",["\uE169",!1]],["door-closed",["\uF52A",!1]],["door-open",["\uF52B",!1]],["dove",["\uF4BA",!1]],["down-left-and-up-right-to-center",["\uF422",!1]],["down-long",["\uF309",!1]],["download",["\uF019",!1]],["dragon",["\uF6D5",!1]],["draw-polygon",["\uF5EE",!1]],["droplet",["\uF043",!1]],["droplet-slash",["\uF5C7",!1]],["drum",["\uF569",!1]],["drum-steelpan",["\uF56A",!1]],["drumstick-bite",["\uF6D7",!1]],["dumbbell",["\uF44B",!1]],["dumpster",["\uF793",!1]],["dumpster-fire",["\uF794",!1]],["dungeon",["\uF6D9",!1]],["e",["E",!1]],["ear-deaf",["\uF2A4",!1]],["ear-listen",["\uF2A2",!1]],["earth-africa",["\uF57C",!1]],["earth-americas",["\uF57D",!1]],["earth-asia",["\uF57E",!1]],["earth-europe",["\uF7A2",!1]],["earth-oceania",["\uE47B",!1]],["egg",["\uF7FB",!1]],["eject",["\uF052",!1]],["elevator",["\uE16D",!1]],["ellipsis",["\uF141",!1]],["ellipsis-vertical",["\uF142",!1]],["envelope",["\uF0E0",!0]],["envelope-circle-check",["\uE4E8",!1]],["envelope-open",["\uF2B6",!0]],["envelope-open-text",["\uF658",!1]],["envelopes-bulk",["\uF674",!1]],["equals",["=",!1]],["eraser",["\uF12D",!1]],["ethernet",["\uF796",!1]],["euro-sign",["\uF153",!1]],["exclamation",["!",!1]],["expand",["\uF065",!1]],["explosion",["\uE4E9",!1]],["eye",["\uF06E",!0]],["eye-dropper",["\uF1FB",!1]],["eye-low-vision",["\uF2A8",!1]],["eye-slash",["\uF070",!0]],["f",["F",!1]],["face-angry",["\uF556",!0]],["face-dizzy",["\uF567",!0]],["face-flushed",["\uF579",!0]],["face-frown",["\uF119",!0]],["face-frown-open",["\uF57A",!0]],["face-grimace",["\uF57F",!0]],["face-grin",["\uF580",!0]],["face-grin-beam",["\uF582",!0]],["face-grin-beam-sweat",["\uF583",!0]],["face-grin-hearts",["\uF584",!0]],["face-grin-squint",["\uF585",!0]],["face-grin-squint-tears",["\uF586",!0]],["face-grin-stars",["\uF587",!0]],["face-grin-tears",["\uF588",!0]],["face-grin-tongue",["\uF589",!0]],["face-grin-tongue-squint",["\uF58A",!0]],["face-grin-tongue-wink",["\uF58B",!0]],["face-grin-wide",["\uF581",!0]],["face-grin-wink",["\uF58C",!0]],["face-kiss",["\uF596",!0]],["face-kiss-beam",["\uF597",!0]],["face-kiss-wink-heart",["\uF598",!0]],["face-laugh",["\uF599",!0]],["face-laugh-beam",["\uF59A",!0]],["face-laugh-squint",["\uF59B",!0]],["face-laugh-wink",["\uF59C",!0]],["face-meh",["\uF11A",!0]],["face-meh-blank",["\uF5A4",!0]],["face-rolling-eyes",["\uF5A5",!0]],["face-sad-cry",["\uF5B3",!0]],["face-sad-tear",["\uF5B4",!0]],["face-smile",["\uF118",!0]],["face-smile-beam",["\uF5B8",!0]],["face-smile-wink",["\uF4DA",!0]],["face-surprise",["\uF5C2",!0]],["face-tired",["\uF5C8",!0]],["fan",["\uF863",!1]],["faucet",["\uE005",!1]],["faucet-drip",["\uE006",!1]],["fax",["\uF1AC",!1]],["feather",["\uF52D",!1]],["feather-pointed",["\uF56B",!1]],["ferry",["\uE4EA",!1]],["file",["\uF15B",!0]],["file-arrow-down",["\uF56D",!1]],["file-arrow-up",["\uF574",!1]],["file-audio",["\uF1C7",!0]],["file-circle-check",["\uE5A0",!1]],["file-circle-exclamation",["\uE4EB",!1]],["file-circle-minus",["\uE4ED",!1]],["file-circle-plus",["\uE494",!1]],["file-circle-question",["\uE4EF",!1]],["file-circle-xmark",["\uE5A1",!1]],["file-code",["\uF1C9",!0]],["file-contract",["\uF56C",!1]],["file-csv",["\uF6DD",!1]],["file-excel",["\uF1C3",!0]],["file-export",["\uF56E",!1]],["file-image",["\uF1C5",!0]],["file-import",["\uF56F",!1]],["file-invoice",["\uF570",!1]],["file-invoice-dollar",["\uF571",!1]],["file-lines",["\uF15C",!0]],["file-medical",["\uF477",!1]],["file-pdf",["\uF1C1",!0]],["file-pen",["\uF31C",!1]],["file-powerpoint",["\uF1C4",!0]],["file-prescription",["\uF572",!1]],["file-shield",["\uE4F0",!1]],["file-signature",["\uF573",!1]],["file-video",["\uF1C8",!0]],["file-waveform",["\uF478",!1]],["file-word",["\uF1C2",!0]],["file-zipper",["\uF1C6",!0]],["fill",["\uF575",!1]],["fill-drip",["\uF576",!1]],["film",["\uF008",!1]],["filter",["\uF0B0",!1]],["filter-circle-dollar",["\uF662",!1]],["filter-circle-xmark",["\uE17B",!1]],["fingerprint",["\uF577",!1]],["fire",["\uF06D",!1]],["fire-burner",["\uE4F1",!1]],["fire-extinguisher",["\uF134",!1]],["fire-flame-curved",["\uF7E4",!1]],["fire-flame-simple",["\uF46A",!1]],["fish",["\uF578",!1]],["fish-fins",["\uE4F2",!1]],["flag",["\uF024",!0]],["flag-checkered",["\uF11E",!1]],["flag-usa",["\uF74D",!1]],["flask",["\uF0C3",!1]],["flask-vial",["\uE4F3",!1]],["floppy-disk",["\uF0C7",!0]],["florin-sign",["\uE184",!1]],["folder",["\uF07B",!0]],["folder-closed",["\uE185",!0]],["folder-minus",["\uF65D",!1]],["folder-open",["\uF07C",!0]],["folder-plus",["\uF65E",!1]],["folder-tree",["\uF802",!1]],["font",["\uF031",!1]],["football",["\uF44E",!1]],["forward",["\uF04E",!1]],["forward-fast",["\uF050",!1]],["forward-step",["\uF051",!1]],["franc-sign",["\uE18F",!1]],["frog",["\uF52E",!1]],["futbol",["\uF1E3",!0]],["g",["G",!1]],["gamepad",["\uF11B",!1]],["gas-pump",["\uF52F",!1]],["gauge",["\uF624",!1]],["gauge-high",["\uF625",!1]],["gauge-simple",["\uF629",!1]],["gauge-simple-high",["\uF62A",!1]],["gavel",["\uF0E3",!1]],["gear",["\uF013",!1]],["gears",["\uF085",!1]],["gem",["\uF3A5",!0]],["genderless",["\uF22D",!1]],["ghost",["\uF6E2",!1]],["gift",["\uF06B",!1]],["gifts",["\uF79C",!1]],["glass-water",["\uE4F4",!1]],["glass-water-droplet",["\uE4F5",!1]],["glasses",["\uF530",!1]],["globe",["\uF0AC",!1]],["golf-ball-tee",["\uF450",!1]],["gopuram",["\uF664",!1]],["graduation-cap",["\uF19D",!1]],["greater-than",[">",!1]],["greater-than-equal",["\uF532",!1]],["grip",["\uF58D",!1]],["grip-lines",["\uF7A4",!1]],["grip-lines-vertical",["\uF7A5",!1]],["grip-vertical",["\uF58E",!1]],["group-arrows-rotate",["\uE4F6",!1]],["guarani-sign",["\uE19A",!1]],["guitar",["\uF7A6",!1]],["gun",["\uE19B",!1]],["h",["H",!1]],["hammer",["\uF6E3",!1]],["hamsa",["\uF665",!1]],["hand",["\uF256",!0]],["hand-back-fist",["\uF255",!0]],["hand-dots",["\uF461",!1]],["hand-fist",["\uF6DE",!1]],["hand-holding",["\uF4BD",!1]],["hand-holding-dollar",["\uF4C0",!1]],["hand-holding-droplet",["\uF4C1",!1]],["hand-holding-hand",["\uE4F7",!1]],["hand-holding-heart",["\uF4BE",!1]],["hand-holding-medical",["\uE05C",!1]],["hand-lizard",["\uF258",!0]],["hand-middle-finger",["\uF806",!1]],["hand-peace",["\uF25B",!0]],["hand-point-down",["\uF0A7",!0]],["hand-point-left",["\uF0A5",!0]],["hand-point-right",["\uF0A4",!0]],["hand-point-up",["\uF0A6",!0]],["hand-pointer",["\uF25A",!0]],["hand-scissors",["\uF257",!0]],["hand-sparkles",["\uE05D",!1]],["hand-spock",["\uF259",!0]],["handcuffs",["\uE4F8",!1]],["hands",["\uF2A7",!1]],["hands-asl-interpreting",["\uF2A3",!1]],["hands-bound",["\uE4F9",!1]],["hands-bubbles",["\uE05E",!1]],["hands-clapping",["\uE1A8",!1]],["hands-holding",["\uF4C2",!1]],["hands-holding-child",["\uE4FA",!1]],["hands-holding-circle",["\uE4FB",!1]],["hands-praying",["\uF684",!1]],["handshake",["\uF2B5",!0]],["handshake-angle",["\uF4C4",!1]],["handshake-simple",["\uF4C6",!1]],["handshake-simple-slash",["\uE05F",!1]],["handshake-slash",["\uE060",!1]],["hanukiah",["\uF6E6",!1]],["hard-drive",["\uF0A0",!0]],["hashtag",["#",!1]],["hat-cowboy",["\uF8C0",!1]],["hat-cowboy-side",["\uF8C1",!1]],["hat-wizard",["\uF6E8",!1]],["head-side-cough",["\uE061",!1]],["head-side-cough-slash",["\uE062",!1]],["head-side-mask",["\uE063",!1]],["head-side-virus",["\uE064",!1]],["heading",["\uF1DC",!1]],["headphones",["\uF025",!1]],["headphones-simple",["\uF58F",!1]],["headset",["\uF590",!1]],["heart",["\uF004",!0]],["heart-circle-bolt",["\uE4FC",!1]],["heart-circle-check",["\uE4FD",!1]],["heart-circle-exclamation",["\uE4FE",!1]],["heart-circle-minus",["\uE4FF",!1]],["heart-circle-plus",["\uE500",!1]],["heart-circle-xmark",["\uE501",!1]],["heart-crack",["\uF7A9",!1]],["heart-pulse",["\uF21E",!1]],["helicopter",["\uF533",!1]],["helicopter-symbol",["\uE502",!1]],["helmet-safety",["\uF807",!1]],["helmet-un",["\uE503",!1]],["highlighter",["\uF591",!1]],["hill-avalanche",["\uE507",!1]],["hill-rockslide",["\uE508",!1]],["hippo",["\uF6ED",!1]],["hockey-puck",["\uF453",!1]],["holly-berry",["\uF7AA",!1]],["horse",["\uF6F0",!1]],["horse-head",["\uF7AB",!1]],["hospital",["\uF0F8",!0]],["hospital-user",["\uF80D",!1]],["hot-tub-person",["\uF593",!1]],["hotdog",["\uF80F",!1]],["hotel",["\uF594",!1]],["hourglass",["\uF254",!0]],["hourglass-end",["\uF253",!1]],["hourglass-half",["\uF252",!0]],["hourglass-start",["\uF251",!1]],["house",["\uF015",!1]],["house-chimney",["\uE3AF",!1]],["house-chimney-crack",["\uF6F1",!1]],["house-chimney-medical",["\uF7F2",!1]],["house-chimney-user",["\uE065",!1]],["house-chimney-window",["\uE00D",!1]],["house-circle-check",["\uE509",!1]],["house-circle-exclamation",["\uE50A",!1]],["house-circle-xmark",["\uE50B",!1]],["house-crack",["\uE3B1",!1]],["house-fire",["\uE50C",!1]],["house-flag",["\uE50D",!1]],["house-flood-water",["\uE50E",!1]],["house-flood-water-circle-arrow-right",["\uE50F",!1]],["house-laptop",["\uE066",!1]],["house-lock",["\uE510",!1]],["house-medical",["\uE3B2",!1]],["house-medical-circle-check",["\uE511",!1]],["house-medical-circle-exclamation",["\uE512",!1]],["house-medical-circle-xmark",["\uE513",!1]],["house-medical-flag",["\uE514",!1]],["house-signal",["\uE012",!1]],["house-tsunami",["\uE515",!1]],["house-user",["\uE1B0",!1]],["hryvnia-sign",["\uF6F2",!1]],["hurricane",["\uF751",!1]],["i",["I",!1]],["i-cursor",["\uF246",!1]],["ice-cream",["\uF810",!1]],["icicles",["\uF7AD",!1]],["icons",["\uF86D",!1]],["id-badge",["\uF2C1",!0]],["id-card",["\uF2C2",!0]],["id-card-clip",["\uF47F",!1]],["igloo",["\uF7AE",!1]],["image",["\uF03E",!0]],["image-portrait",["\uF3E0",!1]],["images",["\uF302",!0]],["inbox",["\uF01C",!1]],["indent",["\uF03C",!1]],["indian-rupee-sign",["\uE1BC",!1]],["industry",["\uF275",!1]],["infinity",["\uF534",!1]],["info",["\uF129",!1]],["italic",["\uF033",!1]],["j",["J",!1]],["jar",["\uE516",!1]],["jar-wheat",["\uE517",!1]],["jedi",["\uF669",!1]],["jet-fighter",["\uF0FB",!1]],["jet-fighter-up",["\uE518",!1]],["joint",["\uF595",!1]],["jug-detergent",["\uE519",!1]],["k",["K",!1]],["kaaba",["\uF66B",!1]],["key",["\uF084",!1]],["keyboard",["\uF11C",!0]],["khanda",["\uF66D",!1]],["kip-sign",["\uE1C4",!1]],["kit-medical",["\uF479",!1]],["kitchen-set",["\uE51A",!1]],["kiwi-bird",["\uF535",!1]],["l",["L",!1]],["land-mine-on",["\uE51B",!1]],["landmark",["\uF66F",!1]],["landmark-dome",["\uF752",!1]],["landmark-flag",["\uE51C",!1]],["language",["\uF1AB",!1]],["laptop",["\uF109",!1]],["laptop-code",["\uF5FC",!1]],["laptop-file",["\uE51D",!1]],["laptop-medical",["\uF812",!1]],["lari-sign",["\uE1C8",!1]],["layer-group",["\uF5FD",!1]],["leaf",["\uF06C",!1]],["left-long",["\uF30A",!1]],["left-right",["\uF337",!1]],["lemon",["\uF094",!0]],["less-than",["<",!1]],["less-than-equal",["\uF537",!1]],["life-ring",["\uF1CD",!0]],["lightbulb",["\uF0EB",!0]],["lines-leaning",["\uE51E",!1]],["link",["\uF0C1",!1]],["link-slash",["\uF127",!1]],["lira-sign",["\uF195",!1]],["list",["\uF03A",!1]],["list-check",["\uF0AE",!1]],["list-ol",["\uF0CB",!1]],["list-ul",["\uF0CA",!1]],["litecoin-sign",["\uE1D3",!1]],["location-arrow",["\uF124",!1]],["location-crosshairs",["\uF601",!1]],["location-dot",["\uF3C5",!1]],["location-pin",["\uF041",!1]],["location-pin-lock",["\uE51F",!1]],["lock",["\uF023",!1]],["lock-open",["\uF3C1",!1]],["locust",["\uE520",!1]],["lungs",["\uF604",!1]],["lungs-virus",["\uE067",!1]],["m",["M",!1]],["magnet",["\uF076",!1]],["magnifying-glass",["\uF002",!1]],["magnifying-glass-arrow-right",["\uE521",!1]],["magnifying-glass-chart",["\uE522",!1]],["magnifying-glass-dollar",["\uF688",!1]],["magnifying-glass-location",["\uF689",!1]],["magnifying-glass-minus",["\uF010",!1]],["magnifying-glass-plus",["\uF00E",!1]],["manat-sign",["\uE1D5",!1]],["map",["\uF279",!0]],["map-location",["\uF59F",!1]],["map-location-dot",["\uF5A0",!1]],["map-pin",["\uF276",!1]],["marker",["\uF5A1",!1]],["mars",["\uF222",!1]],["mars-and-venus",["\uF224",!1]],["mars-and-venus-burst",["\uE523",!1]],["mars-double",["\uF227",!1]],["mars-stroke",["\uF229",!1]],["mars-stroke-right",["\uF22B",!1]],["mars-stroke-up",["\uF22A",!1]],["martini-glass",["\uF57B",!1]],["martini-glass-citrus",["\uF561",!1]],["martini-glass-empty",["\uF000",!1]],["mask",["\uF6FA",!1]],["mask-face",["\uE1D7",!1]],["mask-ventilator",["\uE524",!1]],["masks-theater",["\uF630",!1]],["mattress-pillow",["\uE525",!1]],["maximize",["\uF31E",!1]],["medal",["\uF5A2",!1]],["memory",["\uF538",!1]],["menorah",["\uF676",!1]],["mercury",["\uF223",!1]],["message",["\uF27A",!0]],["meteor",["\uF753",!1]],["microchip",["\uF2DB",!1]],["microphone",["\uF130",!1]],["microphone-lines",["\uF3C9",!1]],["microphone-lines-slash",["\uF539",!1]],["microphone-slash",["\uF131",!1]],["microscope",["\uF610",!1]],["mill-sign",["\uE1ED",!1]],["minimize",["\uF78C",!1]],["minus",["\uF068",!1]],["mitten",["\uF7B5",!1]],["mobile",["\uF3CE",!1]],["mobile-button",["\uF10B",!1]],["mobile-retro",["\uE527",!1]],["mobile-screen",["\uF3CF",!1]],["mobile-screen-button",["\uF3CD",!1]],["money-bill",["\uF0D6",!1]],["money-bill-1",["\uF3D1",!0]],["money-bill-1-wave",["\uF53B",!1]],["money-bill-transfer",["\uE528",!1]],["money-bill-trend-up",["\uE529",!1]],["money-bill-wave",["\uF53A",!1]],["money-bill-wheat",["\uE52A",!1]],["money-bills",["\uE1F3",!1]],["money-check",["\uF53C",!1]],["money-check-dollar",["\uF53D",!1]],["monument",["\uF5A6",!1]],["moon",["\uF186",!0]],["mortar-pestle",["\uF5A7",!1]],["mosque",["\uF678",!1]],["mosquito",["\uE52B",!1]],["mosquito-net",["\uE52C",!1]],["motorcycle",["\uF21C",!1]],["mound",["\uE52D",!1]],["mountain",["\uF6FC",!1]],["mountain-city",["\uE52E",!1]],["mountain-sun",["\uE52F",!1]],["mug-hot",["\uF7B6",!1]],["mug-saucer",["\uF0F4",!1]],["music",["\uF001",!1]],["n",["N",!1]],["naira-sign",["\uE1F6",!1]],["network-wired",["\uF6FF",!1]],["neuter",["\uF22C",!1]],["newspaper",["\uF1EA",!0]],["not-equal",["\uF53E",!1]],["notdef",["\uE1FE",!1]],["note-sticky",["\uF249",!0]],["notes-medical",["\uF481",!1]],["o",["O",!1]],["object-group",["\uF247",!0]],["object-ungroup",["\uF248",!0]],["oil-can",["\uF613",!1]],["oil-well",["\uE532",!1]],["om",["\uF679",!1]],["otter",["\uF700",!1]],["outdent",["\uF03B",!1]],["p",["P",!1]],["pager",["\uF815",!1]],["paint-roller",["\uF5AA",!1]],["paintbrush",["\uF1FC",!1]],["palette",["\uF53F",!1]],["pallet",["\uF482",!1]],["panorama",["\uE209",!1]],["paper-plane",["\uF1D8",!0]],["paperclip",["\uF0C6",!1]],["parachute-box",["\uF4CD",!1]],["paragraph",["\uF1DD",!1]],["passport",["\uF5AB",!1]],["paste",["\uF0EA",!0]],["pause",["\uF04C",!1]],["paw",["\uF1B0",!1]],["peace",["\uF67C",!1]],["pen",["\uF304",!1]],["pen-clip",["\uF305",!1]],["pen-fancy",["\uF5AC",!1]],["pen-nib",["\uF5AD",!1]],["pen-ruler",["\uF5AE",!1]],["pen-to-square",["\uF044",!0]],["pencil",["\uF303",!1]],["people-arrows",["\uE068",!1]],["people-carry-box",["\uF4CE",!1]],["people-group",["\uE533",!1]],["people-line",["\uE534",!1]],["people-pulling",["\uE535",!1]],["people-robbery",["\uE536",!1]],["people-roof",["\uE537",!1]],["pepper-hot",["\uF816",!1]],["percent",["%",!1]],["person",["\uF183",!1]],["person-arrow-down-to-line",["\uE538",!1]],["person-arrow-up-from-line",["\uE539",!1]],["person-biking",["\uF84A",!1]],["person-booth",["\uF756",!1]],["person-breastfeeding",["\uE53A",!1]],["person-burst",["\uE53B",!1]],["person-cane",["\uE53C",!1]],["person-chalkboard",["\uE53D",!1]],["person-circle-check",["\uE53E",!1]],["person-circle-exclamation",["\uE53F",!1]],["person-circle-minus",["\uE540",!1]],["person-circle-plus",["\uE541",!1]],["person-circle-question",["\uE542",!1]],["person-circle-xmark",["\uE543",!1]],["person-digging",["\uF85E",!1]],["person-dots-from-line",["\uF470",!1]],["person-dress",["\uF182",!1]],["person-dress-burst",["\uE544",!1]],["person-drowning",["\uE545",!1]],["person-falling",["\uE546",!1]],["person-falling-burst",["\uE547",!1]],["person-half-dress",["\uE548",!1]],["person-harassing",["\uE549",!1]],["person-hiking",["\uF6EC",!1]],["person-military-pointing",["\uE54A",!1]],["person-military-rifle",["\uE54B",!1]],["person-military-to-person",["\uE54C",!1]],["person-praying",["\uF683",!1]],["person-pregnant",["\uE31E",!1]],["person-rays",["\uE54D",!1]],["person-rifle",["\uE54E",!1]],["person-running",["\uF70C",!1]],["person-shelter",["\uE54F",!1]],["person-skating",["\uF7C5",!1]],["person-skiing",["\uF7C9",!1]],["person-skiing-nordic",["\uF7CA",!1]],["person-snowboarding",["\uF7CE",!1]],["person-swimming",["\uF5C4",!1]],["person-through-window",["\uE5A9",!1]],["person-walking",["\uF554",!1]],["person-walking-arrow-loop-left",["\uE551",!1]],["person-walking-arrow-right",["\uE552",!1]],["person-walking-dashed-line-arrow-right",["\uE553",!1]],["person-walking-luggage",["\uE554",!1]],["person-walking-with-cane",["\uF29D",!1]],["peseta-sign",["\uE221",!1]],["peso-sign",["\uE222",!1]],["phone",["\uF095",!1]],["phone-flip",["\uF879",!1]],["phone-slash",["\uF3DD",!1]],["phone-volume",["\uF2A0",!1]],["photo-film",["\uF87C",!1]],["piggy-bank",["\uF4D3",!1]],["pills",["\uF484",!1]],["pizza-slice",["\uF818",!1]],["place-of-worship",["\uF67F",!1]],["plane",["\uF072",!1]],["plane-arrival",["\uF5AF",!1]],["plane-circle-check",["\uE555",!1]],["plane-circle-exclamation",["\uE556",!1]],["plane-circle-xmark",["\uE557",!1]],["plane-departure",["\uF5B0",!1]],["plane-lock",["\uE558",!1]],["plane-slash",["\uE069",!1]],["plane-up",["\uE22D",!1]],["plant-wilt",["\uE5AA",!1]],["plate-wheat",["\uE55A",!1]],["play",["\uF04B",!1]],["plug",["\uF1E6",!1]],["plug-circle-bolt",["\uE55B",!1]],["plug-circle-check",["\uE55C",!1]],["plug-circle-exclamation",["\uE55D",!1]],["plug-circle-minus",["\uE55E",!1]],["plug-circle-plus",["\uE55F",!1]],["plug-circle-xmark",["\uE560",!1]],["plus",["+",!1]],["plus-minus",["\uE43C",!1]],["podcast",["\uF2CE",!1]],["poo",["\uF2FE",!1]],["poo-storm",["\uF75A",!1]],["poop",["\uF619",!1]],["power-off",["\uF011",!1]],["prescription",["\uF5B1",!1]],["prescription-bottle",["\uF485",!1]],["prescription-bottle-medical",["\uF486",!1]],["print",["\uF02F",!1]],["pump-medical",["\uE06A",!1]],["pump-soap",["\uE06B",!1]],["puzzle-piece",["\uF12E",!1]],["q",["Q",!1]],["qrcode",["\uF029",!1]],["question",["?",!1]],["quote-left",["\uF10D",!1]],["quote-right",["\uF10E",!1]],["r",["R",!1]],["radiation",["\uF7B9",!1]],["radio",["\uF8D7",!1]],["rainbow",["\uF75B",!1]],["ranking-star",["\uE561",!1]],["receipt",["\uF543",!1]],["record-vinyl",["\uF8D9",!1]],["rectangle-ad",["\uF641",!1]],["rectangle-list",["\uF022",!0]],["rectangle-xmark",["\uF410",!0]],["recycle",["\uF1B8",!1]],["registered",["\uF25D",!0]],["repeat",["\uF363",!1]],["reply",["\uF3E5",!1]],["reply-all",["\uF122",!1]],["republican",["\uF75E",!1]],["restroom",["\uF7BD",!1]],["retweet",["\uF079",!1]],["ribbon",["\uF4D6",!1]],["right-from-bracket",["\uF2F5",!1]],["right-left",["\uF362",!1]],["right-long",["\uF30B",!1]],["right-to-bracket",["\uF2F6",!1]],["ring",["\uF70B",!1]],["road",["\uF018",!1]],["road-barrier",["\uE562",!1]],["road-bridge",["\uE563",!1]],["road-circle-check",["\uE564",!1]],["road-circle-exclamation",["\uE565",!1]],["road-circle-xmark",["\uE566",!1]],["road-lock",["\uE567",!1]],["road-spikes",["\uE568",!1]],["robot",["\uF544",!1]],["rocket",["\uF135",!1]],["rotate",["\uF2F1",!1]],["rotate-left",["\uF2EA",!1]],["rotate-right",["\uF2F9",!1]],["route",["\uF4D7",!1]],["rss",["\uF09E",!1]],["ruble-sign",["\uF158",!1]],["rug",["\uE569",!1]],["ruler",["\uF545",!1]],["ruler-combined",["\uF546",!1]],["ruler-horizontal",["\uF547",!1]],["ruler-vertical",["\uF548",!1]],["rupee-sign",["\uF156",!1]],["rupiah-sign",["\uE23D",!1]],["s",["S",!1]],["sack-dollar",["\uF81D",!1]],["sack-xmark",["\uE56A",!1]],["sailboat",["\uE445",!1]],["satellite",["\uF7BF",!1]],["satellite-dish",["\uF7C0",!1]],["scale-balanced",["\uF24E",!1]],["scale-unbalanced",["\uF515",!1]],["scale-unbalanced-flip",["\uF516",!1]],["school",["\uF549",!1]],["school-circle-check",["\uE56B",!1]],["school-circle-exclamation",["\uE56C",!1]],["school-circle-xmark",["\uE56D",!1]],["school-flag",["\uE56E",!1]],["school-lock",["\uE56F",!1]],["scissors",["\uF0C4",!1]],["screwdriver",["\uF54A",!1]],["screwdriver-wrench",["\uF7D9",!1]],["scroll",["\uF70E",!1]],["scroll-torah",["\uF6A0",!1]],["sd-card",["\uF7C2",!1]],["section",["\uE447",!1]],["seedling",["\uF4D8",!1]],["server",["\uF233",!1]],["shapes",["\uF61F",!1]],["share",["\uF064",!1]],["share-from-square",["\uF14D",!0]],["share-nodes",["\uF1E0",!1]],["sheet-plastic",["\uE571",!1]],["shekel-sign",["\uF20B",!1]],["shield",["\uF132",!1]],["shield-cat",["\uE572",!1]],["shield-dog",["\uE573",!1]],["shield-halved",["\uF3ED",!1]],["shield-heart",["\uE574",!1]],["shield-virus",["\uE06C",!1]],["ship",["\uF21A",!1]],["shirt",["\uF553",!1]],["shoe-prints",["\uF54B",!1]],["shop",["\uF54F",!1]],["shop-lock",["\uE4A5",!1]],["shop-slash",["\uE070",!1]],["shower",["\uF2CC",!1]],["shrimp",["\uE448",!1]],["shuffle",["\uF074",!1]],["shuttle-space",["\uF197",!1]],["sign-hanging",["\uF4D9",!1]],["signal",["\uF012",!1]],["signature",["\uF5B7",!1]],["signs-post",["\uF277",!1]],["sim-card",["\uF7C4",!1]],["sink",["\uE06D",!1]],["sitemap",["\uF0E8",!1]],["skull",["\uF54C",!1]],["skull-crossbones",["\uF714",!1]],["slash",["\uF715",!1]],["sleigh",["\uF7CC",!1]],["sliders",["\uF1DE",!1]],["smog",["\uF75F",!1]],["smoking",["\uF48D",!1]],["snowflake",["\uF2DC",!0]],["snowman",["\uF7D0",!1]],["snowplow",["\uF7D2",!1]],["soap",["\uE06E",!1]],["socks",["\uF696",!1]],["solar-panel",["\uF5BA",!1]],["sort",["\uF0DC",!1]],["sort-down",["\uF0DD",!1]],["sort-up",["\uF0DE",!1]],["spa",["\uF5BB",!1]],["spaghetti-monster-flying",["\uF67B",!1]],["spell-check",["\uF891",!1]],["spider",["\uF717",!1]],["spinner",["\uF110",!1]],["splotch",["\uF5BC",!1]],["spoon",["\uF2E5",!1]],["spray-can",["\uF5BD",!1]],["spray-can-sparkles",["\uF5D0",!1]],["square",["\uF0C8",!0]],["square-arrow-up-right",["\uF14C",!1]],["square-caret-down",["\uF150",!0]],["square-caret-left",["\uF191",!0]],["square-caret-right",["\uF152",!0]],["square-caret-up",["\uF151",!0]],["square-check",["\uF14A",!0]],["square-envelope",["\uF199",!1]],["square-full",["\uF45C",!0]],["square-h",["\uF0FD",!1]],["square-minus",["\uF146",!0]],["square-nfi",["\uE576",!1]],["square-parking",["\uF540",!1]],["square-pen",["\uF14B",!1]],["square-person-confined",["\uE577",!1]],["square-phone",["\uF098",!1]],["square-phone-flip",["\uF87B",!1]],["square-plus",["\uF0FE",!0]],["square-poll-horizontal",["\uF682",!1]],["square-poll-vertical",["\uF681",!1]],["square-root-variable",["\uF698",!1]],["square-rss",["\uF143",!1]],["square-share-nodes",["\uF1E1",!1]],["square-up-right",["\uF360",!1]],["square-virus",["\uE578",!1]],["square-xmark",["\uF2D3",!1]],["staff-snake",["\uE579",!1]],["stairs",["\uE289",!1]],["stamp",["\uF5BF",!1]],["stapler",["\uE5AF",!1]],["star",["\uF005",!0]],["star-and-crescent",["\uF699",!1]],["star-half",["\uF089",!0]],["star-half-stroke",["\uF5C0",!0]],["star-of-david",["\uF69A",!1]],["star-of-life",["\uF621",!1]],["sterling-sign",["\uF154",!1]],["stethoscope",["\uF0F1",!1]],["stop",["\uF04D",!1]],["stopwatch",["\uF2F2",!1]],["stopwatch-20",["\uE06F",!1]],["store",["\uF54E",!1]],["store-slash",["\uE071",!1]],["street-view",["\uF21D",!1]],["strikethrough",["\uF0CC",!1]],["stroopwafel",["\uF551",!1]],["subscript",["\uF12C",!1]],["suitcase",["\uF0F2",!1]],["suitcase-medical",["\uF0FA",!1]],["suitcase-rolling",["\uF5C1",!1]],["sun",["\uF185",!0]],["sun-plant-wilt",["\uE57A",!1]],["superscript",["\uF12B",!1]],["swatchbook",["\uF5C3",!1]],["synagogue",["\uF69B",!1]],["syringe",["\uF48E",!1]],["t",["T",!1]],["table",["\uF0CE",!1]],["table-cells",["\uF00A",!1]],["table-cells-large",["\uF009",!1]],["table-columns",["\uF0DB",!1]],["table-list",["\uF00B",!1]],["table-tennis-paddle-ball",["\uF45D",!1]],["tablet",["\uF3FB",!1]],["tablet-button",["\uF10A",!1]],["tablet-screen-button",["\uF3FA",!1]],["tablets",["\uF490",!1]],["tachograph-digital",["\uF566",!1]],["tag",["\uF02B",!1]],["tags",["\uF02C",!1]],["tape",["\uF4DB",!1]],["tarp",["\uE57B",!1]],["tarp-droplet",["\uE57C",!1]],["taxi",["\uF1BA",!1]],["teeth",["\uF62E",!1]],["teeth-open",["\uF62F",!1]],["temperature-arrow-down",["\uE03F",!1]],["temperature-arrow-up",["\uE040",!1]],["temperature-empty",["\uF2CB",!1]],["temperature-full",["\uF2C7",!1]],["temperature-half",["\uF2C9",!1]],["temperature-high",["\uF769",!1]],["temperature-low",["\uF76B",!1]],["temperature-quarter",["\uF2CA",!1]],["temperature-three-quarters",["\uF2C8",!1]],["tenge-sign",["\uF7D7",!1]],["tent",["\uE57D",!1]],["tent-arrow-down-to-line",["\uE57E",!1]],["tent-arrow-left-right",["\uE57F",!1]],["tent-arrow-turn-left",["\uE580",!1]],["tent-arrows-down",["\uE581",!1]],["tents",["\uE582",!1]],["terminal",["\uF120",!1]],["text-height",["\uF034",!1]],["text-slash",["\uF87D",!1]],["text-width",["\uF035",!1]],["thermometer",["\uF491",!1]],["thumbs-down",["\uF165",!0]],["thumbs-up",["\uF164",!0]],["thumbtack",["\uF08D",!1]],["ticket",["\uF145",!1]],["ticket-simple",["\uF3FF",!1]],["timeline",["\uE29C",!1]],["toggle-off",["\uF204",!1]],["toggle-on",["\uF205",!1]],["toilet",["\uF7D8",!1]],["toilet-paper",["\uF71E",!1]],["toilet-paper-slash",["\uE072",!1]],["toilet-portable",["\uE583",!1]],["toilets-portable",["\uE584",!1]],["toolbox",["\uF552",!1]],["tooth",["\uF5C9",!1]],["torii-gate",["\uF6A1",!1]],["tornado",["\uF76F",!1]],["tower-broadcast",["\uF519",!1]],["tower-cell",["\uE585",!1]],["tower-observation",["\uE586",!1]],["tractor",["\uF722",!1]],["trademark",["\uF25C",!1]],["traffic-light",["\uF637",!1]],["trailer",["\uE041",!1]],["train",["\uF238",!1]],["train-subway",["\uF239",!1]],["train-tram",["\uE5B4",!1]],["transgender",["\uF225",!1]],["trash",["\uF1F8",!1]],["trash-arrow-up",["\uF829",!1]],["trash-can",["\uF2ED",!0]],["trash-can-arrow-up",["\uF82A",!1]],["tree",["\uF1BB",!1]],["tree-city",["\uE587",!1]],["triangle-exclamation",["\uF071",!1]],["trophy",["\uF091",!1]],["trowel",["\uE589",!1]],["trowel-bricks",["\uE58A",!1]],["truck",["\uF0D1",!1]],["truck-arrow-right",["\uE58B",!1]],["truck-droplet",["\uE58C",!1]],["truck-fast",["\uF48B",!1]],["truck-field",["\uE58D",!1]],["truck-field-un",["\uE58E",!1]],["truck-front",["\uE2B7",!1]],["truck-medical",["\uF0F9",!1]],["truck-monster",["\uF63B",!1]],["truck-moving",["\uF4DF",!1]],["truck-pickup",["\uF63C",!1]],["truck-plane",["\uE58F",!1]],["truck-ramp-box",["\uF4DE",!1]],["tty",["\uF1E4",!1]],["turkish-lira-sign",["\uE2BB",!1]],["turn-down",["\uF3BE",!1]],["turn-up",["\uF3BF",!1]],["tv",["\uF26C",!1]],["u",["U",!1]],["umbrella",["\uF0E9",!1]],["umbrella-beach",["\uF5CA",!1]],["underline",["\uF0CD",!1]],["universal-access",["\uF29A",!1]],["unlock",["\uF09C",!1]],["unlock-keyhole",["\uF13E",!1]],["up-down",["\uF338",!1]],["up-down-left-right",["\uF0B2",!1]],["up-long",["\uF30C",!1]],["up-right-and-down-left-from-center",["\uF424",!1]],["up-right-from-square",["\uF35D",!1]],["upload",["\uF093",!1]],["user",["\uF007",!0]],["user-astronaut",["\uF4FB",!1]],["user-check",["\uF4FC",!1]],["user-clock",["\uF4FD",!1]],["user-doctor",["\uF0F0",!1]],["user-gear",["\uF4FE",!1]],["user-graduate",["\uF501",!1]],["user-group",["\uF500",!1]],["user-injured",["\uF728",!1]],["user-large",["\uF406",!1]],["user-large-slash",["\uF4FA",!1]],["user-lock",["\uF502",!1]],["user-minus",["\uF503",!1]],["user-ninja",["\uF504",!1]],["user-nurse",["\uF82F",!1]],["user-pen",["\uF4FF",!1]],["user-plus",["\uF234",!1]],["user-secret",["\uF21B",!1]],["user-shield",["\uF505",!1]],["user-slash",["\uF506",!1]],["user-tag",["\uF507",!1]],["user-tie",["\uF508",!1]],["user-xmark",["\uF235",!1]],["users",["\uF0C0",!1]],["users-between-lines",["\uE591",!1]],["users-gear",["\uF509",!1]],["users-line",["\uE592",!1]],["users-rays",["\uE593",!1]],["users-rectangle",["\uE594",!1]],["users-slash",["\uE073",!1]],["users-viewfinder",["\uE595",!1]],["utensils",["\uF2E7",!1]],["v",["V",!1]],["van-shuttle",["\uF5B6",!1]],["vault",["\uE2C5",!1]],["vector-square",["\uF5CB",!1]],["venus",["\uF221",!1]],["venus-double",["\uF226",!1]],["venus-mars",["\uF228",!1]],["vest",["\uE085",!1]],["vest-patches",["\uE086",!1]],["vial",["\uF492",!1]],["vial-circle-check",["\uE596",!1]],["vial-virus",["\uE597",!1]],["vials",["\uF493",!1]],["video",["\uF03D",!1]],["video-slash",["\uF4E2",!1]],["vihara",["\uF6A7",!1]],["virus",["\uE074",!1]],["virus-covid",["\uE4A8",!1]],["virus-covid-slash",["\uE4A9",!1]],["virus-slash",["\uE075",!1]],["viruses",["\uE076",!1]],["voicemail",["\uF897",!1]],["volcano",["\uF770",!1]],["volleyball",["\uF45F",!1]],["volume-high",["\uF028",!1]],["volume-low",["\uF027",!1]],["volume-off",["\uF026",!1]],["volume-xmark",["\uF6A9",!1]],["vr-cardboard",["\uF729",!1]],["w",["W",!1]],["walkie-talkie",["\uF8EF",!1]],["wallet",["\uF555",!1]],["wand-magic",["\uF0D0",!1]],["wand-magic-sparkles",["\uE2CA",!1]],["wand-sparkles",["\uF72B",!1]],["warehouse",["\uF494",!1]],["water",["\uF773",!1]],["water-ladder",["\uF5C5",!1]],["wave-square",["\uF83E",!1]],["weight-hanging",["\uF5CD",!1]],["weight-scale",["\uF496",!1]],["wheat-awn",["\uE2CD",!1]],["wheat-awn-circle-exclamation",["\uE598",!1]],["wheelchair",["\uF193",!1]],["wheelchair-move",["\uE2CE",!1]],["whiskey-glass",["\uF7A0",!1]],["wifi",["\uF1EB",!1]],["wind",["\uF72E",!1]],["window-maximize",["\uF2D0",!0]],["window-minimize",["\uF2D1",!0]],["window-restore",["\uF2D2",!0]],["wine-bottle",["\uF72F",!1]],["wine-glass",["\uF4E3",!1]],["wine-glass-empty",["\uF5CE",!1]],["won-sign",["\uF159",!1]],["worm",["\uE599",!1]],["wrench",["\uF0AD",!1]],["x",["X",!1]],["x-ray",["\uF497",!1]],["xmark",["\uF00D",!1]],["xmarks-lines",["\uE59A",!1]],["y",["Y",!1]],["yen-sign",["\uF157",!1]],["yin-yang",["\uF6AD",!1]],["z",["Z",!1]]]);window.getFontAwesome6Metadata=()=>new Map(r),window.getFontAwesome6IconMetadata=n=>r.get(e.get(n)||n)})();(()=>{let e=new Map([[16,14],[24,18],[32,28],[48,42],[64,56],[96,84],[128,112],[144,130]]);class r extends HTMLElement{root=void 0;svgStyle=document.createElement("style");connectedCallback(){this.validate();let f=this.getRoot(),t=document.createElement("slot");f.append(t),this.setAttribute("aria-hidden","true"),this.translate=!1}validate(){if(this.size===0)throw new TypeError("Must provide an icon size.");if(!e.has(this.size))throw new TypeError("Must provide a valid icon size.")}getRoot(){return this.root===void 0&&(this.root=this.attachShadow({mode:"open"}),this.updateRenderSize(),this.root.append(this.svgStyle)),this.root}updateRenderSize(){let f=e.get(this.size);this.svgStyle.textContent=` + `;return new Function("Language","h","v",r)}var V=class{compiled;constructor(r){try{this.compiled=Me(r)}catch(i){throw i instanceof Error&&console.debug(i.message),i}}fetch(r){return this.compiled(ie,{selectPlural:Se,escapeHTML:Ae,formatNumeric:de},r)}};function Ie(e,r){typeof r=="string"?G(e,ze(r)):G(e,function(){return r})}function ze(e){if(!e.includes("{"))return function(){return e};try{let r=new V(e);return r.fetch.bind(r)}catch{return function(){return e}}}var Pe=(()=>{let e="DOMContentLoaded",r=new WeakMap,i=[],c=l=>{do if(l.nextSibling)return!0;while(l=l.parentNode);return!1},t=()=>{i.splice(0).forEach(l=>{r.get(l[0])!==!0&&(r.set(l[0],!0),l[0][l[1]]())})};document.addEventListener(e,t);class a extends HTMLElement{static withParsedCallback(d,g="parsed"){let{prototype:w}=d,{connectedCallback:q}=w,S=g+"Callback",z=(h,m,p,E)=>{m.disconnect(),p.removeEventListener(e,E),P(h)},P=h=>{i.length||requestAnimationFrame(t),i.push([h,S])};return Object.defineProperties(w,{connectedCallback:{configurable:!0,writable:!0,value(){if(q&&q.apply(this,arguments),S in this&&!r.has(this)){let h=this,{ownerDocument:m}=h;if(r.set(h,!1),m.readyState==="complete"||c(h))P(h);else{let p=()=>z(h,E,m,p);m.addEventListener(e,p);let E=new MutationObserver(()=>{c(h)&&z(h,E,m,p)});E.observe(h.parentNode,{childList:!0,subtree:!0})}}}},[g]:{configurable:!0,get(){return r.get(this)===!0}}}),d}}return a.withParsedCallback(a)})(),pe=Pe;(()=>{let e=new Map([["contact-book","address-book"],["contact-card","address-card"],["vcard","address-card"],["angle-double-down","angles-down"],["angle-double-left","angles-left"],["angle-double-right","angles-right"],["angle-double-up","angles-up"],["apple-alt","apple-whole"],["sort-numeric-asc","arrow-down-1-9"],["sort-numeric-down","arrow-down-1-9"],["sort-numeric-desc","arrow-down-9-1"],["sort-numeric-down-alt","arrow-down-9-1"],["sort-alpha-asc","arrow-down-a-z"],["sort-alpha-down","arrow-down-a-z"],["long-arrow-down","arrow-down-long"],["sort-amount-desc","arrow-down-short-wide"],["sort-amount-down-alt","arrow-down-short-wide"],["sort-amount-asc","arrow-down-wide-short"],["sort-amount-down","arrow-down-wide-short"],["sort-alpha-desc","arrow-down-z-a"],["sort-alpha-down-alt","arrow-down-z-a"],["long-arrow-left","arrow-left-long"],["mouse-pointer","arrow-pointer"],["exchange","arrow-right-arrow-left"],["sign-out","arrow-right-from-bracket"],["long-arrow-right","arrow-right-long"],["sign-in","arrow-right-to-bracket"],["arrow-left-rotate","arrow-rotate-left"],["arrow-rotate-back","arrow-rotate-left"],["arrow-rotate-backward","arrow-rotate-left"],["undo","arrow-rotate-left"],["arrow-right-rotate","arrow-rotate-right"],["arrow-rotate-forward","arrow-rotate-right"],["redo","arrow-rotate-right"],["level-down","arrow-turn-down"],["level-up","arrow-turn-up"],["sort-numeric-up","arrow-up-1-9"],["sort-numeric-up-alt","arrow-up-9-1"],["sort-alpha-up","arrow-up-a-z"],["long-arrow-up","arrow-up-long"],["external-link","arrow-up-right-from-square"],["sort-amount-up-alt","arrow-up-short-wide"],["sort-amount-up","arrow-up-wide-short"],["sort-alpha-up-alt","arrow-up-z-a"],["arrows-h","arrows-left-right"],["refresh","arrows-rotate"],["sync","arrows-rotate"],["arrows-v","arrows-up-down"],["arrows","arrows-up-down-left-right"],["carriage-baby","baby-carriage"],["fast-backward","backward-fast"],["step-backward","backward-step"],["shopping-bag","bag-shopping"],["haykal","bahai"],["cancel","ban"],["smoking-ban","ban-smoking"],["band-aid","bandage"],["navicon","bars"],["tasks-alt","bars-progress"],["reorder","bars-staggered"],["stream","bars-staggered"],["baseball-ball","baseball"],["shopping-basket","basket-shopping"],["basketball-ball","basketball"],["bathtub","bath"],["battery-0","battery-empty"],["battery","battery-full"],["battery-5","battery-full"],["battery-3","battery-half"],["battery-2","battery-quarter"],["battery-4","battery-three-quarters"],["procedures","bed-pulse"],["beer","beer-mug-empty"],["concierge-bell","bell-concierge"],["zap","bolt"],["atlas","book-atlas"],["bible","book-bible"],["journal-whills","book-journal-whills"],["book-reader","book-open-reader"],["quran","book-quran"],["book-dead","book-skull"],["tanakh","book-tanakh"],["border-style","border-top-left"],["archive","box-archive"],["boxes","boxes-stacked"],["boxes-alt","boxes-stacked"],["quidditch","broom-ball"],["quidditch-broom-ball","broom-ball"],["bank","building-columns"],["institution","building-columns"],["museum","building-columns"],["university","building-columns"],["hamburger","burger"],["bus-alt","bus-simple"],["briefcase-clock","business-time"],["tram","cable-car"],["birthday-cake","cake-candles"],["cake","cake-candles"],["calendar-alt","calendar-days"],["calendar-times","calendar-xmark"],["camera-alt","camera"],["automobile","car"],["battery-car","car-battery"],["car-crash","car-burst"],["car-alt","car-rear"],["dolly-flatbed","cart-flatbed"],["luggage-cart","cart-flatbed-suitcase"],["shopping-cart","cart-shopping"],["blackboard","chalkboard"],["chalkboard-teacher","chalkboard-user"],["glass-cheers","champagne-glasses"],["area-chart","chart-area"],["bar-chart","chart-bar"],["line-chart","chart-line"],["pie-chart","chart-pie"],["vote-yea","check-to-slot"],["child-rifle","child-combatant"],["arrow-circle-down","circle-arrow-down"],["arrow-circle-left","circle-arrow-left"],["arrow-circle-right","circle-arrow-right"],["arrow-circle-up","circle-arrow-up"],["check-circle","circle-check"],["chevron-circle-down","circle-chevron-down"],["chevron-circle-left","circle-chevron-left"],["chevron-circle-right","circle-chevron-right"],["chevron-circle-up","circle-chevron-up"],["donate","circle-dollar-to-slot"],["dot-circle","circle-dot"],["arrow-alt-circle-down","circle-down"],["exclamation-circle","circle-exclamation"],["hospital-symbol","circle-h"],["adjust","circle-half-stroke"],["info-circle","circle-info"],["arrow-alt-circle-left","circle-left"],["minus-circle","circle-minus"],["pause-circle","circle-pause"],["play-circle","circle-play"],["plus-circle","circle-plus"],["question-circle","circle-question"],["radiation-alt","circle-radiation"],["arrow-alt-circle-right","circle-right"],["stop-circle","circle-stop"],["arrow-alt-circle-up","circle-up"],["user-circle","circle-user"],["times-circle","circle-xmark"],["xmark-circle","circle-xmark"],["clock-four","clock"],["history","clock-rotate-left"],["cloud-download","cloud-arrow-down"],["cloud-download-alt","cloud-arrow-down"],["cloud-upload","cloud-arrow-up"],["cloud-upload-alt","cloud-arrow-up"],["thunderstorm","cloud-bolt"],["commenting","comment-dots"],["sms","comment-sms"],["drafting-compass","compass-drafting"],["mouse","computer-mouse"],["credit-card-alt","credit-card"],["crop-alt","crop-simple"],["backspace","delete-left"],["desktop-alt","desktop"],["project-diagram","diagram-project"],["directions","diamond-turn-right"],["dollar","dollar-sign"],["usd","dollar-sign"],["dolly-box","dolly"],["compress-alt","down-left-and-up-right-to-center"],["long-arrow-alt-down","down-long"],["tint","droplet"],["tint-slash","droplet-slash"],["deaf","ear-deaf"],["deafness","ear-deaf"],["hard-of-hearing","ear-deaf"],["assistive-listening-systems","ear-listen"],["globe-africa","earth-africa"],["earth","earth-americas"],["earth-america","earth-americas"],["globe-americas","earth-americas"],["globe-asia","earth-asia"],["globe-europe","earth-europe"],["globe-oceania","earth-oceania"],["ellipsis-h","ellipsis"],["ellipsis-v","ellipsis-vertical"],["mail-bulk","envelopes-bulk"],["eur","euro-sign"],["euro","euro-sign"],["eye-dropper-empty","eye-dropper"],["eyedropper","eye-dropper"],["low-vision","eye-low-vision"],["angry","face-angry"],["dizzy","face-dizzy"],["flushed","face-flushed"],["frown","face-frown"],["frown-open","face-frown-open"],["grimace","face-grimace"],["grin","face-grin"],["grin-beam","face-grin-beam"],["grin-beam-sweat","face-grin-beam-sweat"],["grin-hearts","face-grin-hearts"],["grin-squint","face-grin-squint"],["grin-squint-tears","face-grin-squint-tears"],["grin-stars","face-grin-stars"],["grin-tears","face-grin-tears"],["grin-tongue","face-grin-tongue"],["grin-tongue-squint","face-grin-tongue-squint"],["grin-tongue-wink","face-grin-tongue-wink"],["grin-alt","face-grin-wide"],["grin-wink","face-grin-wink"],["kiss","face-kiss"],["kiss-beam","face-kiss-beam"],["kiss-wink-heart","face-kiss-wink-heart"],["laugh","face-laugh"],["laugh-beam","face-laugh-beam"],["laugh-squint","face-laugh-squint"],["laugh-wink","face-laugh-wink"],["meh","face-meh"],["meh-blank","face-meh-blank"],["meh-rolling-eyes","face-rolling-eyes"],["sad-cry","face-sad-cry"],["sad-tear","face-sad-tear"],["smile","face-smile"],["smile-beam","face-smile-beam"],["smile-wink","face-smile-wink"],["surprise","face-surprise"],["tired","face-tired"],["feather-alt","feather-pointed"],["file-download","file-arrow-down"],["file-upload","file-arrow-up"],["arrow-right-from-file","file-export"],["arrow-right-to-file","file-import"],["file-alt","file-lines"],["file-text","file-lines"],["file-edit","file-pen"],["file-medical-alt","file-waveform"],["file-archive","file-zipper"],["funnel-dollar","filter-circle-dollar"],["fire-alt","fire-flame-curved"],["burn","fire-flame-simple"],["save","floppy-disk"],["folder-blank","folder"],["football-ball","football"],["fast-forward","forward-fast"],["step-forward","forward-step"],["futbol-ball","futbol"],["soccer-ball","futbol"],["dashboard","gauge"],["gauge-med","gauge"],["tachometer-alt-average","gauge"],["tachometer-alt","gauge-high"],["tachometer-alt-fast","gauge-high"],["gauge-simple-med","gauge-simple"],["tachometer-average","gauge-simple"],["tachometer","gauge-simple-high"],["tachometer-fast","gauge-simple-high"],["legal","gavel"],["cog","gear"],["cogs","gears"],["golf-ball","golf-ball-tee"],["mortar-board","graduation-cap"],["grip-horizontal","grip"],["hand-paper","hand"],["hand-rock","hand-back-fist"],["allergies","hand-dots"],["fist-raised","hand-fist"],["hand-holding-usd","hand-holding-dollar"],["hand-holding-water","hand-holding-droplet"],["sign-language","hands"],["signing","hands"],["american-sign-language-interpreting","hands-asl-interpreting"],["asl-interpreting","hands-asl-interpreting"],["hands-american-sign-language-interpreting","hands-asl-interpreting"],["hands-wash","hands-bubbles"],["praying-hands","hands-praying"],["hands-helping","handshake-angle"],["handshake-alt","handshake-simple"],["handshake-alt-slash","handshake-simple-slash"],["hdd","hard-drive"],["header","heading"],["headphones-alt","headphones-simple"],["heart-broken","heart-crack"],["heartbeat","heart-pulse"],["hard-hat","helmet-safety"],["hat-hard","helmet-safety"],["hospital-alt","hospital"],["hospital-wide","hospital"],["hot-tub","hot-tub-person"],["hourglass-empty","hourglass"],["hourglass-3","hourglass-end"],["hourglass-2","hourglass-half"],["hourglass-1","hourglass-start"],["home","house"],["home-alt","house"],["home-lg-alt","house"],["home-lg","house-chimney"],["house-damage","house-chimney-crack"],["clinic-medical","house-chimney-medical"],["laptop-house","house-laptop"],["home-user","house-user"],["hryvnia","hryvnia-sign"],["heart-music-camera-bolt","icons"],["drivers-license","id-card"],["id-card-alt","id-card-clip"],["portrait","image-portrait"],["indian-rupee","indian-rupee-sign"],["inr","indian-rupee-sign"],["fighter-jet","jet-fighter"],["first-aid","kit-medical"],["landmark-alt","landmark-dome"],["long-arrow-alt-left","left-long"],["arrows-alt-h","left-right"],["chain","link"],["chain-broken","link-slash"],["chain-slash","link-slash"],["unlink","link-slash"],["list-squares","list"],["tasks","list-check"],["list-1-2","list-ol"],["list-numeric","list-ol"],["list-dots","list-ul"],["location","location-crosshairs"],["map-marker-alt","location-dot"],["map-marker","location-pin"],["search","magnifying-glass"],["search-dollar","magnifying-glass-dollar"],["search-location","magnifying-glass-location"],["search-minus","magnifying-glass-minus"],["search-plus","magnifying-glass-plus"],["map-marked","map-location"],["map-marked-alt","map-location-dot"],["mars-stroke-h","mars-stroke-right"],["mars-stroke-v","mars-stroke-up"],["glass-martini-alt","martini-glass"],["cocktail","martini-glass-citrus"],["glass-martini","martini-glass-empty"],["theater-masks","masks-theater"],["expand-arrows-alt","maximize"],["comment-alt","message"],["microphone-alt","microphone-lines"],["microphone-alt-slash","microphone-lines-slash"],["compress-arrows-alt","minimize"],["subtract","minus"],["mobile-android","mobile"],["mobile-phone","mobile"],["mobile-android-alt","mobile-screen"],["mobile-alt","mobile-screen-button"],["money-bill-alt","money-bill-1"],["money-bill-wave-alt","money-bill-1-wave"],["money-check-alt","money-check-dollar"],["coffee","mug-saucer"],["sticky-note","note-sticky"],["dedent","outdent"],["paint-brush","paintbrush"],["file-clipboard","paste"],["pen-alt","pen-clip"],["pencil-ruler","pen-ruler"],["edit","pen-to-square"],["pencil-alt","pencil"],["people-arrows-left-right","people-arrows"],["people-carry","people-carry-box"],["percentage","percent"],["male","person"],["biking","person-biking"],["digging","person-digging"],["diagnoses","person-dots-from-line"],["female","person-dress"],["hiking","person-hiking"],["pray","person-praying"],["running","person-running"],["skating","person-skating"],["skiing","person-skiing"],["skiing-nordic","person-skiing-nordic"],["snowboarding","person-snowboarding"],["swimmer","person-swimming"],["walking","person-walking"],["blind","person-walking-with-cane"],["phone-alt","phone-flip"],["volume-control-phone","phone-volume"],["photo-video","photo-film"],["add","plus"],["poo-bolt","poo-storm"],["prescription-bottle-alt","prescription-bottle-medical"],["quote-left-alt","quote-left"],["quote-right-alt","quote-right"],["ad","rectangle-ad"],["list-alt","rectangle-list"],["rectangle-times","rectangle-xmark"],["times-rectangle","rectangle-xmark"],["window-close","rectangle-xmark"],["mail-reply","reply"],["mail-reply-all","reply-all"],["sign-out-alt","right-from-bracket"],["exchange-alt","right-left"],["long-arrow-alt-right","right-long"],["sign-in-alt","right-to-bracket"],["sync-alt","rotate"],["rotate-back","rotate-left"],["rotate-backward","rotate-left"],["undo-alt","rotate-left"],["redo-alt","rotate-right"],["rotate-forward","rotate-right"],["feed","rss"],["rouble","ruble-sign"],["rub","ruble-sign"],["ruble","ruble-sign"],["rupee","rupee-sign"],["balance-scale","scale-balanced"],["balance-scale-left","scale-unbalanced"],["balance-scale-right","scale-unbalanced-flip"],["cut","scissors"],["tools","screwdriver-wrench"],["torah","scroll-torah"],["sprout","seedling"],["triangle-circle-square","shapes"],["mail-forward","share"],["share-square","share-from-square"],["share-alt","share-nodes"],["ils","shekel-sign"],["shekel","shekel-sign"],["sheqel","shekel-sign"],["sheqel-sign","shekel-sign"],["shield-blank","shield"],["shield-alt","shield-halved"],["t-shirt","shirt"],["tshirt","shirt"],["store-alt","shop"],["store-alt-slash","shop-slash"],["random","shuffle"],["space-shuttle","shuttle-space"],["sign","sign-hanging"],["signal-5","signal"],["signal-perfect","signal"],["map-signs","signs-post"],["sliders-h","sliders"],["unsorted","sort"],["sort-desc","sort-down"],["sort-asc","sort-up"],["pastafarianism","spaghetti-monster-flying"],["utensil-spoon","spoon"],["air-freshener","spray-can-sparkles"],["external-link-square","square-arrow-up-right"],["caret-square-down","square-caret-down"],["caret-square-left","square-caret-left"],["caret-square-right","square-caret-right"],["caret-square-up","square-caret-up"],["check-square","square-check"],["envelope-square","square-envelope"],["h-square","square-h"],["minus-square","square-minus"],["parking","square-parking"],["pen-square","square-pen"],["pencil-square","square-pen"],["phone-square","square-phone"],["phone-square-alt","square-phone-flip"],["plus-square","square-plus"],["poll-h","square-poll-horizontal"],["poll","square-poll-vertical"],["square-root-alt","square-root-variable"],["rss-square","square-rss"],["share-alt-square","square-share-nodes"],["external-link-square-alt","square-up-right"],["times-square","square-xmark"],["xmark-square","square-xmark"],["rod-asclepius","staff-snake"],["rod-snake","staff-snake"],["staff-aesculapius","staff-snake"],["star-half-alt","star-half-stroke"],["gbp","sterling-sign"],["pound-sign","sterling-sign"],["medkit","suitcase-medical"],["th","table-cells"],["th-large","table-cells-large"],["columns","table-columns"],["th-list","table-list"],["ping-pong-paddle-ball","table-tennis-paddle-ball"],["table-tennis","table-tennis-paddle-ball"],["tablet-android","tablet"],["tablet-alt","tablet-screen-button"],["digital-tachograph","tachograph-digital"],["cab","taxi"],["temperature-down","temperature-arrow-down"],["temperature-up","temperature-arrow-up"],["temperature-0","temperature-empty"],["thermometer-0","temperature-empty"],["thermometer-empty","temperature-empty"],["temperature-4","temperature-full"],["thermometer-4","temperature-full"],["thermometer-full","temperature-full"],["temperature-2","temperature-half"],["thermometer-2","temperature-half"],["thermometer-half","temperature-half"],["temperature-1","temperature-quarter"],["thermometer-1","temperature-quarter"],["thermometer-quarter","temperature-quarter"],["temperature-3","temperature-three-quarters"],["thermometer-3","temperature-three-quarters"],["thermometer-three-quarters","temperature-three-quarters"],["tenge","tenge-sign"],["remove-format","text-slash"],["thumb-tack","thumbtack"],["ticket-alt","ticket-simple"],["broadcast-tower","tower-broadcast"],["subway","train-subway"],["transgender-alt","transgender"],["trash-restore","trash-arrow-up"],["trash-alt","trash-can"],["trash-restore-alt","trash-can-arrow-up"],["exclamation-triangle","triangle-exclamation"],["warning","triangle-exclamation"],["shipping-fast","truck-fast"],["ambulance","truck-medical"],["truck-loading","truck-ramp-box"],["teletype","tty"],["try","turkish-lira-sign"],["turkish-lira","turkish-lira-sign"],["level-down-alt","turn-down"],["level-up-alt","turn-up"],["television","tv"],["tv-alt","tv"],["unlock-alt","unlock-keyhole"],["arrows-alt-v","up-down"],["arrows-alt","up-down-left-right"],["long-arrow-alt-up","up-long"],["expand-alt","up-right-and-down-left-from-center"],["external-link-alt","up-right-from-square"],["user-md","user-doctor"],["user-cog","user-gear"],["user-friends","user-group"],["user-alt","user-large"],["user-alt-slash","user-large-slash"],["user-edit","user-pen"],["user-times","user-xmark"],["users-cog","users-gear"],["cutlery","utensils"],["shuttle-van","van-shuttle"],["video-camera","video"],["volleyball-ball","volleyball"],["volume-up","volume-high"],["volume-down","volume-low"],["volume-mute","volume-xmark"],["volume-times","volume-xmark"],["magic","wand-magic"],["magic-wand-sparkles","wand-magic-sparkles"],["ladder-water","water-ladder"],["swimming-pool","water-ladder"],["weight","weight-scale"],["wheat-alt","wheat-awn"],["wheelchair-alt","wheelchair-move"],["glass-whiskey","whiskey-glass"],["wifi-3","wifi"],["wifi-strong","wifi"],["wine-glass-alt","wine-glass-empty"],["krw","won-sign"],["won","won-sign"],["close","xmark"],["multiply","xmark"],["remove","xmark"],["times","xmark"],["cny","yen-sign"],["jpy","yen-sign"],["rmb","yen-sign"],["yen","yen-sign"]]),r=new Map([["0",["0",!1]],["1",["1",!1]],["2",["2",!1]],["3",["3",!1]],["4",["4",!1]],["5",["5",!1]],["6",["6",!1]],["7",["7",!1]],["8",["8",!1]],["9",["9",!1]],["a",["A",!1]],["address-book",["\uF2B9",!0]],["address-card",["\uF2BB",!0]],["align-center",["\uF037",!1]],["align-justify",["\uF039",!1]],["align-left",["\uF036",!1]],["align-right",["\uF038",!1]],["anchor",["\uF13D",!1]],["anchor-circle-check",["\uE4AA",!1]],["anchor-circle-exclamation",["\uE4AB",!1]],["anchor-circle-xmark",["\uE4AC",!1]],["anchor-lock",["\uE4AD",!1]],["angle-down",["\uF107",!1]],["angle-left",["\uF104",!1]],["angle-right",["\uF105",!1]],["angle-up",["\uF106",!1]],["angles-down",["\uF103",!1]],["angles-left",["\uF100",!1]],["angles-right",["\uF101",!1]],["angles-up",["\uF102",!1]],["ankh",["\uF644",!1]],["apple-whole",["\uF5D1",!1]],["archway",["\uF557",!1]],["arrow-down",["\uF063",!1]],["arrow-down-1-9",["\uF162",!1]],["arrow-down-9-1",["\uF886",!1]],["arrow-down-a-z",["\uF15D",!1]],["arrow-down-long",["\uF175",!1]],["arrow-down-short-wide",["\uF884",!1]],["arrow-down-up-across-line",["\uE4AF",!1]],["arrow-down-up-lock",["\uE4B0",!1]],["arrow-down-wide-short",["\uF160",!1]],["arrow-down-z-a",["\uF881",!1]],["arrow-left",["\uF060",!1]],["arrow-left-long",["\uF177",!1]],["arrow-pointer",["\uF245",!1]],["arrow-right",["\uF061",!1]],["arrow-right-arrow-left",["\uF0EC",!1]],["arrow-right-from-bracket",["\uF08B",!1]],["arrow-right-long",["\uF178",!1]],["arrow-right-to-bracket",["\uF090",!1]],["arrow-right-to-city",["\uE4B3",!1]],["arrow-rotate-left",["\uF0E2",!1]],["arrow-rotate-right",["\uF01E",!1]],["arrow-trend-down",["\uE097",!1]],["arrow-trend-up",["\uE098",!1]],["arrow-turn-down",["\uF149",!1]],["arrow-turn-up",["\uF148",!1]],["arrow-up",["\uF062",!1]],["arrow-up-1-9",["\uF163",!1]],["arrow-up-9-1",["\uF887",!1]],["arrow-up-a-z",["\uF15E",!1]],["arrow-up-from-bracket",["\uE09A",!1]],["arrow-up-from-ground-water",["\uE4B5",!1]],["arrow-up-from-water-pump",["\uE4B6",!1]],["arrow-up-long",["\uF176",!1]],["arrow-up-right-dots",["\uE4B7",!1]],["arrow-up-right-from-square",["\uF08E",!1]],["arrow-up-short-wide",["\uF885",!1]],["arrow-up-wide-short",["\uF161",!1]],["arrow-up-z-a",["\uF882",!1]],["arrows-down-to-line",["\uE4B8",!1]],["arrows-down-to-people",["\uE4B9",!1]],["arrows-left-right",["\uF07E",!1]],["arrows-left-right-to-line",["\uE4BA",!1]],["arrows-rotate",["\uF021",!1]],["arrows-spin",["\uE4BB",!1]],["arrows-split-up-and-left",["\uE4BC",!1]],["arrows-to-circle",["\uE4BD",!1]],["arrows-to-dot",["\uE4BE",!1]],["arrows-to-eye",["\uE4BF",!1]],["arrows-turn-right",["\uE4C0",!1]],["arrows-turn-to-dots",["\uE4C1",!1]],["arrows-up-down",["\uF07D",!1]],["arrows-up-down-left-right",["\uF047",!1]],["arrows-up-to-line",["\uE4C2",!1]],["asterisk",["*",!1]],["at",["@",!1]],["atom",["\uF5D2",!1]],["audio-description",["\uF29E",!1]],["austral-sign",["\uE0A9",!1]],["award",["\uF559",!1]],["b",["B",!1]],["baby",["\uF77C",!1]],["baby-carriage",["\uF77D",!1]],["backward",["\uF04A",!1]],["backward-fast",["\uF049",!1]],["backward-step",["\uF048",!1]],["bacon",["\uF7E5",!1]],["bacteria",["\uE059",!1]],["bacterium",["\uE05A",!1]],["bag-shopping",["\uF290",!1]],["bahai",["\uF666",!1]],["baht-sign",["\uE0AC",!1]],["ban",["\uF05E",!1]],["ban-smoking",["\uF54D",!1]],["bandage",["\uF462",!1]],["bangladeshi-taka-sign",["\uE2E6",!1]],["barcode",["\uF02A",!1]],["bars",["\uF0C9",!1]],["bars-progress",["\uF828",!1]],["bars-staggered",["\uF550",!1]],["baseball",["\uF433",!1]],["baseball-bat-ball",["\uF432",!1]],["basket-shopping",["\uF291",!1]],["basketball",["\uF434",!1]],["bath",["\uF2CD",!1]],["battery-empty",["\uF244",!1]],["battery-full",["\uF240",!1]],["battery-half",["\uF242",!1]],["battery-quarter",["\uF243",!1]],["battery-three-quarters",["\uF241",!1]],["bed",["\uF236",!1]],["bed-pulse",["\uF487",!1]],["beer-mug-empty",["\uF0FC",!1]],["bell",["\uF0F3",!0]],["bell-concierge",["\uF562",!1]],["bell-slash",["\uF1F6",!0]],["bezier-curve",["\uF55B",!1]],["bicycle",["\uF206",!1]],["binoculars",["\uF1E5",!1]],["biohazard",["\uF780",!1]],["bitcoin-sign",["\uE0B4",!1]],["blender",["\uF517",!1]],["blender-phone",["\uF6B6",!1]],["blog",["\uF781",!1]],["bold",["\uF032",!1]],["bolt",["\uF0E7",!1]],["bolt-lightning",["\uE0B7",!1]],["bomb",["\uF1E2",!1]],["bone",["\uF5D7",!1]],["bong",["\uF55C",!1]],["book",["\uF02D",!1]],["book-atlas",["\uF558",!1]],["book-bible",["\uF647",!1]],["book-bookmark",["\uE0BB",!1]],["book-journal-whills",["\uF66A",!1]],["book-medical",["\uF7E6",!1]],["book-open",["\uF518",!1]],["book-open-reader",["\uF5DA",!1]],["book-quran",["\uF687",!1]],["book-skull",["\uF6B7",!1]],["book-tanakh",["\uF827",!1]],["bookmark",["\uF02E",!0]],["border-all",["\uF84C",!1]],["border-none",["\uF850",!1]],["border-top-left",["\uF853",!1]],["bore-hole",["\uE4C3",!1]],["bottle-droplet",["\uE4C4",!1]],["bottle-water",["\uE4C5",!1]],["bowl-food",["\uE4C6",!1]],["bowl-rice",["\uE2EB",!1]],["bowling-ball",["\uF436",!1]],["box",["\uF466",!1]],["box-archive",["\uF187",!1]],["box-open",["\uF49E",!1]],["box-tissue",["\uE05B",!1]],["boxes-packing",["\uE4C7",!1]],["boxes-stacked",["\uF468",!1]],["braille",["\uF2A1",!1]],["brain",["\uF5DC",!1]],["brazilian-real-sign",["\uE46C",!1]],["bread-slice",["\uF7EC",!1]],["bridge",["\uE4C8",!1]],["bridge-circle-check",["\uE4C9",!1]],["bridge-circle-exclamation",["\uE4CA",!1]],["bridge-circle-xmark",["\uE4CB",!1]],["bridge-lock",["\uE4CC",!1]],["bridge-water",["\uE4CE",!1]],["briefcase",["\uF0B1",!1]],["briefcase-medical",["\uF469",!1]],["broom",["\uF51A",!1]],["broom-ball",["\uF458",!1]],["brush",["\uF55D",!1]],["bucket",["\uE4CF",!1]],["bug",["\uF188",!1]],["bug-slash",["\uE490",!1]],["bugs",["\uE4D0",!1]],["building",["\uF1AD",!0]],["building-circle-arrow-right",["\uE4D1",!1]],["building-circle-check",["\uE4D2",!1]],["building-circle-exclamation",["\uE4D3",!1]],["building-circle-xmark",["\uE4D4",!1]],["building-columns",["\uF19C",!1]],["building-flag",["\uE4D5",!1]],["building-lock",["\uE4D6",!1]],["building-ngo",["\uE4D7",!1]],["building-shield",["\uE4D8",!1]],["building-un",["\uE4D9",!1]],["building-user",["\uE4DA",!1]],["building-wheat",["\uE4DB",!1]],["bullhorn",["\uF0A1",!1]],["bullseye",["\uF140",!1]],["burger",["\uF805",!1]],["burst",["\uE4DC",!1]],["bus",["\uF207",!1]],["bus-simple",["\uF55E",!1]],["business-time",["\uF64A",!1]],["c",["C",!1]],["cable-car",["\uF7DA",!1]],["cake-candles",["\uF1FD",!1]],["calculator",["\uF1EC",!1]],["calendar",["\uF133",!0]],["calendar-check",["\uF274",!0]],["calendar-day",["\uF783",!1]],["calendar-days",["\uF073",!0]],["calendar-minus",["\uF272",!0]],["calendar-plus",["\uF271",!0]],["calendar-week",["\uF784",!1]],["calendar-xmark",["\uF273",!0]],["camera",["\uF030",!1]],["camera-retro",["\uF083",!1]],["camera-rotate",["\uE0D8",!1]],["campground",["\uF6BB",!1]],["candy-cane",["\uF786",!1]],["cannabis",["\uF55F",!1]],["capsules",["\uF46B",!1]],["car",["\uF1B9",!1]],["car-battery",["\uF5DF",!1]],["car-burst",["\uF5E1",!1]],["car-on",["\uE4DD",!1]],["car-rear",["\uF5DE",!1]],["car-side",["\uF5E4",!1]],["car-tunnel",["\uE4DE",!1]],["caravan",["\uF8FF",!1]],["caret-down",["\uF0D7",!1]],["caret-left",["\uF0D9",!1]],["caret-right",["\uF0DA",!1]],["caret-up",["\uF0D8",!1]],["carrot",["\uF787",!1]],["cart-arrow-down",["\uF218",!1]],["cart-flatbed",["\uF474",!1]],["cart-flatbed-suitcase",["\uF59D",!1]],["cart-plus",["\uF217",!1]],["cart-shopping",["\uF07A",!1]],["cash-register",["\uF788",!1]],["cat",["\uF6BE",!1]],["cedi-sign",["\uE0DF",!1]],["cent-sign",["\uE3F5",!1]],["certificate",["\uF0A3",!1]],["chair",["\uF6C0",!1]],["chalkboard",["\uF51B",!1]],["chalkboard-user",["\uF51C",!1]],["champagne-glasses",["\uF79F",!1]],["charging-station",["\uF5E7",!1]],["chart-area",["\uF1FE",!1]],["chart-bar",["\uF080",!0]],["chart-column",["\uE0E3",!1]],["chart-gantt",["\uE0E4",!1]],["chart-line",["\uF201",!1]],["chart-pie",["\uF200",!1]],["chart-simple",["\uE473",!1]],["check",["\uF00C",!1]],["check-double",["\uF560",!1]],["check-to-slot",["\uF772",!1]],["cheese",["\uF7EF",!1]],["chess",["\uF439",!1]],["chess-bishop",["\uF43A",!0]],["chess-board",["\uF43C",!1]],["chess-king",["\uF43F",!0]],["chess-knight",["\uF441",!0]],["chess-pawn",["\uF443",!0]],["chess-queen",["\uF445",!0]],["chess-rook",["\uF447",!0]],["chevron-down",["\uF078",!1]],["chevron-left",["\uF053",!1]],["chevron-right",["\uF054",!1]],["chevron-up",["\uF077",!1]],["child",["\uF1AE",!1]],["child-combatant",["\uE4E0",!1]],["child-dress",["\uE59C",!1]],["child-reaching",["\uE59D",!1]],["children",["\uE4E1",!1]],["church",["\uF51D",!1]],["circle",["\uF111",!0]],["circle-arrow-down",["\uF0AB",!1]],["circle-arrow-left",["\uF0A8",!1]],["circle-arrow-right",["\uF0A9",!1]],["circle-arrow-up",["\uF0AA",!1]],["circle-check",["\uF058",!0]],["circle-chevron-down",["\uF13A",!1]],["circle-chevron-left",["\uF137",!1]],["circle-chevron-right",["\uF138",!1]],["circle-chevron-up",["\uF139",!1]],["circle-dollar-to-slot",["\uF4B9",!1]],["circle-dot",["\uF192",!0]],["circle-down",["\uF358",!0]],["circle-exclamation",["\uF06A",!1]],["circle-h",["\uF47E",!1]],["circle-half-stroke",["\uF042",!1]],["circle-info",["\uF05A",!1]],["circle-left",["\uF359",!0]],["circle-minus",["\uF056",!1]],["circle-nodes",["\uE4E2",!1]],["circle-notch",["\uF1CE",!1]],["circle-pause",["\uF28B",!0]],["circle-play",["\uF144",!0]],["circle-plus",["\uF055",!1]],["circle-question",["\uF059",!0]],["circle-radiation",["\uF7BA",!1]],["circle-right",["\uF35A",!0]],["circle-stop",["\uF28D",!0]],["circle-up",["\uF35B",!0]],["circle-user",["\uF2BD",!0]],["circle-xmark",["\uF057",!0]],["city",["\uF64F",!1]],["clapperboard",["\uE131",!1]],["clipboard",["\uF328",!0]],["clipboard-check",["\uF46C",!1]],["clipboard-list",["\uF46D",!1]],["clipboard-question",["\uE4E3",!1]],["clipboard-user",["\uF7F3",!1]],["clock",["\uF017",!0]],["clock-rotate-left",["\uF1DA",!1]],["clone",["\uF24D",!0]],["closed-captioning",["\uF20A",!0]],["cloud",["\uF0C2",!1]],["cloud-arrow-down",["\uF0ED",!1]],["cloud-arrow-up",["\uF0EE",!1]],["cloud-bolt",["\uF76C",!1]],["cloud-meatball",["\uF73B",!1]],["cloud-moon",["\uF6C3",!1]],["cloud-moon-rain",["\uF73C",!1]],["cloud-rain",["\uF73D",!1]],["cloud-showers-heavy",["\uF740",!1]],["cloud-showers-water",["\uE4E4",!1]],["cloud-sun",["\uF6C4",!1]],["cloud-sun-rain",["\uF743",!1]],["clover",["\uE139",!1]],["code",["\uF121",!1]],["code-branch",["\uF126",!1]],["code-commit",["\uF386",!1]],["code-compare",["\uE13A",!1]],["code-fork",["\uE13B",!1]],["code-merge",["\uF387",!1]],["code-pull-request",["\uE13C",!1]],["coins",["\uF51E",!1]],["colon-sign",["\uE140",!1]],["comment",["\uF075",!0]],["comment-dollar",["\uF651",!1]],["comment-dots",["\uF4AD",!0]],["comment-medical",["\uF7F5",!1]],["comment-slash",["\uF4B3",!1]],["comment-sms",["\uF7CD",!1]],["comments",["\uF086",!0]],["comments-dollar",["\uF653",!1]],["compact-disc",["\uF51F",!1]],["compass",["\uF14E",!0]],["compass-drafting",["\uF568",!1]],["compress",["\uF066",!1]],["computer",["\uE4E5",!1]],["computer-mouse",["\uF8CC",!1]],["cookie",["\uF563",!1]],["cookie-bite",["\uF564",!1]],["copy",["\uF0C5",!0]],["copyright",["\uF1F9",!0]],["couch",["\uF4B8",!1]],["cow",["\uF6C8",!1]],["credit-card",["\uF09D",!0]],["crop",["\uF125",!1]],["crop-simple",["\uF565",!1]],["cross",["\uF654",!1]],["crosshairs",["\uF05B",!1]],["crow",["\uF520",!1]],["crown",["\uF521",!1]],["crutch",["\uF7F7",!1]],["cruzeiro-sign",["\uE152",!1]],["cube",["\uF1B2",!1]],["cubes",["\uF1B3",!1]],["cubes-stacked",["\uE4E6",!1]],["d",["D",!1]],["database",["\uF1C0",!1]],["delete-left",["\uF55A",!1]],["democrat",["\uF747",!1]],["desktop",["\uF390",!1]],["dharmachakra",["\uF655",!1]],["diagram-next",["\uE476",!1]],["diagram-predecessor",["\uE477",!1]],["diagram-project",["\uF542",!1]],["diagram-successor",["\uE47A",!1]],["diamond",["\uF219",!1]],["diamond-turn-right",["\uF5EB",!1]],["dice",["\uF522",!1]],["dice-d20",["\uF6CF",!1]],["dice-d6",["\uF6D1",!1]],["dice-five",["\uF523",!1]],["dice-four",["\uF524",!1]],["dice-one",["\uF525",!1]],["dice-six",["\uF526",!1]],["dice-three",["\uF527",!1]],["dice-two",["\uF528",!1]],["disease",["\uF7FA",!1]],["display",["\uE163",!1]],["divide",["\uF529",!1]],["dna",["\uF471",!1]],["dog",["\uF6D3",!1]],["dollar-sign",["$",!1]],["dolly",["\uF472",!1]],["dong-sign",["\uE169",!1]],["door-closed",["\uF52A",!1]],["door-open",["\uF52B",!1]],["dove",["\uF4BA",!1]],["down-left-and-up-right-to-center",["\uF422",!1]],["down-long",["\uF309",!1]],["download",["\uF019",!1]],["dragon",["\uF6D5",!1]],["draw-polygon",["\uF5EE",!1]],["droplet",["\uF043",!1]],["droplet-slash",["\uF5C7",!1]],["drum",["\uF569",!1]],["drum-steelpan",["\uF56A",!1]],["drumstick-bite",["\uF6D7",!1]],["dumbbell",["\uF44B",!1]],["dumpster",["\uF793",!1]],["dumpster-fire",["\uF794",!1]],["dungeon",["\uF6D9",!1]],["e",["E",!1]],["ear-deaf",["\uF2A4",!1]],["ear-listen",["\uF2A2",!1]],["earth-africa",["\uF57C",!1]],["earth-americas",["\uF57D",!1]],["earth-asia",["\uF57E",!1]],["earth-europe",["\uF7A2",!1]],["earth-oceania",["\uE47B",!1]],["egg",["\uF7FB",!1]],["eject",["\uF052",!1]],["elevator",["\uE16D",!1]],["ellipsis",["\uF141",!1]],["ellipsis-vertical",["\uF142",!1]],["envelope",["\uF0E0",!0]],["envelope-circle-check",["\uE4E8",!1]],["envelope-open",["\uF2B6",!0]],["envelope-open-text",["\uF658",!1]],["envelopes-bulk",["\uF674",!1]],["equals",["=",!1]],["eraser",["\uF12D",!1]],["ethernet",["\uF796",!1]],["euro-sign",["\uF153",!1]],["exclamation",["!",!1]],["expand",["\uF065",!1]],["explosion",["\uE4E9",!1]],["eye",["\uF06E",!0]],["eye-dropper",["\uF1FB",!1]],["eye-low-vision",["\uF2A8",!1]],["eye-slash",["\uF070",!0]],["f",["F",!1]],["face-angry",["\uF556",!0]],["face-dizzy",["\uF567",!0]],["face-flushed",["\uF579",!0]],["face-frown",["\uF119",!0]],["face-frown-open",["\uF57A",!0]],["face-grimace",["\uF57F",!0]],["face-grin",["\uF580",!0]],["face-grin-beam",["\uF582",!0]],["face-grin-beam-sweat",["\uF583",!0]],["face-grin-hearts",["\uF584",!0]],["face-grin-squint",["\uF585",!0]],["face-grin-squint-tears",["\uF586",!0]],["face-grin-stars",["\uF587",!0]],["face-grin-tears",["\uF588",!0]],["face-grin-tongue",["\uF589",!0]],["face-grin-tongue-squint",["\uF58A",!0]],["face-grin-tongue-wink",["\uF58B",!0]],["face-grin-wide",["\uF581",!0]],["face-grin-wink",["\uF58C",!0]],["face-kiss",["\uF596",!0]],["face-kiss-beam",["\uF597",!0]],["face-kiss-wink-heart",["\uF598",!0]],["face-laugh",["\uF599",!0]],["face-laugh-beam",["\uF59A",!0]],["face-laugh-squint",["\uF59B",!0]],["face-laugh-wink",["\uF59C",!0]],["face-meh",["\uF11A",!0]],["face-meh-blank",["\uF5A4",!0]],["face-rolling-eyes",["\uF5A5",!0]],["face-sad-cry",["\uF5B3",!0]],["face-sad-tear",["\uF5B4",!0]],["face-smile",["\uF118",!0]],["face-smile-beam",["\uF5B8",!0]],["face-smile-wink",["\uF4DA",!0]],["face-surprise",["\uF5C2",!0]],["face-tired",["\uF5C8",!0]],["fan",["\uF863",!1]],["faucet",["\uE005",!1]],["faucet-drip",["\uE006",!1]],["fax",["\uF1AC",!1]],["feather",["\uF52D",!1]],["feather-pointed",["\uF56B",!1]],["ferry",["\uE4EA",!1]],["file",["\uF15B",!0]],["file-arrow-down",["\uF56D",!1]],["file-arrow-up",["\uF574",!1]],["file-audio",["\uF1C7",!0]],["file-circle-check",["\uE5A0",!1]],["file-circle-exclamation",["\uE4EB",!1]],["file-circle-minus",["\uE4ED",!1]],["file-circle-plus",["\uE494",!1]],["file-circle-question",["\uE4EF",!1]],["file-circle-xmark",["\uE5A1",!1]],["file-code",["\uF1C9",!0]],["file-contract",["\uF56C",!1]],["file-csv",["\uF6DD",!1]],["file-excel",["\uF1C3",!0]],["file-export",["\uF56E",!1]],["file-image",["\uF1C5",!0]],["file-import",["\uF56F",!1]],["file-invoice",["\uF570",!1]],["file-invoice-dollar",["\uF571",!1]],["file-lines",["\uF15C",!0]],["file-medical",["\uF477",!1]],["file-pdf",["\uF1C1",!0]],["file-pen",["\uF31C",!1]],["file-powerpoint",["\uF1C4",!0]],["file-prescription",["\uF572",!1]],["file-shield",["\uE4F0",!1]],["file-signature",["\uF573",!1]],["file-video",["\uF1C8",!0]],["file-waveform",["\uF478",!1]],["file-word",["\uF1C2",!0]],["file-zipper",["\uF1C6",!0]],["fill",["\uF575",!1]],["fill-drip",["\uF576",!1]],["film",["\uF008",!1]],["filter",["\uF0B0",!1]],["filter-circle-dollar",["\uF662",!1]],["filter-circle-xmark",["\uE17B",!1]],["fingerprint",["\uF577",!1]],["fire",["\uF06D",!1]],["fire-burner",["\uE4F1",!1]],["fire-extinguisher",["\uF134",!1]],["fire-flame-curved",["\uF7E4",!1]],["fire-flame-simple",["\uF46A",!1]],["fish",["\uF578",!1]],["fish-fins",["\uE4F2",!1]],["flag",["\uF024",!0]],["flag-checkered",["\uF11E",!1]],["flag-usa",["\uF74D",!1]],["flask",["\uF0C3",!1]],["flask-vial",["\uE4F3",!1]],["floppy-disk",["\uF0C7",!0]],["florin-sign",["\uE184",!1]],["folder",["\uF07B",!0]],["folder-closed",["\uE185",!0]],["folder-minus",["\uF65D",!1]],["folder-open",["\uF07C",!0]],["folder-plus",["\uF65E",!1]],["folder-tree",["\uF802",!1]],["font",["\uF031",!1]],["football",["\uF44E",!1]],["forward",["\uF04E",!1]],["forward-fast",["\uF050",!1]],["forward-step",["\uF051",!1]],["franc-sign",["\uE18F",!1]],["frog",["\uF52E",!1]],["futbol",["\uF1E3",!0]],["g",["G",!1]],["gamepad",["\uF11B",!1]],["gas-pump",["\uF52F",!1]],["gauge",["\uF624",!1]],["gauge-high",["\uF625",!1]],["gauge-simple",["\uF629",!1]],["gauge-simple-high",["\uF62A",!1]],["gavel",["\uF0E3",!1]],["gear",["\uF013",!1]],["gears",["\uF085",!1]],["gem",["\uF3A5",!0]],["genderless",["\uF22D",!1]],["ghost",["\uF6E2",!1]],["gift",["\uF06B",!1]],["gifts",["\uF79C",!1]],["glass-water",["\uE4F4",!1]],["glass-water-droplet",["\uE4F5",!1]],["glasses",["\uF530",!1]],["globe",["\uF0AC",!1]],["golf-ball-tee",["\uF450",!1]],["gopuram",["\uF664",!1]],["graduation-cap",["\uF19D",!1]],["greater-than",[">",!1]],["greater-than-equal",["\uF532",!1]],["grip",["\uF58D",!1]],["grip-lines",["\uF7A4",!1]],["grip-lines-vertical",["\uF7A5",!1]],["grip-vertical",["\uF58E",!1]],["group-arrows-rotate",["\uE4F6",!1]],["guarani-sign",["\uE19A",!1]],["guitar",["\uF7A6",!1]],["gun",["\uE19B",!1]],["h",["H",!1]],["hammer",["\uF6E3",!1]],["hamsa",["\uF665",!1]],["hand",["\uF256",!0]],["hand-back-fist",["\uF255",!0]],["hand-dots",["\uF461",!1]],["hand-fist",["\uF6DE",!1]],["hand-holding",["\uF4BD",!1]],["hand-holding-dollar",["\uF4C0",!1]],["hand-holding-droplet",["\uF4C1",!1]],["hand-holding-hand",["\uE4F7",!1]],["hand-holding-heart",["\uF4BE",!1]],["hand-holding-medical",["\uE05C",!1]],["hand-lizard",["\uF258",!0]],["hand-middle-finger",["\uF806",!1]],["hand-peace",["\uF25B",!0]],["hand-point-down",["\uF0A7",!0]],["hand-point-left",["\uF0A5",!0]],["hand-point-right",["\uF0A4",!0]],["hand-point-up",["\uF0A6",!0]],["hand-pointer",["\uF25A",!0]],["hand-scissors",["\uF257",!0]],["hand-sparkles",["\uE05D",!1]],["hand-spock",["\uF259",!0]],["handcuffs",["\uE4F8",!1]],["hands",["\uF2A7",!1]],["hands-asl-interpreting",["\uF2A3",!1]],["hands-bound",["\uE4F9",!1]],["hands-bubbles",["\uE05E",!1]],["hands-clapping",["\uE1A8",!1]],["hands-holding",["\uF4C2",!1]],["hands-holding-child",["\uE4FA",!1]],["hands-holding-circle",["\uE4FB",!1]],["hands-praying",["\uF684",!1]],["handshake",["\uF2B5",!0]],["handshake-angle",["\uF4C4",!1]],["handshake-simple",["\uF4C6",!1]],["handshake-simple-slash",["\uE05F",!1]],["handshake-slash",["\uE060",!1]],["hanukiah",["\uF6E6",!1]],["hard-drive",["\uF0A0",!0]],["hashtag",["#",!1]],["hat-cowboy",["\uF8C0",!1]],["hat-cowboy-side",["\uF8C1",!1]],["hat-wizard",["\uF6E8",!1]],["head-side-cough",["\uE061",!1]],["head-side-cough-slash",["\uE062",!1]],["head-side-mask",["\uE063",!1]],["head-side-virus",["\uE064",!1]],["heading",["\uF1DC",!1]],["headphones",["\uF025",!1]],["headphones-simple",["\uF58F",!1]],["headset",["\uF590",!1]],["heart",["\uF004",!0]],["heart-circle-bolt",["\uE4FC",!1]],["heart-circle-check",["\uE4FD",!1]],["heart-circle-exclamation",["\uE4FE",!1]],["heart-circle-minus",["\uE4FF",!1]],["heart-circle-plus",["\uE500",!1]],["heart-circle-xmark",["\uE501",!1]],["heart-crack",["\uF7A9",!1]],["heart-pulse",["\uF21E",!1]],["helicopter",["\uF533",!1]],["helicopter-symbol",["\uE502",!1]],["helmet-safety",["\uF807",!1]],["helmet-un",["\uE503",!1]],["highlighter",["\uF591",!1]],["hill-avalanche",["\uE507",!1]],["hill-rockslide",["\uE508",!1]],["hippo",["\uF6ED",!1]],["hockey-puck",["\uF453",!1]],["holly-berry",["\uF7AA",!1]],["horse",["\uF6F0",!1]],["horse-head",["\uF7AB",!1]],["hospital",["\uF0F8",!0]],["hospital-user",["\uF80D",!1]],["hot-tub-person",["\uF593",!1]],["hotdog",["\uF80F",!1]],["hotel",["\uF594",!1]],["hourglass",["\uF254",!0]],["hourglass-end",["\uF253",!1]],["hourglass-half",["\uF252",!0]],["hourglass-start",["\uF251",!1]],["house",["\uF015",!1]],["house-chimney",["\uE3AF",!1]],["house-chimney-crack",["\uF6F1",!1]],["house-chimney-medical",["\uF7F2",!1]],["house-chimney-user",["\uE065",!1]],["house-chimney-window",["\uE00D",!1]],["house-circle-check",["\uE509",!1]],["house-circle-exclamation",["\uE50A",!1]],["house-circle-xmark",["\uE50B",!1]],["house-crack",["\uE3B1",!1]],["house-fire",["\uE50C",!1]],["house-flag",["\uE50D",!1]],["house-flood-water",["\uE50E",!1]],["house-flood-water-circle-arrow-right",["\uE50F",!1]],["house-laptop",["\uE066",!1]],["house-lock",["\uE510",!1]],["house-medical",["\uE3B2",!1]],["house-medical-circle-check",["\uE511",!1]],["house-medical-circle-exclamation",["\uE512",!1]],["house-medical-circle-xmark",["\uE513",!1]],["house-medical-flag",["\uE514",!1]],["house-signal",["\uE012",!1]],["house-tsunami",["\uE515",!1]],["house-user",["\uE1B0",!1]],["hryvnia-sign",["\uF6F2",!1]],["hurricane",["\uF751",!1]],["i",["I",!1]],["i-cursor",["\uF246",!1]],["ice-cream",["\uF810",!1]],["icicles",["\uF7AD",!1]],["icons",["\uF86D",!1]],["id-badge",["\uF2C1",!0]],["id-card",["\uF2C2",!0]],["id-card-clip",["\uF47F",!1]],["igloo",["\uF7AE",!1]],["image",["\uF03E",!0]],["image-portrait",["\uF3E0",!1]],["images",["\uF302",!0]],["inbox",["\uF01C",!1]],["indent",["\uF03C",!1]],["indian-rupee-sign",["\uE1BC",!1]],["industry",["\uF275",!1]],["infinity",["\uF534",!1]],["info",["\uF129",!1]],["italic",["\uF033",!1]],["j",["J",!1]],["jar",["\uE516",!1]],["jar-wheat",["\uE517",!1]],["jedi",["\uF669",!1]],["jet-fighter",["\uF0FB",!1]],["jet-fighter-up",["\uE518",!1]],["joint",["\uF595",!1]],["jug-detergent",["\uE519",!1]],["k",["K",!1]],["kaaba",["\uF66B",!1]],["key",["\uF084",!1]],["keyboard",["\uF11C",!0]],["khanda",["\uF66D",!1]],["kip-sign",["\uE1C4",!1]],["kit-medical",["\uF479",!1]],["kitchen-set",["\uE51A",!1]],["kiwi-bird",["\uF535",!1]],["l",["L",!1]],["land-mine-on",["\uE51B",!1]],["landmark",["\uF66F",!1]],["landmark-dome",["\uF752",!1]],["landmark-flag",["\uE51C",!1]],["language",["\uF1AB",!1]],["laptop",["\uF109",!1]],["laptop-code",["\uF5FC",!1]],["laptop-file",["\uE51D",!1]],["laptop-medical",["\uF812",!1]],["lari-sign",["\uE1C8",!1]],["layer-group",["\uF5FD",!1]],["leaf",["\uF06C",!1]],["left-long",["\uF30A",!1]],["left-right",["\uF337",!1]],["lemon",["\uF094",!0]],["less-than",["<",!1]],["less-than-equal",["\uF537",!1]],["life-ring",["\uF1CD",!0]],["lightbulb",["\uF0EB",!0]],["lines-leaning",["\uE51E",!1]],["link",["\uF0C1",!1]],["link-slash",["\uF127",!1]],["lira-sign",["\uF195",!1]],["list",["\uF03A",!1]],["list-check",["\uF0AE",!1]],["list-ol",["\uF0CB",!1]],["list-ul",["\uF0CA",!1]],["litecoin-sign",["\uE1D3",!1]],["location-arrow",["\uF124",!1]],["location-crosshairs",["\uF601",!1]],["location-dot",["\uF3C5",!1]],["location-pin",["\uF041",!1]],["location-pin-lock",["\uE51F",!1]],["lock",["\uF023",!1]],["lock-open",["\uF3C1",!1]],["locust",["\uE520",!1]],["lungs",["\uF604",!1]],["lungs-virus",["\uE067",!1]],["m",["M",!1]],["magnet",["\uF076",!1]],["magnifying-glass",["\uF002",!1]],["magnifying-glass-arrow-right",["\uE521",!1]],["magnifying-glass-chart",["\uE522",!1]],["magnifying-glass-dollar",["\uF688",!1]],["magnifying-glass-location",["\uF689",!1]],["magnifying-glass-minus",["\uF010",!1]],["magnifying-glass-plus",["\uF00E",!1]],["manat-sign",["\uE1D5",!1]],["map",["\uF279",!0]],["map-location",["\uF59F",!1]],["map-location-dot",["\uF5A0",!1]],["map-pin",["\uF276",!1]],["marker",["\uF5A1",!1]],["mars",["\uF222",!1]],["mars-and-venus",["\uF224",!1]],["mars-and-venus-burst",["\uE523",!1]],["mars-double",["\uF227",!1]],["mars-stroke",["\uF229",!1]],["mars-stroke-right",["\uF22B",!1]],["mars-stroke-up",["\uF22A",!1]],["martini-glass",["\uF57B",!1]],["martini-glass-citrus",["\uF561",!1]],["martini-glass-empty",["\uF000",!1]],["mask",["\uF6FA",!1]],["mask-face",["\uE1D7",!1]],["mask-ventilator",["\uE524",!1]],["masks-theater",["\uF630",!1]],["mattress-pillow",["\uE525",!1]],["maximize",["\uF31E",!1]],["medal",["\uF5A2",!1]],["memory",["\uF538",!1]],["menorah",["\uF676",!1]],["mercury",["\uF223",!1]],["message",["\uF27A",!0]],["meteor",["\uF753",!1]],["microchip",["\uF2DB",!1]],["microphone",["\uF130",!1]],["microphone-lines",["\uF3C9",!1]],["microphone-lines-slash",["\uF539",!1]],["microphone-slash",["\uF131",!1]],["microscope",["\uF610",!1]],["mill-sign",["\uE1ED",!1]],["minimize",["\uF78C",!1]],["minus",["\uF068",!1]],["mitten",["\uF7B5",!1]],["mobile",["\uF3CE",!1]],["mobile-button",["\uF10B",!1]],["mobile-retro",["\uE527",!1]],["mobile-screen",["\uF3CF",!1]],["mobile-screen-button",["\uF3CD",!1]],["money-bill",["\uF0D6",!1]],["money-bill-1",["\uF3D1",!0]],["money-bill-1-wave",["\uF53B",!1]],["money-bill-transfer",["\uE528",!1]],["money-bill-trend-up",["\uE529",!1]],["money-bill-wave",["\uF53A",!1]],["money-bill-wheat",["\uE52A",!1]],["money-bills",["\uE1F3",!1]],["money-check",["\uF53C",!1]],["money-check-dollar",["\uF53D",!1]],["monument",["\uF5A6",!1]],["moon",["\uF186",!0]],["mortar-pestle",["\uF5A7",!1]],["mosque",["\uF678",!1]],["mosquito",["\uE52B",!1]],["mosquito-net",["\uE52C",!1]],["motorcycle",["\uF21C",!1]],["mound",["\uE52D",!1]],["mountain",["\uF6FC",!1]],["mountain-city",["\uE52E",!1]],["mountain-sun",["\uE52F",!1]],["mug-hot",["\uF7B6",!1]],["mug-saucer",["\uF0F4",!1]],["music",["\uF001",!1]],["n",["N",!1]],["naira-sign",["\uE1F6",!1]],["network-wired",["\uF6FF",!1]],["neuter",["\uF22C",!1]],["newspaper",["\uF1EA",!0]],["not-equal",["\uF53E",!1]],["notdef",["\uE1FE",!1]],["note-sticky",["\uF249",!0]],["notes-medical",["\uF481",!1]],["o",["O",!1]],["object-group",["\uF247",!0]],["object-ungroup",["\uF248",!0]],["oil-can",["\uF613",!1]],["oil-well",["\uE532",!1]],["om",["\uF679",!1]],["otter",["\uF700",!1]],["outdent",["\uF03B",!1]],["p",["P",!1]],["pager",["\uF815",!1]],["paint-roller",["\uF5AA",!1]],["paintbrush",["\uF1FC",!1]],["palette",["\uF53F",!1]],["pallet",["\uF482",!1]],["panorama",["\uE209",!1]],["paper-plane",["\uF1D8",!0]],["paperclip",["\uF0C6",!1]],["parachute-box",["\uF4CD",!1]],["paragraph",["\uF1DD",!1]],["passport",["\uF5AB",!1]],["paste",["\uF0EA",!0]],["pause",["\uF04C",!1]],["paw",["\uF1B0",!1]],["peace",["\uF67C",!1]],["pen",["\uF304",!1]],["pen-clip",["\uF305",!1]],["pen-fancy",["\uF5AC",!1]],["pen-nib",["\uF5AD",!1]],["pen-ruler",["\uF5AE",!1]],["pen-to-square",["\uF044",!0]],["pencil",["\uF303",!1]],["people-arrows",["\uE068",!1]],["people-carry-box",["\uF4CE",!1]],["people-group",["\uE533",!1]],["people-line",["\uE534",!1]],["people-pulling",["\uE535",!1]],["people-robbery",["\uE536",!1]],["people-roof",["\uE537",!1]],["pepper-hot",["\uF816",!1]],["percent",["%",!1]],["person",["\uF183",!1]],["person-arrow-down-to-line",["\uE538",!1]],["person-arrow-up-from-line",["\uE539",!1]],["person-biking",["\uF84A",!1]],["person-booth",["\uF756",!1]],["person-breastfeeding",["\uE53A",!1]],["person-burst",["\uE53B",!1]],["person-cane",["\uE53C",!1]],["person-chalkboard",["\uE53D",!1]],["person-circle-check",["\uE53E",!1]],["person-circle-exclamation",["\uE53F",!1]],["person-circle-minus",["\uE540",!1]],["person-circle-plus",["\uE541",!1]],["person-circle-question",["\uE542",!1]],["person-circle-xmark",["\uE543",!1]],["person-digging",["\uF85E",!1]],["person-dots-from-line",["\uF470",!1]],["person-dress",["\uF182",!1]],["person-dress-burst",["\uE544",!1]],["person-drowning",["\uE545",!1]],["person-falling",["\uE546",!1]],["person-falling-burst",["\uE547",!1]],["person-half-dress",["\uE548",!1]],["person-harassing",["\uE549",!1]],["person-hiking",["\uF6EC",!1]],["person-military-pointing",["\uE54A",!1]],["person-military-rifle",["\uE54B",!1]],["person-military-to-person",["\uE54C",!1]],["person-praying",["\uF683",!1]],["person-pregnant",["\uE31E",!1]],["person-rays",["\uE54D",!1]],["person-rifle",["\uE54E",!1]],["person-running",["\uF70C",!1]],["person-shelter",["\uE54F",!1]],["person-skating",["\uF7C5",!1]],["person-skiing",["\uF7C9",!1]],["person-skiing-nordic",["\uF7CA",!1]],["person-snowboarding",["\uF7CE",!1]],["person-swimming",["\uF5C4",!1]],["person-through-window",["\uE5A9",!1]],["person-walking",["\uF554",!1]],["person-walking-arrow-loop-left",["\uE551",!1]],["person-walking-arrow-right",["\uE552",!1]],["person-walking-dashed-line-arrow-right",["\uE553",!1]],["person-walking-luggage",["\uE554",!1]],["person-walking-with-cane",["\uF29D",!1]],["peseta-sign",["\uE221",!1]],["peso-sign",["\uE222",!1]],["phone",["\uF095",!1]],["phone-flip",["\uF879",!1]],["phone-slash",["\uF3DD",!1]],["phone-volume",["\uF2A0",!1]],["photo-film",["\uF87C",!1]],["piggy-bank",["\uF4D3",!1]],["pills",["\uF484",!1]],["pizza-slice",["\uF818",!1]],["place-of-worship",["\uF67F",!1]],["plane",["\uF072",!1]],["plane-arrival",["\uF5AF",!1]],["plane-circle-check",["\uE555",!1]],["plane-circle-exclamation",["\uE556",!1]],["plane-circle-xmark",["\uE557",!1]],["plane-departure",["\uF5B0",!1]],["plane-lock",["\uE558",!1]],["plane-slash",["\uE069",!1]],["plane-up",["\uE22D",!1]],["plant-wilt",["\uE5AA",!1]],["plate-wheat",["\uE55A",!1]],["play",["\uF04B",!1]],["plug",["\uF1E6",!1]],["plug-circle-bolt",["\uE55B",!1]],["plug-circle-check",["\uE55C",!1]],["plug-circle-exclamation",["\uE55D",!1]],["plug-circle-minus",["\uE55E",!1]],["plug-circle-plus",["\uE55F",!1]],["plug-circle-xmark",["\uE560",!1]],["plus",["+",!1]],["plus-minus",["\uE43C",!1]],["podcast",["\uF2CE",!1]],["poo",["\uF2FE",!1]],["poo-storm",["\uF75A",!1]],["poop",["\uF619",!1]],["power-off",["\uF011",!1]],["prescription",["\uF5B1",!1]],["prescription-bottle",["\uF485",!1]],["prescription-bottle-medical",["\uF486",!1]],["print",["\uF02F",!1]],["pump-medical",["\uE06A",!1]],["pump-soap",["\uE06B",!1]],["puzzle-piece",["\uF12E",!1]],["q",["Q",!1]],["qrcode",["\uF029",!1]],["question",["?",!1]],["quote-left",["\uF10D",!1]],["quote-right",["\uF10E",!1]],["r",["R",!1]],["radiation",["\uF7B9",!1]],["radio",["\uF8D7",!1]],["rainbow",["\uF75B",!1]],["ranking-star",["\uE561",!1]],["receipt",["\uF543",!1]],["record-vinyl",["\uF8D9",!1]],["rectangle-ad",["\uF641",!1]],["rectangle-list",["\uF022",!0]],["rectangle-xmark",["\uF410",!0]],["recycle",["\uF1B8",!1]],["registered",["\uF25D",!0]],["repeat",["\uF363",!1]],["reply",["\uF3E5",!1]],["reply-all",["\uF122",!1]],["republican",["\uF75E",!1]],["restroom",["\uF7BD",!1]],["retweet",["\uF079",!1]],["ribbon",["\uF4D6",!1]],["right-from-bracket",["\uF2F5",!1]],["right-left",["\uF362",!1]],["right-long",["\uF30B",!1]],["right-to-bracket",["\uF2F6",!1]],["ring",["\uF70B",!1]],["road",["\uF018",!1]],["road-barrier",["\uE562",!1]],["road-bridge",["\uE563",!1]],["road-circle-check",["\uE564",!1]],["road-circle-exclamation",["\uE565",!1]],["road-circle-xmark",["\uE566",!1]],["road-lock",["\uE567",!1]],["road-spikes",["\uE568",!1]],["robot",["\uF544",!1]],["rocket",["\uF135",!1]],["rotate",["\uF2F1",!1]],["rotate-left",["\uF2EA",!1]],["rotate-right",["\uF2F9",!1]],["route",["\uF4D7",!1]],["rss",["\uF09E",!1]],["ruble-sign",["\uF158",!1]],["rug",["\uE569",!1]],["ruler",["\uF545",!1]],["ruler-combined",["\uF546",!1]],["ruler-horizontal",["\uF547",!1]],["ruler-vertical",["\uF548",!1]],["rupee-sign",["\uF156",!1]],["rupiah-sign",["\uE23D",!1]],["s",["S",!1]],["sack-dollar",["\uF81D",!1]],["sack-xmark",["\uE56A",!1]],["sailboat",["\uE445",!1]],["satellite",["\uF7BF",!1]],["satellite-dish",["\uF7C0",!1]],["scale-balanced",["\uF24E",!1]],["scale-unbalanced",["\uF515",!1]],["scale-unbalanced-flip",["\uF516",!1]],["school",["\uF549",!1]],["school-circle-check",["\uE56B",!1]],["school-circle-exclamation",["\uE56C",!1]],["school-circle-xmark",["\uE56D",!1]],["school-flag",["\uE56E",!1]],["school-lock",["\uE56F",!1]],["scissors",["\uF0C4",!1]],["screwdriver",["\uF54A",!1]],["screwdriver-wrench",["\uF7D9",!1]],["scroll",["\uF70E",!1]],["scroll-torah",["\uF6A0",!1]],["sd-card",["\uF7C2",!1]],["section",["\uE447",!1]],["seedling",["\uF4D8",!1]],["server",["\uF233",!1]],["shapes",["\uF61F",!1]],["share",["\uF064",!1]],["share-from-square",["\uF14D",!0]],["share-nodes",["\uF1E0",!1]],["sheet-plastic",["\uE571",!1]],["shekel-sign",["\uF20B",!1]],["shield",["\uF132",!1]],["shield-cat",["\uE572",!1]],["shield-dog",["\uE573",!1]],["shield-halved",["\uF3ED",!1]],["shield-heart",["\uE574",!1]],["shield-virus",["\uE06C",!1]],["ship",["\uF21A",!1]],["shirt",["\uF553",!1]],["shoe-prints",["\uF54B",!1]],["shop",["\uF54F",!1]],["shop-lock",["\uE4A5",!1]],["shop-slash",["\uE070",!1]],["shower",["\uF2CC",!1]],["shrimp",["\uE448",!1]],["shuffle",["\uF074",!1]],["shuttle-space",["\uF197",!1]],["sign-hanging",["\uF4D9",!1]],["signal",["\uF012",!1]],["signature",["\uF5B7",!1]],["signs-post",["\uF277",!1]],["sim-card",["\uF7C4",!1]],["sink",["\uE06D",!1]],["sitemap",["\uF0E8",!1]],["skull",["\uF54C",!1]],["skull-crossbones",["\uF714",!1]],["slash",["\uF715",!1]],["sleigh",["\uF7CC",!1]],["sliders",["\uF1DE",!1]],["smog",["\uF75F",!1]],["smoking",["\uF48D",!1]],["snowflake",["\uF2DC",!0]],["snowman",["\uF7D0",!1]],["snowplow",["\uF7D2",!1]],["soap",["\uE06E",!1]],["socks",["\uF696",!1]],["solar-panel",["\uF5BA",!1]],["sort",["\uF0DC",!1]],["sort-down",["\uF0DD",!1]],["sort-up",["\uF0DE",!1]],["spa",["\uF5BB",!1]],["spaghetti-monster-flying",["\uF67B",!1]],["spell-check",["\uF891",!1]],["spider",["\uF717",!1]],["spinner",["\uF110",!1]],["splotch",["\uF5BC",!1]],["spoon",["\uF2E5",!1]],["spray-can",["\uF5BD",!1]],["spray-can-sparkles",["\uF5D0",!1]],["square",["\uF0C8",!0]],["square-arrow-up-right",["\uF14C",!1]],["square-caret-down",["\uF150",!0]],["square-caret-left",["\uF191",!0]],["square-caret-right",["\uF152",!0]],["square-caret-up",["\uF151",!0]],["square-check",["\uF14A",!0]],["square-envelope",["\uF199",!1]],["square-full",["\uF45C",!0]],["square-h",["\uF0FD",!1]],["square-minus",["\uF146",!0]],["square-nfi",["\uE576",!1]],["square-parking",["\uF540",!1]],["square-pen",["\uF14B",!1]],["square-person-confined",["\uE577",!1]],["square-phone",["\uF098",!1]],["square-phone-flip",["\uF87B",!1]],["square-plus",["\uF0FE",!0]],["square-poll-horizontal",["\uF682",!1]],["square-poll-vertical",["\uF681",!1]],["square-root-variable",["\uF698",!1]],["square-rss",["\uF143",!1]],["square-share-nodes",["\uF1E1",!1]],["square-up-right",["\uF360",!1]],["square-virus",["\uE578",!1]],["square-xmark",["\uF2D3",!1]],["staff-snake",["\uE579",!1]],["stairs",["\uE289",!1]],["stamp",["\uF5BF",!1]],["stapler",["\uE5AF",!1]],["star",["\uF005",!0]],["star-and-crescent",["\uF699",!1]],["star-half",["\uF089",!0]],["star-half-stroke",["\uF5C0",!0]],["star-of-david",["\uF69A",!1]],["star-of-life",["\uF621",!1]],["sterling-sign",["\uF154",!1]],["stethoscope",["\uF0F1",!1]],["stop",["\uF04D",!1]],["stopwatch",["\uF2F2",!1]],["stopwatch-20",["\uE06F",!1]],["store",["\uF54E",!1]],["store-slash",["\uE071",!1]],["street-view",["\uF21D",!1]],["strikethrough",["\uF0CC",!1]],["stroopwafel",["\uF551",!1]],["subscript",["\uF12C",!1]],["suitcase",["\uF0F2",!1]],["suitcase-medical",["\uF0FA",!1]],["suitcase-rolling",["\uF5C1",!1]],["sun",["\uF185",!0]],["sun-plant-wilt",["\uE57A",!1]],["superscript",["\uF12B",!1]],["swatchbook",["\uF5C3",!1]],["synagogue",["\uF69B",!1]],["syringe",["\uF48E",!1]],["t",["T",!1]],["table",["\uF0CE",!1]],["table-cells",["\uF00A",!1]],["table-cells-large",["\uF009",!1]],["table-columns",["\uF0DB",!1]],["table-list",["\uF00B",!1]],["table-tennis-paddle-ball",["\uF45D",!1]],["tablet",["\uF3FB",!1]],["tablet-button",["\uF10A",!1]],["tablet-screen-button",["\uF3FA",!1]],["tablets",["\uF490",!1]],["tachograph-digital",["\uF566",!1]],["tag",["\uF02B",!1]],["tags",["\uF02C",!1]],["tape",["\uF4DB",!1]],["tarp",["\uE57B",!1]],["tarp-droplet",["\uE57C",!1]],["taxi",["\uF1BA",!1]],["teeth",["\uF62E",!1]],["teeth-open",["\uF62F",!1]],["temperature-arrow-down",["\uE03F",!1]],["temperature-arrow-up",["\uE040",!1]],["temperature-empty",["\uF2CB",!1]],["temperature-full",["\uF2C7",!1]],["temperature-half",["\uF2C9",!1]],["temperature-high",["\uF769",!1]],["temperature-low",["\uF76B",!1]],["temperature-quarter",["\uF2CA",!1]],["temperature-three-quarters",["\uF2C8",!1]],["tenge-sign",["\uF7D7",!1]],["tent",["\uE57D",!1]],["tent-arrow-down-to-line",["\uE57E",!1]],["tent-arrow-left-right",["\uE57F",!1]],["tent-arrow-turn-left",["\uE580",!1]],["tent-arrows-down",["\uE581",!1]],["tents",["\uE582",!1]],["terminal",["\uF120",!1]],["text-height",["\uF034",!1]],["text-slash",["\uF87D",!1]],["text-width",["\uF035",!1]],["thermometer",["\uF491",!1]],["thumbs-down",["\uF165",!0]],["thumbs-up",["\uF164",!0]],["thumbtack",["\uF08D",!1]],["ticket",["\uF145",!1]],["ticket-simple",["\uF3FF",!1]],["timeline",["\uE29C",!1]],["toggle-off",["\uF204",!1]],["toggle-on",["\uF205",!1]],["toilet",["\uF7D8",!1]],["toilet-paper",["\uF71E",!1]],["toilet-paper-slash",["\uE072",!1]],["toilet-portable",["\uE583",!1]],["toilets-portable",["\uE584",!1]],["toolbox",["\uF552",!1]],["tooth",["\uF5C9",!1]],["torii-gate",["\uF6A1",!1]],["tornado",["\uF76F",!1]],["tower-broadcast",["\uF519",!1]],["tower-cell",["\uE585",!1]],["tower-observation",["\uE586",!1]],["tractor",["\uF722",!1]],["trademark",["\uF25C",!1]],["traffic-light",["\uF637",!1]],["trailer",["\uE041",!1]],["train",["\uF238",!1]],["train-subway",["\uF239",!1]],["train-tram",["\uE5B4",!1]],["transgender",["\uF225",!1]],["trash",["\uF1F8",!1]],["trash-arrow-up",["\uF829",!1]],["trash-can",["\uF2ED",!0]],["trash-can-arrow-up",["\uF82A",!1]],["tree",["\uF1BB",!1]],["tree-city",["\uE587",!1]],["triangle-exclamation",["\uF071",!1]],["trophy",["\uF091",!1]],["trowel",["\uE589",!1]],["trowel-bricks",["\uE58A",!1]],["truck",["\uF0D1",!1]],["truck-arrow-right",["\uE58B",!1]],["truck-droplet",["\uE58C",!1]],["truck-fast",["\uF48B",!1]],["truck-field",["\uE58D",!1]],["truck-field-un",["\uE58E",!1]],["truck-front",["\uE2B7",!1]],["truck-medical",["\uF0F9",!1]],["truck-monster",["\uF63B",!1]],["truck-moving",["\uF4DF",!1]],["truck-pickup",["\uF63C",!1]],["truck-plane",["\uE58F",!1]],["truck-ramp-box",["\uF4DE",!1]],["tty",["\uF1E4",!1]],["turkish-lira-sign",["\uE2BB",!1]],["turn-down",["\uF3BE",!1]],["turn-up",["\uF3BF",!1]],["tv",["\uF26C",!1]],["u",["U",!1]],["umbrella",["\uF0E9",!1]],["umbrella-beach",["\uF5CA",!1]],["underline",["\uF0CD",!1]],["universal-access",["\uF29A",!1]],["unlock",["\uF09C",!1]],["unlock-keyhole",["\uF13E",!1]],["up-down",["\uF338",!1]],["up-down-left-right",["\uF0B2",!1]],["up-long",["\uF30C",!1]],["up-right-and-down-left-from-center",["\uF424",!1]],["up-right-from-square",["\uF35D",!1]],["upload",["\uF093",!1]],["user",["\uF007",!0]],["user-astronaut",["\uF4FB",!1]],["user-check",["\uF4FC",!1]],["user-clock",["\uF4FD",!1]],["user-doctor",["\uF0F0",!1]],["user-gear",["\uF4FE",!1]],["user-graduate",["\uF501",!1]],["user-group",["\uF500",!1]],["user-injured",["\uF728",!1]],["user-large",["\uF406",!1]],["user-large-slash",["\uF4FA",!1]],["user-lock",["\uF502",!1]],["user-minus",["\uF503",!1]],["user-ninja",["\uF504",!1]],["user-nurse",["\uF82F",!1]],["user-pen",["\uF4FF",!1]],["user-plus",["\uF234",!1]],["user-secret",["\uF21B",!1]],["user-shield",["\uF505",!1]],["user-slash",["\uF506",!1]],["user-tag",["\uF507",!1]],["user-tie",["\uF508",!1]],["user-xmark",["\uF235",!1]],["users",["\uF0C0",!1]],["users-between-lines",["\uE591",!1]],["users-gear",["\uF509",!1]],["users-line",["\uE592",!1]],["users-rays",["\uE593",!1]],["users-rectangle",["\uE594",!1]],["users-slash",["\uE073",!1]],["users-viewfinder",["\uE595",!1]],["utensils",["\uF2E7",!1]],["v",["V",!1]],["van-shuttle",["\uF5B6",!1]],["vault",["\uE2C5",!1]],["vector-square",["\uF5CB",!1]],["venus",["\uF221",!1]],["venus-double",["\uF226",!1]],["venus-mars",["\uF228",!1]],["vest",["\uE085",!1]],["vest-patches",["\uE086",!1]],["vial",["\uF492",!1]],["vial-circle-check",["\uE596",!1]],["vial-virus",["\uE597",!1]],["vials",["\uF493",!1]],["video",["\uF03D",!1]],["video-slash",["\uF4E2",!1]],["vihara",["\uF6A7",!1]],["virus",["\uE074",!1]],["virus-covid",["\uE4A8",!1]],["virus-covid-slash",["\uE4A9",!1]],["virus-slash",["\uE075",!1]],["viruses",["\uE076",!1]],["voicemail",["\uF897",!1]],["volcano",["\uF770",!1]],["volleyball",["\uF45F",!1]],["volume-high",["\uF028",!1]],["volume-low",["\uF027",!1]],["volume-off",["\uF026",!1]],["volume-xmark",["\uF6A9",!1]],["vr-cardboard",["\uF729",!1]],["w",["W",!1]],["walkie-talkie",["\uF8EF",!1]],["wallet",["\uF555",!1]],["wand-magic",["\uF0D0",!1]],["wand-magic-sparkles",["\uE2CA",!1]],["wand-sparkles",["\uF72B",!1]],["warehouse",["\uF494",!1]],["water",["\uF773",!1]],["water-ladder",["\uF5C5",!1]],["wave-square",["\uF83E",!1]],["weight-hanging",["\uF5CD",!1]],["weight-scale",["\uF496",!1]],["wheat-awn",["\uE2CD",!1]],["wheat-awn-circle-exclamation",["\uE598",!1]],["wheelchair",["\uF193",!1]],["wheelchair-move",["\uE2CE",!1]],["whiskey-glass",["\uF7A0",!1]],["wifi",["\uF1EB",!1]],["wind",["\uF72E",!1]],["window-maximize",["\uF2D0",!0]],["window-minimize",["\uF2D1",!0]],["window-restore",["\uF2D2",!0]],["wine-bottle",["\uF72F",!1]],["wine-glass",["\uF4E3",!1]],["wine-glass-empty",["\uF5CE",!1]],["won-sign",["\uF159",!1]],["worm",["\uE599",!1]],["wrench",["\uF0AD",!1]],["x",["X",!1]],["x-ray",["\uF497",!1]],["xmark",["\uF00D",!1]],["xmarks-lines",["\uE59A",!1]],["y",["Y",!1]],["yen-sign",["\uF157",!1]],["yin-yang",["\uF6AD",!1]],["z",["Z",!1]]]);window.getFontAwesome6Metadata=()=>new Map(r),window.getFontAwesome6IconMetadata=i=>r.get(e.get(i)||i)})();(()=>{let e=new Map([[16,14],[24,18],[32,28],[48,42],[64,56],[96,84],[128,112],[144,130]]);class r extends HTMLElement{root=void 0;svgStyle=document.createElement("style");connectedCallback(){this.validate();let c=this.getRoot(),t=document.createElement("slot");c.append(t),this.setAttribute("aria-hidden","true"),this.translate=!1}validate(){if(this.size===0)throw new TypeError("Must provide an icon size.");if(!e.has(this.size))throw new TypeError("Must provide a valid icon size.")}getRoot(){return this.root===void 0&&(this.root=this.attachShadow({mode:"open"}),this.updateRenderSize(),this.root.append(this.svgStyle)),this.root}updateRenderSize(){let c=e.get(this.size);this.svgStyle.textContent=` ::slotted(svg) { fill: currentColor; - height: ${f}px; + height: ${c}px; shape-rendering: geometricprecision; } - `}get size(){let f=this.getAttribute("size");return f===null?0:parseInt(f)}set size(f){if(!e.has(f))throw new Error(`Refused to set the invalid icon size '${f}'.`);this.setAttribute("size",f.toString()),this.updateRenderSize()}}window.customElements.define("fa-brand",r)})();(()=>{let e;function r(){return e===void 0&&(e=!0,window.getComputedStyle(document.documentElement).getPropertyValue("--fa-font-family")==="Font Awesome 6 Pro"&&(e=!1)),e}let n=new Map([[16,14],[24,18],[32,28],[48,42],[64,56],[96,84],[128,112],[144,130]]);class f extends HTMLElement{connectedCallback(){this.hasAttribute("size")||this.setAttribute("size","16"),this.validate(),this.setIcon(this.name,this.solid),this.setAttribute("aria-hidden","true"),this.translate=!1}validate(){if(!n.has(this.size))throw new TypeError("Must provide a valid icon size.");if(this.name==="")throw new TypeError("Must provide the name of the icon.");if(!this.isValidIconName(this.name))throw new TypeError(`The icon '${this.name}' is unknown or unsupported.`)}setIcon(a,l=!1){if(!this.isValidIconName(a))throw new TypeError(`The icon '${a}' is unknown or unsupported.`);!l&&!this.hasNonSolidStyle(a)&&(l=!0),!(a===this.name&&l===this.solid&&this.shadowRoot!==null)&&(l?this.setAttribute("solid",""):this.removeAttribute("solid"),this.setAttribute("name",a),this.updateIcon())}isValidIconName(a){return a!==null&&window.getFontAwesome6IconMetadata(a)!==void 0}hasNonSolidStyle(a){if(r()){let[,l]=window.getFontAwesome6IconMetadata(a);if(!l)return!1}return!0}getShadowRoot(){return this.shadowRoot===null?this.attachShadow({mode:"open"}):this.shadowRoot}updateIcon(){let a=this.getShadowRoot();if(a.childNodes[0]?.remove(),this.name==="spinner")a.append(this.createSpinner());else{let[l]=window.getFontAwesome6IconMetadata(this.name);a.append(l)}}createSpinner(){let a=document.createElement("div");a.innerHTML=` + `}get size(){let c=this.getAttribute("size");return c===null?0:parseInt(c)}set size(c){if(!e.has(c))throw new Error(`Refused to set the invalid icon size '${c}'.`);this.setAttribute("size",c.toString()),this.updateRenderSize()}}window.customElements.define("fa-brand",r)})();(()=>{let e;function r(){return e===void 0&&(e=!0,window.getComputedStyle(document.documentElement).getPropertyValue("--fa-font-family")==="Font Awesome 6 Pro"&&(e=!1)),e}let i=new Map([[16,14],[24,18],[32,28],[48,42],[64,56],[96,84],[128,112],[144,130]]);class c extends HTMLElement{connectedCallback(){this.hasAttribute("size")||this.setAttribute("size","16"),this.validate(),this.setIcon(this.name,this.solid),this.setAttribute("aria-hidden","true"),this.translate=!1}validate(){if(!i.has(this.size))throw new TypeError("Must provide a valid icon size.");if(this.name==="")throw new TypeError("Must provide the name of the icon.");if(!this.isValidIconName(this.name))throw new TypeError(`The icon '${this.name}' is unknown or unsupported.`)}setIcon(a,l=!1){if(!this.isValidIconName(a))throw new TypeError(`The icon '${a}' is unknown or unsupported.`);!l&&!this.hasNonSolidStyle(a)&&(l=!0),!(a===this.name&&l===this.solid&&this.shadowRoot!==null)&&(l?this.setAttribute("solid",""):this.removeAttribute("solid"),this.setAttribute("name",a),this.updateIcon())}isValidIconName(a){return a!==null&&window.getFontAwesome6IconMetadata(a)!==void 0}hasNonSolidStyle(a){if(r()){let[,l]=window.getFontAwesome6IconMetadata(a);if(!l)return!1}return!0}getShadowRoot(){return this.shadowRoot===null?this.attachShadow({mode:"open"}):this.shadowRoot}updateIcon(){let a=this.getShadowRoot();if(a.childNodes[0]?.remove(),this.name==="spinner")a.append(this.createSpinner());else{let[l]=window.getFontAwesome6IconMetadata(this.name);a.append(l)}}createSpinner(){let a=document.createElement("div");a.innerHTML=` @@ -55,19 +55,29 @@ Expecting `+Z.join(", ")+", got '"+(this.terminals_[_]||_)+"'":ae="Parse error o stroke-dashoffset: -124; } } - `,a.append(l),a}get solid(){return this.hasAttribute("solid")}get name(){return this.getAttribute("name")||""}get size(){let a=this.getAttribute("size");return a===null?0:parseInt(a)}set size(a){if(!n.has(a))throw new Error(`Refused to set the invalid icon size '${a}'.`);this.setAttribute("size",a.toString())}}window.customElements.define("fa-icon",f)})();{let e=Date.now()-window.TIME_NOW*1e3,r=document.documentElement.lang,f=(()=>{let h="",m=document.querySelector('meta[name="timezone"]');if(m){h=m.content;try{Intl.DateTimeFormat(void 0,{timeZone:h})}catch{h=""}}return h||(h=Intl.DateTimeFormat().resolvedOptions().timeZone),h})(),t,a,l=()=>{let h=new Date,m=new Date(h.getFullYear(),h.getMonth(),h.getDate());t!==m.getTime()&&(t=m.getTime(),a=new Date(h.getFullYear(),h.getMonth(),h.getDate()-1).getTime())};l();let d;(p=>(p[p.Today=0]="Today",p[p.Yesterday=-1]="Yesterday"))(d||={});let g={Date:new Intl.DateTimeFormat(r,{dateStyle:"long",timeZone:f}),DateAndTime:new Intl.DateTimeFormat(r,{dateStyle:"long",timeStyle:"short",timeZone:f}),DayOfWeekAndTime:new Intl.DateTimeFormat(r,{weekday:"long",hour:"2-digit",minute:"2-digit",timeZone:f}),Hours:new Intl.RelativeTimeFormat(r),Minutes:new Intl.RelativeTimeFormat(r),TodayOrYesterday:new Intl.RelativeTimeFormat(r,{numeric:"auto"})},w={OneMinute:60,OneHour:3600,OneDay:86400,TwelveHours:3600*12,SixDays:86400*6};class q extends HTMLElement{#e;#a;get date(){if(this.#e===void 0){let m=this.getAttribute("date");if(!m)throw new Error("The 'date' attribute is missing.");this.#e=new Date(m)}return this.#e}set date(m){this.setAttribute("date",m.toISOString()),this.refresh(!0)}get static(){return this.hasAttribute("static")}set static(m){m===!0?this.setAttribute("static",""):this.removeAttribute("static")}connectedCallback(){this.refresh(!0)}refresh(m){let p=this.date,E=Math.trunc((Date.now()-p.getTime()-e)/1e3);if(this.#a===void 0){this.#a=document.createElement("time");let x=this.attachShadow({mode:"open"});x.append(this.#a);let D=document.createElement("style");D.textContent=` + `,a.append(l),a}get solid(){return this.hasAttribute("solid")}get name(){return this.getAttribute("name")||""}get size(){let a=this.getAttribute("size");return a===null?0:parseInt(a)}set size(a){if(!i.has(a))throw new Error(`Refused to set the invalid icon size '${a}'.`);this.setAttribute("size",a.toString())}}window.customElements.define("fa-icon",c)})();{let e=Date.now()-window.TIME_NOW*1e3,r=document.documentElement.lang,c=(()=>{let h="",m=document.querySelector('meta[name="timezone"]');if(m){h=m.content;try{Intl.DateTimeFormat(void 0,{timeZone:h})}catch{h=""}}return h||(h=Intl.DateTimeFormat().resolvedOptions().timeZone),h})(),t,a,l=()=>{let h=new Date,m=new Date(h.getFullYear(),h.getMonth(),h.getDate());t!==m.getTime()&&(t=m.getTime(),a=new Date(h.getFullYear(),h.getMonth(),h.getDate()-1).getTime())};l();let d;(p=>(p[p.Today=0]="Today",p[p.Yesterday=-1]="Yesterday"))(d||={});let g={Date:new Intl.DateTimeFormat(r,{dateStyle:"long",timeZone:c}),DateAndTime:new Intl.DateTimeFormat(r,{dateStyle:"long",timeStyle:"short",timeZone:c}),DayOfWeekAndTime:new Intl.DateTimeFormat(r,{weekday:"long",hour:"2-digit",minute:"2-digit",timeZone:c}),Hours:new Intl.RelativeTimeFormat(r),Minutes:new Intl.RelativeTimeFormat(r),TodayOrYesterday:new Intl.RelativeTimeFormat(r,{numeric:"auto"})},w={OneMinute:60,OneHour:3600,OneDay:86400,TwelveHours:3600*12,SixDays:86400*6};class q extends HTMLElement{#e;#a;get date(){if(this.#e===void 0){let m=this.getAttribute("date");if(!m)throw new Error("The 'date' attribute is missing.");this.#e=new Date(m)}return this.#e}set date(m){this.setAttribute("date",m.toISOString()),this.refresh(!0)}get static(){return this.hasAttribute("static")}set static(m){m===!0?this.setAttribute("static",""):this.removeAttribute("static")}connectedCallback(){this.refresh(!0)}refresh(m){let p=this.date,E=Math.trunc((Date.now()-p.getTime()-e)/1e3);if(this.#a===void 0){this.#a=document.createElement("time");let x=this.attachShadow({mode:"open"});x.append(this.#a);let D=document.createElement("style");D.textContent=` @media print { time::after { content: " (" attr(title) ")"; } - }`,x.append(D)}m&&(this.#a.dateTime=p.toISOString(),this.#a.title=g.DateAndTime.format(p));let k;if(this.static)k=this.#a.title;else if(Et?k=this.#t(x,0):p.getTime()>a?k=this.#t(x,-1):k=x.map(W=>W.value).join(""):k=g.DateAndTime.format(p)}else k=g.Date.format(p);k=k.charAt(0).toUpperCase()+k.slice(1),this.#a.textContent=k}#t(m,p){return m.map(k=>k.type==="weekday"?g.TodayOrYesterday.format(p,"day"):k.value).join("")}}window.customElements.define("woltlab-core-date-time",q);let S=()=>{document.querySelectorAll("woltlab-core-date-time").forEach(h=>h.refresh(!1))},z,P=()=>{z=window.setInterval(()=>{l(),S()},6e4)};document.addEventListener("DOMContentLoaded",()=>P(),{once:!0}),document.addEventListener("visibilitychange",()=>{document.hidden?window.clearInterval(z):(S(),P())})}{let r=[24,48,96];class n extends HTMLElement{#e;#a;connectedCallback(){this.#e===void 0&&this.#t()}attributeChangedCallback(t,a,l){if(t==="size"){let d=parseInt(l||"");if(!r.includes(d)){let g=parseInt(a||"");r.includes(g)||(g=24),this.setAttribute(t,g.toString())}}}#t(){this.classList.add("loading-indicator"),this.hasAttribute("size")||this.setAttribute("size",24 .toString()),this.#e=document.createElement("fa-icon"),this.#e.size=this.size,this.#e.setIcon("spinner"),this.#a=document.createElement("span"),this.#a.classList.add("loading-indicator__text"),this.#a.textContent=window.WoltLabLanguage.getPhrase("wcf.global.loading"),this.#a.hidden=this.hideText;let t=document.createElement("div");t.classList.add("loading-indicator__wrapper"),t.append(this.#e,this.#a),this.append(t)}get size(){return parseInt(this.getAttribute("size"))}set size(t){if(!r.includes(t))throw new TypeError(`The size ${t} is unrecognized, permitted values are ${r.join(", ")}.`);this.setAttribute("size",t.toString()),this.#e&&(this.#e.size=t)}get hideText(){return this.hasAttribute("hide-text")}set hideText(t){t?this.setAttribute("hide-text",""):this.removeAttribute("hide-text"),this.#a&&(this.#a.hidden=t)}static get observedAttributes(){return["size"]}}window.customElements.define("woltlab-core-loading-indicator",n)}{let e;(l=>(l.Info="info",l.Success="success",l.Warning="warning",l.Error="error"))(e||={});class r extends HTMLElement{#e;#a;connectedCallback(){let f=this.attachShadow({mode:"open"});this.#e=document.createElement("fa-icon"),this.#e.size=24,this.#e.setIcon(this.icon,!0),this.#e.slot="icon",this.append(this.#e);let t=document.createElement("style");t.textContent=` + }`,x.append(D)}m&&(this.#a.dateTime=p.toISOString(),this.#a.title=g.DateAndTime.format(p));let k;if(this.static)k=this.#a.title;else if(Et?k=this.#t(x,0):p.getTime()>a?k=this.#t(x,-1):k=x.map(W=>W.value).join(""):k=g.DateAndTime.format(p)}else k=g.Date.format(p);k=k.charAt(0).toUpperCase()+k.slice(1),this.#a.textContent=k}#t(m,p){return m.map(k=>k.type==="weekday"?g.TodayOrYesterday.format(p,"day"):k.value).join("")}}window.customElements.define("woltlab-core-date-time",q);let S=()=>{document.querySelectorAll("woltlab-core-date-time").forEach(h=>h.refresh(!1))},z,P=()=>{z=window.setInterval(()=>{l(),S()},6e4)};document.addEventListener("DOMContentLoaded",()=>P(),{once:!0}),document.addEventListener("visibilitychange",()=>{document.hidden?window.clearInterval(z):(S(),P())})}{class e extends HTMLElement{#e;constructor(){super(),this.#e=document.createElement("input"),this.#e.type="file",this.#e.addEventListener("change",()=>{let{files:i}=this.#e;if(!(i===null||i.length===0))for(let c of i){let t=new CustomEvent("shouldUpload",{cancelable:!0,detail:c});if(this.dispatchEvent(t),t.defaultPrevented)continue;let a=new CustomEvent("upload",{detail:c});this.dispatchEvent(a)}})}connectedCallback(){this.attachShadow({mode:"open"}).append(this.#e);let c=document.createElement("style");c.textContent=` + :host { + position: relative; + } + + input { + inset: 0; + position: absolute; + visibility: hidden; + } + `}}window.customElements.define("woltlab-core-file-upload",e)}{let r=[24,48,96];class i extends HTMLElement{#e;#a;connectedCallback(){this.#e===void 0&&this.#t()}attributeChangedCallback(t,a,l){if(t==="size"){let d=parseInt(l||"");if(!r.includes(d)){let g=parseInt(a||"");r.includes(g)||(g=24),this.setAttribute(t,g.toString())}}}#t(){this.classList.add("loading-indicator"),this.hasAttribute("size")||this.setAttribute("size",24 .toString()),this.#e=document.createElement("fa-icon"),this.#e.size=this.size,this.#e.setIcon("spinner"),this.#a=document.createElement("span"),this.#a.classList.add("loading-indicator__text"),this.#a.textContent=window.WoltLabLanguage.getPhrase("wcf.global.loading"),this.#a.hidden=this.hideText;let t=document.createElement("div");t.classList.add("loading-indicator__wrapper"),t.append(this.#e,this.#a),this.append(t)}get size(){return parseInt(this.getAttribute("size"))}set size(t){if(!r.includes(t))throw new TypeError(`The size ${t} is unrecognized, permitted values are ${r.join(", ")}.`);this.setAttribute("size",t.toString()),this.#e&&(this.#e.size=t)}get hideText(){return this.hasAttribute("hide-text")}set hideText(t){t?this.setAttribute("hide-text",""):this.removeAttribute("hide-text"),this.#a&&(this.#a.hidden=t)}static get observedAttributes(){return["size"]}}window.customElements.define("woltlab-core-loading-indicator",i)}{let e;(l=>(l.Info="info",l.Success="success",l.Warning="warning",l.Error="error"))(e||={});class r extends HTMLElement{#e;#a;connectedCallback(){let c=this.attachShadow({mode:"open"});this.#e=document.createElement("fa-icon"),this.#e.size=24,this.#e.setIcon(this.icon,!0),this.#e.slot="icon",this.append(this.#e);let t=document.createElement("style");t.textContent=` :host { align-items: center; display: grid; gap: 5px; grid-template-columns: max-content auto; } - `,this.#a=document.createElement("div"),this.#a.classList.add("content");let a=document.createElement("slot");this.#a.append(a);let l=document.createElement("slot");l.name="icon",f.append(t,l,this.#a),this.#t()}#t(){this.#e.setIcon(this.icon,!0),this.#a.setAttribute("role",this.type==="error"?"alert":"status"),this.classList.remove(...Object.values(e)),this.classList.add(this.type)}get type(){if(!this.hasAttribute("type"))throw new Error("missing attribute 'type'");let f=this.getAttribute("type");if(!Object.values(e).includes(f))throw new Error(`invalid value '${f}' for attribute 'type' given`);return f}set type(f){this.setAttribute("type",f),this.#e!==void 0&&this.#t()}get icon(){switch(this.type){case"success":return"circle-check";case"warning":return"triangle-exclamation";case"error":return"circle-exclamation";case"info":return"circle-info"}}}window.customElements.define("woltlab-core-notice",r)}{let e,r=()=>(e===void 0&&(e=window.matchMedia("(max-width: 544px)")),e);class n extends HTMLElement{#e="pagination";connectedCallback(){this.#a(),r().addEventListener("change",()=>this.#a())}#a(){if(this.innerHTML="",this.count<2)return;this.classList.add(`${this.#e}__wrapper`);let t=this.#t();this.append(t);let a=this.#n();a&&t.append(a);let l=document.createElement("ul");l.classList.add(`${this.#e}__list`),t.append(l),l.append(this.#r(1)),this.page>this.thresholdForEllipsis+1&&l.append(this.#l()),this.#c().forEach(g=>{l.append(g)}),this.count-this.page>this.thresholdForEllipsis&&l.append(this.#l()),l.append(this.#r(this.count));let d=this.#o();d&&t.append(d)}#t(){let t=document.createElement("nav");return t.setAttribute("role","navigation"),t.setAttribute("aria-label",window.WoltLabLanguage.getPhrase("wcf.page.pagination")),t.classList.add(this.#e),t}#n(){if(this.page===1)return;let t=document.createElement("div");t.classList.add(`${this.#e}__prev`);let a=this.#s(this.page-1);a instanceof HTMLAnchorElement&&(a.rel="prev"),a.title=window.WoltLabLanguage.getPhrase("wcf.global.page.previous"),a.classList.add("jsTooltip"),t.append(a);let l=document.createElement("fa-icon");return l.setIcon("arrow-left"),a.append(l),t}#o(){if(this.page===this.count)return;let t=document.createElement("div");t.classList.add(`${this.#e}__next`);let a=this.#s(this.page+1);a instanceof HTMLAnchorElement&&(a.rel="next"),a.title=window.WoltLabLanguage.getPhrase("wcf.global.page.next"),a.classList.add("jsTooltip"),t.append(a);let l=document.createElement("fa-icon");return l.setIcon("arrow-right"),a.append(l),t}#s(t){let a,l=this.getLinkUrl(t);return l?(a=document.createElement("a"),a.href=l):(a=document.createElement("button"),a.type="button",this.page===t?a.disabled=!0:a.addEventListener("click",()=>{this.#i(t)})),a.classList.add(`${this.#e}__link`),a}#r(t){let a=document.createElement("li");a.classList.add(`${this.#e}__item`);let l=this.#s(t);return l.setAttribute("aria-label",window.WoltLabLanguage.getPhrase("wcf.page.pageNo",{pageNo:t})),t===this.page&&(l.setAttribute("aria-current","page"),l.classList.add(`${this.#e}__link--current`)),l.textContent=t.toLocaleString(document.documentElement.lang),a.append(l),a}#c(){let t=[],a,l;r().matches?(a=this.page,l=this.page):(a=this.page-1,a===3&&a--,l=this.page+1,l===this.count-2&&l++);for(let d=a;d<=l;d++)d<=1||d>=this.count||t.push(this.#r(d));return t}#l(){let t=document.createElement("li");t.classList.add(`${this.#e}__item`,`${this.#e}__item--ellipsis`);let a=document.createElement("button");return a.type="button",a.title=window.WoltLabLanguage.getPhrase("wcf.page.jumpTo"),a.classList.add("pagination__link","jsTooltip"),a.innerHTML="⋯",a.addEventListener("click",()=>{this.dispatchEvent(new CustomEvent("jumpToPage"))}),t.append(a),t}get thresholdForEllipsis(){return r().matches?1:3}getLinkUrl(t){if(!this.url)return"";let a=new URL(this.url);return a.search+=a.search!==""?"&":"?",a.search+=new URLSearchParams([["pageNo",t.toString()]]).toString(),a.toString()}jumpToPage(t){let a=this.getLinkUrl(t);a?window.location.href=a:this.#i(t)}#i(t){let a=new CustomEvent("switchPage",{cancelable:!0,detail:t});this.dispatchEvent(a),a.defaultPrevented||(this.page=t)}get count(){return this.hasAttribute("count")?parseInt(this.getAttribute("count")):0}set count(t){this.setAttribute("count",t.toString()),this.#a()}get page(){return this.hasAttribute("page")?parseInt(this.getAttribute("page")):1}set page(t){this.setAttribute("page",t.toString()),this.#a()}get url(){return this.getAttribute("url")}set url(t){this.setAttribute("url",t),this.#a()}}window.customElements.define("woltlab-core-pagination",n)}{class e extends HTMLElement{connectedCallback(){this.setData(this.#a(),this.#t())}setData(n,f){this.#e(n,f)}get objectId(){return parseInt(this.getAttribute("object-id"))}get objectType(){return this.getAttribute("object-type")}#e(n,f){if(this.innerHTML="",!n.size)return;let t=document.createElement("button");t.classList.add("reactionSummary","jsTooltip"),t.title=window.WoltLabLanguage.getPhrase("wcf.reactions.summary.listReactions"),t.addEventListener("click",()=>{this.dispatchEvent(new Event("showDetails"))}),this.append(t),n.forEach((a,l)=>{let d=document.createElement("span");d.classList.add("reactionCountButton"),l===f&&d.classList.add("selected");let g=document.createElement("span");g.innerHTML=window.REACTION_TYPES[l].renderedIcon,d.append(g);let w=document.createElement("span");w.classList.add("reactionCount"),w.textContent=a.toString(),d.append(w),t.append(d)})}#a(){let n=JSON.parse(this.getAttribute("data"));return this.removeAttribute("data"),new Map(n)}#t(){return parseInt(this.getAttribute("selected-reaction"))}}window.customElements.define("woltlab-core-reaction-summary",e)}window.WoltLabLanguage=ne;window.WoltLabTemplate=V;window.HTMLParsedElement=pe;})(); + `,this.#a=document.createElement("div"),this.#a.classList.add("content");let a=document.createElement("slot");this.#a.append(a);let l=document.createElement("slot");l.name="icon",c.append(t,l,this.#a),this.#t()}#t(){this.#e.setIcon(this.icon,!0),this.#a.setAttribute("role",this.type==="error"?"alert":"status"),this.classList.remove(...Object.values(e)),this.classList.add(this.type)}get type(){if(!this.hasAttribute("type"))throw new Error("missing attribute 'type'");let c=this.getAttribute("type");if(!Object.values(e).includes(c))throw new Error(`invalid value '${c}' for attribute 'type' given`);return c}set type(c){this.setAttribute("type",c),this.#e!==void 0&&this.#t()}get icon(){switch(this.type){case"success":return"circle-check";case"warning":return"triangle-exclamation";case"error":return"circle-exclamation";case"info":return"circle-info"}}}window.customElements.define("woltlab-core-notice",r)}{let e,r=()=>(e===void 0&&(e=window.matchMedia("(max-width: 544px)")),e);class i extends HTMLElement{#e="pagination";connectedCallback(){this.#a(),r().addEventListener("change",()=>this.#a())}#a(){if(this.innerHTML="",this.count<2)return;this.classList.add(`${this.#e}__wrapper`);let t=this.#t();this.append(t);let a=this.#n();a&&t.append(a);let l=document.createElement("ul");l.classList.add(`${this.#e}__list`),t.append(l),l.append(this.#r(1)),this.page>this.thresholdForEllipsis+1&&l.append(this.#l()),this.#c().forEach(g=>{l.append(g)}),this.count-this.page>this.thresholdForEllipsis&&l.append(this.#l()),l.append(this.#r(this.count));let d=this.#o();d&&t.append(d)}#t(){let t=document.createElement("nav");return t.setAttribute("role","navigation"),t.setAttribute("aria-label",window.WoltLabLanguage.getPhrase("wcf.page.pagination")),t.classList.add(this.#e),t}#n(){if(this.page===1)return;let t=document.createElement("div");t.classList.add(`${this.#e}__prev`);let a=this.#s(this.page-1);a instanceof HTMLAnchorElement&&(a.rel="prev"),a.title=window.WoltLabLanguage.getPhrase("wcf.global.page.previous"),a.classList.add("jsTooltip"),t.append(a);let l=document.createElement("fa-icon");return l.setIcon("arrow-left"),a.append(l),t}#o(){if(this.page===this.count)return;let t=document.createElement("div");t.classList.add(`${this.#e}__next`);let a=this.#s(this.page+1);a instanceof HTMLAnchorElement&&(a.rel="next"),a.title=window.WoltLabLanguage.getPhrase("wcf.global.page.next"),a.classList.add("jsTooltip"),t.append(a);let l=document.createElement("fa-icon");return l.setIcon("arrow-right"),a.append(l),t}#s(t){let a,l=this.getLinkUrl(t);return l?(a=document.createElement("a"),a.href=l):(a=document.createElement("button"),a.type="button",this.page===t?a.disabled=!0:a.addEventListener("click",()=>{this.#i(t)})),a.classList.add(`${this.#e}__link`),a}#r(t){let a=document.createElement("li");a.classList.add(`${this.#e}__item`);let l=this.#s(t);return l.setAttribute("aria-label",window.WoltLabLanguage.getPhrase("wcf.page.pageNo",{pageNo:t})),t===this.page&&(l.setAttribute("aria-current","page"),l.classList.add(`${this.#e}__link--current`)),l.textContent=t.toLocaleString(document.documentElement.lang),a.append(l),a}#c(){let t=[],a,l;r().matches?(a=this.page,l=this.page):(a=this.page-1,a===3&&a--,l=this.page+1,l===this.count-2&&l++);for(let d=a;d<=l;d++)d<=1||d>=this.count||t.push(this.#r(d));return t}#l(){let t=document.createElement("li");t.classList.add(`${this.#e}__item`,`${this.#e}__item--ellipsis`);let a=document.createElement("button");return a.type="button",a.title=window.WoltLabLanguage.getPhrase("wcf.page.jumpTo"),a.classList.add("pagination__link","jsTooltip"),a.innerHTML="⋯",a.addEventListener("click",()=>{this.dispatchEvent(new CustomEvent("jumpToPage"))}),t.append(a),t}get thresholdForEllipsis(){return r().matches?1:3}getLinkUrl(t){if(!this.url)return"";let a=new URL(this.url);return a.search+=a.search!==""?"&":"?",a.search+=new URLSearchParams([["pageNo",t.toString()]]).toString(),a.toString()}jumpToPage(t){let a=this.getLinkUrl(t);a?window.location.href=a:this.#i(t)}#i(t){let a=new CustomEvent("switchPage",{cancelable:!0,detail:t});this.dispatchEvent(a),a.defaultPrevented||(this.page=t)}get count(){return this.hasAttribute("count")?parseInt(this.getAttribute("count")):0}set count(t){this.setAttribute("count",t.toString()),this.#a()}get page(){return this.hasAttribute("page")?parseInt(this.getAttribute("page")):1}set page(t){this.setAttribute("page",t.toString()),this.#a()}get url(){return this.getAttribute("url")}set url(t){this.setAttribute("url",t),this.#a()}}window.customElements.define("woltlab-core-pagination",i)}{class e extends HTMLElement{connectedCallback(){this.setData(this.#a(),this.#t())}setData(i,c){this.#e(i,c)}get objectId(){return parseInt(this.getAttribute("object-id"))}get objectType(){return this.getAttribute("object-type")}#e(i,c){if(this.innerHTML="",!i.size)return;let t=document.createElement("button");t.classList.add("reactionSummary","jsTooltip"),t.title=window.WoltLabLanguage.getPhrase("wcf.reactions.summary.listReactions"),t.addEventListener("click",()=>{this.dispatchEvent(new Event("showDetails"))}),this.append(t),i.forEach((a,l)=>{let d=document.createElement("span");d.classList.add("reactionCountButton"),l===c&&d.classList.add("selected");let g=document.createElement("span");g.innerHTML=window.REACTION_TYPES[l].renderedIcon,d.append(g);let w=document.createElement("span");w.classList.add("reactionCount"),w.textContent=a.toString(),d.append(w),t.append(d)})}#a(){let i=JSON.parse(this.getAttribute("data"));return this.removeAttribute("data"),new Map(i)}#t(){return parseInt(this.getAttribute("selected-reaction"))}}window.customElements.define("woltlab-core-reaction-summary",e)}window.WoltLabLanguage=ne;window.WoltLabTemplate=V;window.HTMLParsedElement=pe;})(); /** * Handles the low level management of language items. * diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php new file mode 100644 index 00000000000..e17f42aa313 --- /dev/null +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -0,0 +1,17 @@ + Date: Tue, 26 Dec 2023 15:23:15 +0100 Subject: [PATCH 05/97] Implement a naive chunked upload --- .../shared_messageFormAttachments.tpl | 2 +- ts/WoltLabSuite/Core/Component/File/Upload.ts | 19 +++++- .../Core/Component/File/Upload.js | 14 +++- .../lib/action/FileUploadAction.class.php | 68 +++++++++++++++++++ .../FileUploadPreflightAction.class.php | 67 ++++++++++++++++++ wcfsetup/setup/db/install.sql | 12 ++-- 6 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php diff --git a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl index b68bfdb2158..8b190553d77 100644 --- a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl +++ b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl @@ -1,7 +1,7 @@
diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index d886849d3bd..261ab4d9ab1 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -1,15 +1,28 @@ import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; +type PreflightResponse = { + endpoints: string[]; +}; + async function upload(element: WoltlabCoreFileUploadElement, file: File): Promise { + const response = (await prepareRequest(element.dataset.endpoint!) + .post({ + filename: file.name, + filesize: file.size, + }) + .fetchAsJson()) as PreflightResponse; + const { endpoints } = response; + const chunkSize = 2_000_000; const chunks = Math.ceil(file.size / chunkSize); for (let i = 0; i < chunks; i++) { - const chunk = file.slice(i * chunkSize, i * chunkSize + chunkSize + 1); + const start = i * chunkSize; + const end = start + chunkSize; + const chunk = file.slice(start, end); - const response = await prepareRequest(element.dataset.endpoint!).post(chunk).fetchAsResponse(); - console.log(response); + await prepareRequest(endpoints[i]).post(chunk).fetchAsResponse(); } } 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 24b61c94e6f..4fdde035d67 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -3,12 +3,20 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; async function upload(element, file) { + const response = (await (0, Backend_1.prepareRequest)(element.dataset.endpoint) + .post({ + filename: file.name, + filesize: file.size, + }) + .fetchAsJson()); + const { endpoints } = response; const chunkSize = 2000000; const chunks = Math.ceil(file.size / chunkSize); for (let i = 0; i < chunks; i++) { - const chunk = file.slice(i * chunkSize, i * chunkSize + chunkSize + 1); - const response = await (0, Backend_1.prepareRequest)(element.dataset.endpoint).post(chunk).fetchAsResponse(); - console.log(response); + const start = i * chunkSize; + const end = start + chunkSize; + const chunk = file.slice(start, end); + await (0, Backend_1.prepareRequest)(endpoints[i]).post(chunk).fetchAsResponse(); } } function setup() { diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index e17f42aa313..4c396ce744e 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -6,11 +6,79 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use wcf\http\Helper; +use wcf\system\exception\IllegalLinkException; +use wcf\system\WCF; final class FileUploadAction implements RequestHandlerInterface { public function handle(ServerRequestInterface $request): ResponseInterface { + // TODO: `sequenceNo` should be of type `non-negative-int`, but requires Valinor 1.7+ + $parameters = Helper::mapQueryParameters( + $request->getQueryParams(), + <<<'EOT' + array { + identifier: non-empty-string, + sequenceNo: int, + } + EOT, + ); + + $sql = "SELECT * + FROM wcf1_file_temporary + WHERE identifier = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$parameters['identifier']]); + $row = $statement->fetchSingleRow(); + + if ($row === false) { + throw new IllegalLinkException(); + } + + // Check if this is a valid sequence no. + // TODO: The chunk calculation shouldn’t be based on a fixed number. + $chunkSize = 2_000_000; + $chunks = (int)\ceil($row['filesize'] / $chunkSize); + if ($parameters['sequenceNo'] >= $chunks) { + throw new IllegalLinkException(); + } + + // Check if the actual size matches the expectations. + if ($parameters['sequenceNo'] === $chunks - 1) { + // The last chunk is most likely smaller than our chunk size. + $expectedSize = $row['filesize'] - $chunkSize * ($chunks - 1); + } else { + $expectedSize = $chunkSize; + } + + $chunk = \file_get_contents('php://input'); + $actualSize = \strlen($chunk); + + if ($actualSize !== $expectedSize) { + throw new IllegalLinkException(); + } + + $folderA = \substr($row['identifier'], 0, 2); + $folderB = \substr($row['identifier'], 2, 2); + + $tmpPath = \sprintf( + \WCF_DIR . '_data/private/fileUpload/%s/%s/', + $folderA, + $folderB, + ); + if (!\is_dir($tmpPath)) { + \mkdir($tmpPath, recursive: true); + } + + $filename = \sprintf( + '%s-%d.bin', + $row['identifier'], + $parameters['sequenceNo'], + ); + + \file_put_contents($tmpPath . $filename, $chunk); + // TODO: Dummy response to simulate a successful upload of a chunk. return new EmptyResponse(); } diff --git a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php new file mode 100644 index 00000000000..1fd23b200f8 --- /dev/null +++ b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php @@ -0,0 +1,67 @@ +getParsedBody(), + <<<'EOT' + array { + filename: non-empty-string, + filesize: positive-int, + } + EOT, + ); + + // TODO: The chunk calculation shouldn’t be based on a fixed number. + $chunkSize = 2_000_000; + $chunks = (int)\ceil($parameters['filesize'] / $chunkSize); + + $identifier = $this->createTemporaryFile($parameters); + + $endpoints = []; + for ($i = 0; $i < $chunks; $i++) { + $endpoints[] = LinkHandler::getInstance()->getControllerLink( + FileUploadAction::class, + [ + 'identifier' => $identifier, + 'sequenceNo' => $i, + ] + ); + } + + return new JsonResponse([ + 'endpoints' => $endpoints, + ]); + } + + private function createTemporaryFile(array $parameters): string + { + $identifier = \bin2hex(\random_bytes(20)); + + $sql = "INSERT INTO wcf1_file_temporary + (identifier, time, filename, filesize) + VALUES (?, ?, ?, ?)"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $identifier, + \TIME_NOW, + $parameters['filename'], + $parameters['filesize'], + ]); + + return $identifier; + } +} diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index a32722b7fc5..cb59bd13b4c 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -595,20 +595,18 @@ CREATE TABLE wcf1_event_listener ( DROP TABLE IF EXISTS wcf1_file_temporary; CREATE TABLE wcf1_file_temporary ( - uuidv4 CHAR(36) NOT NULL PRIMARY KEY, - prefix CHAR(40) NOT NULL, - lastModified INT NOT NULL, + identifier CHAR(40) NOT NULL PRIMARY KEY, + time INT NOT NULL, filename VARCHAR(255) NOT NULL, - filesize BIGINT NOT NULL, - chunks SMALLINT NOT NULL + filesize BIGINT NOT NULL ); DROP TABLE IF EXISTS wcf1_file_chunk; CREATE TABLE wcf1_file_chunk ( - uuidv4 CHAR(36) NOT NULL, + identifier CHAR(40) NOT NULL, sequenceNo SMALLINT NOT NULL, - PRIMARY KEY chunk (uuidv4, sequenceNo) + PRIMARY KEY chunk (identifier, sequenceNo) ); /* As the flood control table can be a high traffic table and as it is periodically emptied, From 17a4f2ae343330c61e1d87a100513eae84afe115 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Tue, 26 Dec 2023 19:20:15 +0100 Subject: [PATCH 06/97] Use buffers to write uploaded files --- ts/WoltLabSuite/Core/Component/File/Upload.ts | 20 ++++++ .../Core/Component/File/Upload.js | 15 ++++ .../lib/action/FileUploadAction.class.php | 72 ++++++++++++++----- 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 261ab4d9ab1..5e0236741a9 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -17,13 +17,30 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis const chunkSize = 2_000_000; const chunks = Math.ceil(file.size / chunkSize); + const arrayBufferToHex = (buffer: ArrayBuffer): string => { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + }; + + const hash = await window.crypto.subtle.digest("SHA-256", await file.arrayBuffer()); + console.log("checksum for the entire file is:", arrayBufferToHex(hash)); + + const data: Blob[] = []; for (let i = 0; i < chunks; i++) { const start = i * chunkSize; const end = start + chunkSize; const chunk = file.slice(start, end); + data.push(chunk); + + console.log("Uploading", start, "to", end, " (total: " + chunk.size + " of " + file.size + ")"); await prepareRequest(endpoints[i]).post(chunk).fetchAsResponse(); } + + const uploadedChunks = new Blob(data); + const uploadedHash = await window.crypto.subtle.digest("SHA-256", await uploadedChunks.arrayBuffer()); + console.log("checksum for the entire file is:", arrayBufferToHex(uploadedHash)); } export function setup(): void { @@ -31,5 +48,8 @@ export function setup(): void { element.addEventListener("upload", (event: CustomEvent) => { void upload(element, event.detail); }); + + const file = new File(["a".repeat(4_000_001)], "test.txt"); + void upload(element, file); }); } 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 4fdde035d67..2c6a0e6dd40 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -12,18 +12,33 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co const { endpoints } = response; const chunkSize = 2000000; const chunks = Math.ceil(file.size / chunkSize); + const arrayBufferToHex = (buffer) => { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + }; + const hash = await window.crypto.subtle.digest("SHA-256", await file.arrayBuffer()); + console.log("checksum for the entire file is:", arrayBufferToHex(hash)); + const data = []; for (let i = 0; i < chunks; i++) { const start = i * chunkSize; const end = start + chunkSize; const chunk = file.slice(start, end); + data.push(chunk); + console.log("Uploading", start, "to", end, " (total: " + chunk.size + " of " + file.size + ")"); await (0, Backend_1.prepareRequest)(endpoints[i]).post(chunk).fetchAsResponse(); } + const uploadedChunks = new Blob(data); + const uploadedHash = await window.crypto.subtle.digest("SHA-256", await uploadedChunks.arrayBuffer()); + console.log("checksum for the entire file is:", arrayBufferToHex(uploadedHash)); } function setup() { (0, Selector_1.wheneverFirstSeen)("woltlab-core-file-upload", (element) => { element.addEventListener("upload", (event) => { void upload(element, event.detail); }); + const file = new File(["a".repeat(4000001)], "test.txt"); + void upload(element, file); }); } exports.setup = setup; diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index 4c396ce744e..4e14983b4e3 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -8,6 +8,7 @@ use Psr\Http\Server\RequestHandlerInterface; use wcf\http\Helper; use wcf\system\exception\IllegalLinkException; +use wcf\system\io\AtomicWriter; use wcf\system\WCF; final class FileUploadAction implements RequestHandlerInterface @@ -44,21 +45,6 @@ public function handle(ServerRequestInterface $request): ResponseInterface throw new IllegalLinkException(); } - // Check if the actual size matches the expectations. - if ($parameters['sequenceNo'] === $chunks - 1) { - // The last chunk is most likely smaller than our chunk size. - $expectedSize = $row['filesize'] - $chunkSize * ($chunks - 1); - } else { - $expectedSize = $chunkSize; - } - - $chunk = \file_get_contents('php://input'); - $actualSize = \strlen($chunk); - - if ($actualSize !== $expectedSize) { - throw new IllegalLinkException(); - } - $folderA = \substr($row['identifier'], 0, 2); $folderB = \substr($row['identifier'], 2, 2); @@ -77,7 +63,61 @@ public function handle(ServerRequestInterface $request): ResponseInterface $parameters['sequenceNo'], ); - \file_put_contents($tmpPath . $filename, $chunk); + // Write the chunk using a buffer to avoid blowing up the memory limit. + // See https://stackoverflow.com/a/61997147 + $file = new AtomicWriter($tmpPath . $filename); + $bufferSize = 1 * 1024 * 1024; + + $fh = \fopen('php://input', 'rb'); + while (!\feof($fh)) { + $file->write(\fread($fh, $bufferSize)); + } + \fclose($fh); + + $file->flush(); + + // Check if we have all chunks. + $data = []; + for ($i = 0; $i < $chunks; $i++) { + $filename = \sprintf( + '%s-%d.bin', + $row['identifier'], + $i, + ); + + if (\file_exists($tmpPath . $filename)) { + $data[] = $tmpPath . $filename; + } + } + + if (\count($data) === $chunks) { + // Concatenate the files by reading only a limited buffer at a time + // to avoid blowing up the memory limit. + // See https://stackoverflow.com/a/61997147 + $bufferSize = 1 * 1024 * 1024; + + $newFilename = \sprintf('%s-final.bin', $row['identifier']); + $file = new AtomicWriter($tmpPath . $newFilename); + foreach ($data as $fileChunk) { + $fh = \fopen($fileChunk, 'rb'); + while (!\feof($fh)) { + $file->write(\fread($fh, $bufferSize)); + } + \fclose($fh); + } + + $file->flush(); + + \wcfDebug( + \memory_get_peak_usage(true), + \hash_file( + 'sha256', + $tmpPath . $newFilename, + ) + ); + } + + \wcfDebug(\memory_get_peak_usage(true)); // TODO: Dummy response to simulate a successful upload of a chunk. return new EmptyResponse(); From 06a7cdbfc6bc4f16388b0d8d12a3925a91a96014 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Wed, 27 Dec 2023 17:54:10 +0100 Subject: [PATCH 07/97] Add SHA-256 checksums to the uploaded data --- ts/WoltLabSuite/Core/Component/File/Upload.ts | 33 +++++++------- .../Core/Component/File/Upload.js | 28 ++++++------ .../lib/action/FileUploadAction.class.php | 43 +++++++++++++------ .../FileUploadPreflightAction.class.php | 12 +++--- wcfsetup/setup/db/install.sql | 3 +- 5 files changed, 68 insertions(+), 51 deletions(-) diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 5e0236741a9..85e6925845e 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -6,10 +6,13 @@ type PreflightResponse = { }; async function upload(element: WoltlabCoreFileUploadElement, file: File): Promise { + const fileHash = await getSha256Hash(await file.arrayBuffer()); + const response = (await prepareRequest(element.dataset.endpoint!) .post({ filename: file.name, - filesize: file.size, + fileSize: file.size, + fileHash, }) .fetchAsJson()) as PreflightResponse; const { endpoints } = response; @@ -17,30 +20,26 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis const chunkSize = 2_000_000; const chunks = Math.ceil(file.size / chunkSize); - const arrayBufferToHex = (buffer: ArrayBuffer): string => { - return Array.from(new Uint8Array(buffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - }; - - const hash = await window.crypto.subtle.digest("SHA-256", await file.arrayBuffer()); - console.log("checksum for the entire file is:", arrayBufferToHex(hash)); - - const data: Blob[] = []; for (let i = 0; i < chunks; i++) { const start = i * chunkSize; const end = start + chunkSize; const chunk = file.slice(start, end); - data.push(chunk); - console.log("Uploading", start, "to", end, " (total: " + chunk.size + " of " + file.size + ")"); + const endpoint = new URL(endpoints[i]); - await prepareRequest(endpoints[i]).post(chunk).fetchAsResponse(); + const checksum = await getSha256Hash(await chunk.arrayBuffer()); + endpoint.searchParams.append("checksum", checksum); + + await prepareRequest(endpoint.toString()).post(chunk).fetchAsResponse(); } +} + +async function getSha256Hash(data: BufferSource): Promise { + const buffer = await window.crypto.subtle.digest("SHA-256", data); - const uploadedChunks = new Blob(data); - const uploadedHash = await window.crypto.subtle.digest("SHA-256", await uploadedChunks.arrayBuffer()); - console.log("checksum for the entire file is:", arrayBufferToHex(uploadedHash)); + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); } export function setup(): void { 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 2c6a0e6dd40..48adb30f3bc 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -3,34 +3,32 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; async function upload(element, file) { + const fileHash = await getSha256Hash(await file.arrayBuffer()); const response = (await (0, Backend_1.prepareRequest)(element.dataset.endpoint) .post({ filename: file.name, - filesize: file.size, + fileSize: file.size, + fileHash, }) .fetchAsJson()); const { endpoints } = response; const chunkSize = 2000000; const chunks = Math.ceil(file.size / chunkSize); - const arrayBufferToHex = (buffer) => { - return Array.from(new Uint8Array(buffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - }; - const hash = await window.crypto.subtle.digest("SHA-256", await file.arrayBuffer()); - console.log("checksum for the entire file is:", arrayBufferToHex(hash)); - const data = []; for (let i = 0; i < chunks; i++) { const start = i * chunkSize; const end = start + chunkSize; const chunk = file.slice(start, end); - data.push(chunk); - console.log("Uploading", start, "to", end, " (total: " + chunk.size + " of " + file.size + ")"); - await (0, Backend_1.prepareRequest)(endpoints[i]).post(chunk).fetchAsResponse(); + const endpoint = new URL(endpoints[i]); + const checksum = await getSha256Hash(await chunk.arrayBuffer()); + endpoint.searchParams.append("checksum", checksum); + await (0, Backend_1.prepareRequest)(endpoint.toString()).post(chunk).fetchAsResponse(); } - const uploadedChunks = new Blob(data); - const uploadedHash = await window.crypto.subtle.digest("SHA-256", await uploadedChunks.arrayBuffer()); - console.log("checksum for the entire file is:", arrayBufferToHex(uploadedHash)); + } + async function getSha256Hash(data) { + 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 setup() { (0, Selector_1.wheneverFirstSeen)("woltlab-core-file-upload", (element) => { diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index 4e14983b4e3..1fcbb6c7a9c 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -9,6 +9,7 @@ use wcf\http\Helper; use wcf\system\exception\IllegalLinkException; use wcf\system\io\AtomicWriter; +use wcf\system\io\File; use wcf\system\WCF; final class FileUploadAction implements RequestHandlerInterface @@ -20,6 +21,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $request->getQueryParams(), <<<'EOT' array { + checksum: non-empty-string, identifier: non-empty-string, sequenceNo: int, } @@ -34,14 +36,31 @@ public function handle(ServerRequestInterface $request): ResponseInterface $row = $statement->fetchSingleRow(); if ($row === false) { + // TODO: Proper error message throw new IllegalLinkException(); } // Check if this is a valid sequence no. // TODO: The chunk calculation shouldn’t be based on a fixed number. $chunkSize = 2_000_000; - $chunks = (int)\ceil($row['filesize'] / $chunkSize); + $chunks = (int)\ceil($row['fileSize'] / $chunkSize); if ($parameters['sequenceNo'] >= $chunks) { + // TODO: Proper error message + throw new IllegalLinkException(); + } + + // Check if the checksum matches the received data. + $ctx = \hash_init('sha256'); + $bufferSize = 1 * 1024 * 1024; + $stream = $request->getBody(); + while (!$stream->eof()) { + \hash_update($ctx, $stream->read($bufferSize)); + } + $result = \hash_final($ctx); + $stream->rewind(); + + if ($result !== $parameters['checksum']) { + // TODO: Proper error message throw new IllegalLinkException(); } @@ -65,16 +84,14 @@ public function handle(ServerRequestInterface $request): ResponseInterface // Write the chunk using a buffer to avoid blowing up the memory limit. // See https://stackoverflow.com/a/61997147 - $file = new AtomicWriter($tmpPath . $filename); + $result = new AtomicWriter($tmpPath . $filename); $bufferSize = 1 * 1024 * 1024; - $fh = \fopen('php://input', 'rb'); - while (!\feof($fh)) { - $file->write(\fread($fh, $bufferSize)); + while (!$stream->eof()) { + $result->write($stream->read($bufferSize)); } - \fclose($fh); - $file->flush(); + $result->flush(); // Check if we have all chunks. $data = []; @@ -97,16 +114,16 @@ public function handle(ServerRequestInterface $request): ResponseInterface $bufferSize = 1 * 1024 * 1024; $newFilename = \sprintf('%s-final.bin', $row['identifier']); - $file = new AtomicWriter($tmpPath . $newFilename); + $result = new AtomicWriter($tmpPath . $newFilename); foreach ($data as $fileChunk) { - $fh = \fopen($fileChunk, 'rb'); - while (!\feof($fh)) { - $file->write(\fread($fh, $bufferSize)); + $source = new File($fileChunk, 'rb'); + while (!$source->eof()) { + $result->write($source->read($bufferSize)); } - \fclose($fh); + $source->close(); } - $file->flush(); + $result->flush(); \wcfDebug( \memory_get_peak_usage(true), diff --git a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php index 1fd23b200f8..42ae6e952b8 100644 --- a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php @@ -20,14 +20,15 @@ public function handle(ServerRequestInterface $request): ResponseInterface <<<'EOT' array { filename: non-empty-string, - filesize: positive-int, + fileSize: positive-int, + fileHash: non-empty-string, } EOT, ); // TODO: The chunk calculation shouldn’t be based on a fixed number. $chunkSize = 2_000_000; - $chunks = (int)\ceil($parameters['filesize'] / $chunkSize); + $chunks = (int)\ceil($parameters['fileSize'] / $chunkSize); $identifier = $this->createTemporaryFile($parameters); @@ -52,14 +53,15 @@ private function createTemporaryFile(array $parameters): string $identifier = \bin2hex(\random_bytes(20)); $sql = "INSERT INTO wcf1_file_temporary - (identifier, time, filename, filesize) - VALUES (?, ?, ?, ?)"; + (identifier, time, filename, fileSize, fileHash) + VALUES (?, ?, ?, ?, ?)"; $statement = WCF::getDB()->prepare($sql); $statement->execute([ $identifier, \TIME_NOW, $parameters['filename'], - $parameters['filesize'], + $parameters['fileSize'], + $parameters['fileHash'], ]); return $identifier; diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index cb59bd13b4c..c3520a4d557 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -598,7 +598,8 @@ CREATE TABLE wcf1_file_temporary ( identifier CHAR(40) NOT NULL PRIMARY KEY, time INT NOT NULL, filename VARCHAR(255) NOT NULL, - filesize BIGINT NOT NULL + fileSize BIGINT NOT NULL, + fileHash CHAR(64) NOT NULL ); DROP TABLE IF EXISTS wcf1_file_chunk; From 532eceee59465bba62bda6682461efe4db319f9a Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Wed, 27 Dec 2023 18:06:10 +0100 Subject: [PATCH 08/97] Remove the unnecessary table `wcf1_file_chunk` There is no need to track each chunk because we can simply use the file system as the single source of truth. --- wcfsetup/setup/db/install.sql | 8 -------- 1 file changed, 8 deletions(-) diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index c3520a4d557..d22a2540cbd 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -602,14 +602,6 @@ CREATE TABLE wcf1_file_temporary ( fileHash CHAR(64) NOT NULL ); -DROP TABLE IF EXISTS wcf1_file_chunk; -CREATE TABLE wcf1_file_chunk ( - identifier CHAR(40) NOT NULL, - sequenceNo SMALLINT NOT NULL, - - PRIMARY KEY chunk (identifier, sequenceNo) -); - /* As the flood control table can be a high traffic table and as it is periodically emptied, there is no foreign key on the `objectTypeID` to speed up insertions. */ DROP TABLE IF EXISTS wcf1_flood_control; From 0a901ebe2d8312e1204c7daa815c04b9c007c6ad Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 28 Dec 2023 13:24:59 +0100 Subject: [PATCH 09/97] Dynamically calculate the chunk size --- ts/WoltLabSuite/Core/Component/File/Upload.ts | 5 +- .../Core/Component/File/Upload.js | 5 +- .../lib/action/FileUploadAction.class.php | 47 +++++++++++++------ .../FileUploadPreflightAction.class.php | 15 +++++- 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 85e6925845e..a9c2a7c1df9 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -17,10 +17,9 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis .fetchAsJson()) as PreflightResponse; const { endpoints } = response; - const chunkSize = 2_000_000; - const chunks = Math.ceil(file.size / chunkSize); + const chunkSize = Math.ceil(file.size / endpoints.length); - for (let i = 0; i < chunks; i++) { + for (let i = 0, length = endpoints.length; i < length; i++) { const start = i * chunkSize; const end = start + chunkSize; const chunk = file.slice(start, end); 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 48adb30f3bc..9bcf12e246d 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -12,9 +12,8 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co }) .fetchAsJson()); const { endpoints } = response; - const chunkSize = 2000000; - const chunks = Math.ceil(file.size / chunkSize); - for (let i = 0; i < chunks; i++) { + const chunkSize = Math.ceil(file.size / endpoints.length); + for (let i = 0, length = endpoints.length; i < length; i++) { const start = i * chunkSize; const end = start + chunkSize; const chunk = file.slice(start, end); diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index 1fcbb6c7a9c..af6b373beea 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -41,8 +41,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface } // Check if this is a valid sequence no. - // TODO: The chunk calculation shouldn’t be based on a fixed number. - $chunkSize = 2_000_000; + $chunkSize = $this->getOptimalChunkSize(); $chunks = (int)\ceil($row['fileSize'] / $chunkSize); if ($parameters['sequenceNo'] >= $chunks) { // TODO: Proper error message @@ -117,26 +116,44 @@ public function handle(ServerRequestInterface $request): ResponseInterface $result = new AtomicWriter($tmpPath . $newFilename); foreach ($data as $fileChunk) { $source = new File($fileChunk, 'rb'); - while (!$source->eof()) { - $result->write($source->read($bufferSize)); + try { + while (!$source->eof()) { + $result->write($source->read($bufferSize)); + } + } finally { + $source->close(); } - $source->close(); } $result->flush(); - \wcfDebug( - \memory_get_peak_usage(true), - \hash_file( - 'sha256', - $tmpPath . $newFilename, - ) - ); - } + // Check if the final result matches the expected checksum. + $checksum = \hash_file('sha256', $tmpPath . $newFilename); + if ($checksum !== $row['checksum']) { + // TODO: Proper error message + throw new IllegalLinkException(); + } - \wcfDebug(\memory_get_peak_usage(true)); + // Remove the temporary chunks. + foreach ($data as $fileChunk) { + \unlink($fileChunk); + } + + // TODO: Move the data from the temporary file to the actual "file". + } - // TODO: Dummy response to simulate a successful upload of a chunk. return new EmptyResponse(); } + + // TODO: This is currently duplicated in `FileUploadPreflightAction` + private function getOptimalChunkSize(): int + { + $postMaxSize = \ini_parse_quantity(\ini_get('post_max_size')); + if ($postMaxSize === 0) { + // Disabling it is fishy, assume a more reasonable limit of 100 MB. + $postMaxSize = 100 * 1_024 * 1_024; + } + + return $postMaxSize; + } } diff --git a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php index 42ae6e952b8..0e1d11e62a4 100644 --- a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php @@ -26,8 +26,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface EOT, ); - // TODO: The chunk calculation shouldn’t be based on a fixed number. - $chunkSize = 2_000_000; + $chunkSize = $this->getOptimalChunkSize(); $chunks = (int)\ceil($parameters['fileSize'] / $chunkSize); $identifier = $this->createTemporaryFile($parameters); @@ -66,4 +65,16 @@ private function createTemporaryFile(array $parameters): string return $identifier; } + + // TODO: This is currently duplicated in `FileUploadAction` + private function getOptimalChunkSize(): int + { + $postMaxSize = \ini_parse_quantity(\ini_get('post_max_size')); + if ($postMaxSize === 0) { + // Disabling it is fishy, assume a more reasonable limit of 100 MB. + $postMaxSize = 100 * 1_024 * 1_024; + } + + return $postMaxSize; + } } From 8aea8b3d6a9ef843926a86729d0e711aabc23eec Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 28 Dec 2023 16:37:34 +0100 Subject: [PATCH 10/97] Add a proper DBO for the handling of temporary files --- .../lib/action/FileUploadAction.class.php | 45 +++++------------- .../FileUploadPreflightAction.class.php | 47 +++++++------------ .../file/temporary/FileTemporary.class.php | 40 ++++++++++++++++ .../temporary/FileTemporaryAction.class.php | 19 ++++++++ .../temporary/FileTemporaryEditor.class.php | 23 +++++++++ .../temporary/FileTemporaryList.class.php | 21 +++++++++ wcfsetup/setup/db/install.sql | 2 +- 7 files changed, 133 insertions(+), 64 deletions(-) create mode 100644 wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php create mode 100644 wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php create mode 100644 wcfsetup/install/files/lib/data/file/temporary/FileTemporaryEditor.class.php create mode 100644 wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index af6b373beea..9c5e34d779e 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -6,11 +6,11 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use wcf\data\file\temporary\FileTemporary; use wcf\http\Helper; use wcf\system\exception\IllegalLinkException; use wcf\system\io\AtomicWriter; use wcf\system\io\File; -use wcf\system\WCF; final class FileUploadAction implements RequestHandlerInterface { @@ -28,22 +28,15 @@ public function handle(ServerRequestInterface $request): ResponseInterface EOT, ); - $sql = "SELECT * - FROM wcf1_file_temporary - WHERE identifier = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([$parameters['identifier']]); - $row = $statement->fetchSingleRow(); - - if ($row === false) { + $fileTemporary = new FileTemporary($parameters['identifier']); + if (!$fileTemporary->identifier) { // TODO: Proper error message throw new IllegalLinkException(); } // Check if this is a valid sequence no. - $chunkSize = $this->getOptimalChunkSize(); - $chunks = (int)\ceil($row['fileSize'] / $chunkSize); - if ($parameters['sequenceNo'] >= $chunks) { + $numberOfChunks = $fileTemporary->getNumberOfChunks(); + if ($parameters['sequenceNo'] >= $numberOfChunks) { // TODO: Proper error message throw new IllegalLinkException(); } @@ -63,8 +56,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface throw new IllegalLinkException(); } - $folderA = \substr($row['identifier'], 0, 2); - $folderB = \substr($row['identifier'], 2, 2); + $folderA = \substr($fileTemporary->identifier, 0, 2); + $folderB = \substr($fileTemporary->identifier, 2, 2); $tmpPath = \sprintf( \WCF_DIR . '_data/private/fileUpload/%s/%s/', @@ -77,7 +70,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $filename = \sprintf( '%s-%d.bin', - $row['identifier'], + $fileTemporary->identifier, $parameters['sequenceNo'], ); @@ -94,10 +87,10 @@ public function handle(ServerRequestInterface $request): ResponseInterface // Check if we have all chunks. $data = []; - for ($i = 0; $i < $chunks; $i++) { + for ($i = 0; $i < $numberOfChunks; $i++) { $filename = \sprintf( '%s-%d.bin', - $row['identifier'], + $fileTemporary->identifier, $i, ); @@ -106,13 +99,13 @@ public function handle(ServerRequestInterface $request): ResponseInterface } } - if (\count($data) === $chunks) { + if (\count($data) === $numberOfChunks) { // Concatenate the files by reading only a limited buffer at a time // to avoid blowing up the memory limit. // See https://stackoverflow.com/a/61997147 $bufferSize = 1 * 1024 * 1024; - $newFilename = \sprintf('%s-final.bin', $row['identifier']); + $newFilename = \sprintf('%s-final.bin', $fileTemporary->identifier); $result = new AtomicWriter($tmpPath . $newFilename); foreach ($data as $fileChunk) { $source = new File($fileChunk, 'rb'); @@ -129,7 +122,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface // Check if the final result matches the expected checksum. $checksum = \hash_file('sha256', $tmpPath . $newFilename); - if ($checksum !== $row['checksum']) { + if ($checksum !== $fileTemporary->fileHash) { // TODO: Proper error message throw new IllegalLinkException(); } @@ -144,16 +137,4 @@ public function handle(ServerRequestInterface $request): ResponseInterface return new EmptyResponse(); } - - // TODO: This is currently duplicated in `FileUploadPreflightAction` - private function getOptimalChunkSize(): int - { - $postMaxSize = \ini_parse_quantity(\ini_get('post_max_size')); - if ($postMaxSize === 0) { - // Disabling it is fishy, assume a more reasonable limit of 100 MB. - $postMaxSize = 100 * 1_024 * 1_024; - } - - return $postMaxSize; - } } diff --git a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php index 0e1d11e62a4..727fe5d2c1c 100644 --- a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php @@ -6,9 +6,10 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use wcf\data\file\temporary\FileTemporary; +use wcf\data\file\temporary\FileTemporaryAction; use wcf\http\Helper; use wcf\system\request\LinkHandler; -use wcf\system\WCF; final class FileUploadPreflightAction implements RequestHandlerInterface { @@ -26,17 +27,15 @@ public function handle(ServerRequestInterface $request): ResponseInterface EOT, ); - $chunkSize = $this->getOptimalChunkSize(); - $chunks = (int)\ceil($parameters['fileSize'] / $chunkSize); - - $identifier = $this->createTemporaryFile($parameters); + $fileTemporary = $this->createTemporaryFile($parameters); + $numberOfChunks = $fileTemporary->getNumberOfChunks(); $endpoints = []; - for ($i = 0; $i < $chunks; $i++) { + for ($i = 0; $i < $numberOfChunks; $i++) { $endpoints[] = LinkHandler::getInstance()->getControllerLink( FileUploadAction::class, [ - 'identifier' => $identifier, + 'identifier' => $fileTemporary->identifier, 'sequenceNo' => $i, ] ); @@ -47,34 +46,20 @@ public function handle(ServerRequestInterface $request): ResponseInterface ]); } - private function createTemporaryFile(array $parameters): string + private function createTemporaryFile(array $parameters): FileTemporary { $identifier = \bin2hex(\random_bytes(20)); - $sql = "INSERT INTO wcf1_file_temporary - (identifier, time, filename, fileSize, fileHash) - VALUES (?, ?, ?, ?, ?)"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - $identifier, - \TIME_NOW, - $parameters['filename'], - $parameters['fileSize'], - $parameters['fileHash'], + $action = new FileTemporaryAction([], 'create', [ + 'data' => [ + 'identifier'=>$identifier, + 'time'=>\TIME_NOW, + 'filename'=>$parameters['filename'], + 'fileSize'=>$parameters['fileSize'], + 'fileHash'=>$parameters['fileHash'], + ], ]); - return $identifier; - } - - // TODO: This is currently duplicated in `FileUploadAction` - private function getOptimalChunkSize(): int - { - $postMaxSize = \ini_parse_quantity(\ini_get('post_max_size')); - if ($postMaxSize === 0) { - // Disabling it is fishy, assume a more reasonable limit of 100 MB. - $postMaxSize = 100 * 1_024 * 1_024; - } - - return $postMaxSize; + return $action->executeAction()['returnValues']; } } diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php new file mode 100644 index 00000000000..c9fd98ee360 --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php @@ -0,0 +1,40 @@ + + * @since 6.1 + * + * @property-read string $identifier + * @property-read int|null $time + * @property-read string $filename + * @property-read int $fileSize + * @property-read string $fileHash + */ +class FileTemporary extends DatabaseObject +{ + protected static $databaseTableIndexIsIdentity = false; + + protected static $databaseTableIndexName = 'identifier'; + + public function getNumberOfChunks(): int + { + return \ceil($this->fileSize / $this->getOptimalChunkSize()); + } + + private function getOptimalChunkSize(): int + { + $postMaxSize = \ini_parse_quantity(\ini_get('post_max_size')); + if ($postMaxSize === 0) { + // Disabling it is fishy, assume a more reasonable limit of 100 MB. + $postMaxSize = 100 * 1_024 * 1_024; + } + + return $postMaxSize; + } +} diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php new file mode 100644 index 00000000000..404a80ca932 --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php @@ -0,0 +1,19 @@ + + * + * @method FileTemporary create() + * @method FileTemporaryEditor[] getObjects() + * @method FileTemporaryEditor getSingleObject() + */ +class FileTemporaryAction extends AbstractDatabaseObjectAction +{ + protected $className = FileTemporaryEditor::class; +} diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryEditor.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryEditor.class.php new file mode 100644 index 00000000000..7148784ee5b --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryEditor.class.php @@ -0,0 +1,23 @@ + + * @since 6.1 + * + * @method static FileTemporary create(array $parameters = []) + * @method FileTemporary getDecoratedObject() + * @mixin FileTemporary + */ +class FileTemporaryEditor extends DatabaseObjectEditor +{ + /** + * @inheritDoc + */ + protected static $baseClass = FileTemporary::class; +} diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php new file mode 100644 index 00000000000..2bd4f42eafc --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php @@ -0,0 +1,21 @@ + + * + * @method FileTemporary current() + * @method FileTemporary[] getObjects() + * @method FileTemporary|null getSingleObject() + * @method FileTemporary|null search($objectID) + * @property FileTemporary[] $objects + */ +class FileTemporaryList extends DatabaseObjectList +{ + public $className = FileTemporary::class; +} diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index d22a2540cbd..4664821c09a 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -596,7 +596,7 @@ CREATE TABLE wcf1_event_listener ( DROP TABLE IF EXISTS wcf1_file_temporary; CREATE TABLE wcf1_file_temporary ( identifier CHAR(40) NOT NULL PRIMARY KEY, - time INT NOT NULL, + time INT, filename VARCHAR(255) NOT NULL, fileSize BIGINT NOT NULL, fileHash CHAR(64) NOT NULL From 924bbee496ef91651c62c3d58e0d2b960da16326 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 28 Dec 2023 17:01:54 +0100 Subject: [PATCH 11/97] Use a single source of truth for temporary filenames --- .../lib/action/FileUploadAction.class.php | 41 ++++++++----------- .../FileUploadPreflightAction.class.php | 10 ++--- .../file/temporary/FileTemporary.class.php | 14 +++++++ 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index 9c5e34d779e..81edeed8dfd 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -14,6 +14,12 @@ final class FileUploadAction implements RequestHandlerInterface { + /** + * Read data in chunks to avoid hitting the memory limit. + * See https://stackoverflow.com/a/61997147 + */ + private const FREAD_BUFFER_SIZE = 10 * 1_024 * 1_024; + public function handle(ServerRequestInterface $request): ResponseInterface { // TODO: `sequenceNo` should be of type `non-negative-int`, but requires Valinor 1.7+ @@ -43,10 +49,9 @@ public function handle(ServerRequestInterface $request): ResponseInterface // Check if the checksum matches the received data. $ctx = \hash_init('sha256'); - $bufferSize = 1 * 1024 * 1024; $stream = $request->getBody(); while (!$stream->eof()) { - \hash_update($ctx, $stream->read($bufferSize)); + \hash_update($ctx, $stream->read(self::FREAD_BUFFER_SIZE)); } $result = \hash_final($ctx); $stream->rewind(); @@ -68,19 +73,12 @@ public function handle(ServerRequestInterface $request): ResponseInterface \mkdir($tmpPath, recursive: true); } - $filename = \sprintf( - '%s-%d.bin', - $fileTemporary->identifier, - $parameters['sequenceNo'], - ); - // Write the chunk using a buffer to avoid blowing up the memory limit. // See https://stackoverflow.com/a/61997147 - $result = new AtomicWriter($tmpPath . $filename); - $bufferSize = 1 * 1024 * 1024; + $result = new AtomicWriter($tmpPath . $fileTemporary->getChunkFilename($parameters['sequenceNo'])); while (!$stream->eof()) { - $result->write($stream->read($bufferSize)); + $result->write($stream->read(self::FREAD_BUFFER_SIZE)); } $result->flush(); @@ -88,14 +86,10 @@ public function handle(ServerRequestInterface $request): ResponseInterface // Check if we have all chunks. $data = []; for ($i = 0; $i < $numberOfChunks; $i++) { - $filename = \sprintf( - '%s-%d.bin', - $fileTemporary->identifier, - $i, - ); - - if (\file_exists($tmpPath . $filename)) { - $data[] = $tmpPath . $filename; + $chunkFilename = $fileTemporary->getChunkFilename($i); + + if (\file_exists($tmpPath . $chunkFilename)) { + $data[] = $tmpPath . $chunkFilename; } } @@ -103,15 +97,14 @@ public function handle(ServerRequestInterface $request): ResponseInterface // Concatenate the files by reading only a limited buffer at a time // to avoid blowing up the memory limit. // See https://stackoverflow.com/a/61997147 - $bufferSize = 1 * 1024 * 1024; - $newFilename = \sprintf('%s-final.bin', $fileTemporary->identifier); - $result = new AtomicWriter($tmpPath . $newFilename); + $resultFilename = $fileTemporary->getResultFilename(); + $result = new AtomicWriter($tmpPath . $resultFilename); foreach ($data as $fileChunk) { $source = new File($fileChunk, 'rb'); try { while (!$source->eof()) { - $result->write($source->read($bufferSize)); + $result->write($source->read(self::FREAD_BUFFER_SIZE)); } } finally { $source->close(); @@ -121,7 +114,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $result->flush(); // Check if the final result matches the expected checksum. - $checksum = \hash_file('sha256', $tmpPath . $newFilename); + $checksum = \hash_file('sha256', $tmpPath . $resultFilename); if ($checksum !== $fileTemporary->fileHash) { // TODO: Proper error message throw new IllegalLinkException(); diff --git a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php index 727fe5d2c1c..7ab5871e3c9 100644 --- a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php @@ -52,11 +52,11 @@ private function createTemporaryFile(array $parameters): FileTemporary $action = new FileTemporaryAction([], 'create', [ 'data' => [ - 'identifier'=>$identifier, - 'time'=>\TIME_NOW, - 'filename'=>$parameters['filename'], - 'fileSize'=>$parameters['fileSize'], - 'fileHash'=>$parameters['fileHash'], + 'identifier' => $identifier, + 'time' => \TIME_NOW, + 'filename' => $parameters['filename'], + 'fileSize' => $parameters['fileSize'], + 'fileHash' => $parameters['fileHash'], ], ]); diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php index c9fd98ee360..bf87bda872a 100644 --- a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php @@ -27,6 +27,20 @@ public function getNumberOfChunks(): int return \ceil($this->fileSize / $this->getOptimalChunkSize()); } + public function getChunkFilename(int $sequenceNo): string + { + return \sprintf( + "%s-%d.bin", + $this->identifier, + $sequenceNo, + ); + } + + public function getResultFilename(): string + { + return \sprintf("%s-final.bin", $this->identifier); + } + private function getOptimalChunkSize(): int { $postMaxSize = \ini_parse_quantity(\ini_get('post_max_size')); From 08e27ac5f3ee7d14bd843fa4c56fbd3f439822fe Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 28 Dec 2023 17:21:56 +0100 Subject: [PATCH 12/97] Create the basic data structure for a persistent file --- .../files/lib/data/file/File.class.php | 20 ++++++++++++++++ .../files/lib/data/file/FileAction.class.php | 19 +++++++++++++++ .../files/lib/data/file/FileEditor.class.php | 23 +++++++++++++++++++ .../files/lib/data/file/FileList.class.php | 21 +++++++++++++++++ wcfsetup/setup/db/install.sql | 8 +++++++ 5 files changed, 91 insertions(+) create mode 100644 wcfsetup/install/files/lib/data/file/File.class.php create mode 100644 wcfsetup/install/files/lib/data/file/FileAction.class.php create mode 100644 wcfsetup/install/files/lib/data/file/FileEditor.class.php create mode 100644 wcfsetup/install/files/lib/data/file/FileList.class.php diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php new file mode 100644 index 00000000000..3c700be01c2 --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -0,0 +1,20 @@ + + * @since 6.1 + * + * @property-read int $fileID + * @property-read string $filename + * @property-read int $fileSize + * @property-read string $fileHash + */ +class File extends DatabaseObject +{ +} diff --git a/wcfsetup/install/files/lib/data/file/FileAction.class.php b/wcfsetup/install/files/lib/data/file/FileAction.class.php new file mode 100644 index 00000000000..05a54e32122 --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/FileAction.class.php @@ -0,0 +1,19 @@ + + * + * @method File create() + * @method FileEditor[] getObjects() + * @method FileEditor getSingleObject() + */ +class FileAction extends AbstractDatabaseObjectAction +{ + protected $className = FileEditor::class; +} diff --git a/wcfsetup/install/files/lib/data/file/FileEditor.class.php b/wcfsetup/install/files/lib/data/file/FileEditor.class.php new file mode 100644 index 00000000000..c632887e30c --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/FileEditor.class.php @@ -0,0 +1,23 @@ + + * @since 6.1 + * + * @method static File create(array $parameters = []) + * @method File getDecoratedObject() + * @mixin File + */ +class FileEditor extends DatabaseObjectEditor +{ + /** + * @inheritDoc + */ + protected static $baseClass = File::class; +} diff --git a/wcfsetup/install/files/lib/data/file/FileList.class.php b/wcfsetup/install/files/lib/data/file/FileList.class.php new file mode 100644 index 00000000000..9dca494aefd --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/FileList.class.php @@ -0,0 +1,21 @@ + + * + * @method File current() + * @method File[] getObjects() + * @method File|null getSingleObject() + * @method File|null search($objectID) + * @property File[] $objects + */ +class FileList extends DatabaseObjectList +{ + public $className = File::class; +} diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index 4664821c09a..6e4d096a973 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -593,6 +593,14 @@ CREATE TABLE wcf1_event_listener ( UNIQUE KEY listenerName (listenerName, packageID) ); +DROP TABLE IF EXISTS wcf1_file; +CREATE TABLE wcf1_file ( + fileID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL, + fileSize BIGINT NOT NULL, + fileHash CHAR(64) NOT NULL +); + DROP TABLE IF EXISTS wcf1_file_temporary; CREATE TABLE wcf1_file_temporary ( identifier CHAR(40) NOT NULL PRIMARY KEY, From 325b7ec787760ef80aac1cf44113fdccae68baa2 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 28 Dec 2023 18:00:52 +0100 Subject: [PATCH 13/97] Convert a temporary file into a persistent file --- .../lib/action/FileUploadAction.class.php | 25 +++++++++++-------- .../files/lib/data/file/File.class.php | 20 +++++++++++++++ .../files/lib/data/file/FileEditor.class.php | 24 ++++++++++++++++++ .../file/temporary/FileTemporary.class.php | 12 +++++++++ 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index 81edeed8dfd..906b25bfbd4 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -3,14 +3,17 @@ namespace wcf\action; use Laminas\Diactoros\Response\EmptyResponse; +use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use wcf\data\file\FileEditor; use wcf\data\file\temporary\FileTemporary; +use wcf\data\file\temporary\FileTemporaryEditor; use wcf\http\Helper; use wcf\system\exception\IllegalLinkException; use wcf\system\io\AtomicWriter; -use wcf\system\io\File; +use wcf\system\io\File as IoFile; final class FileUploadAction implements RequestHandlerInterface { @@ -61,14 +64,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface throw new IllegalLinkException(); } - $folderA = \substr($fileTemporary->identifier, 0, 2); - $folderB = \substr($fileTemporary->identifier, 2, 2); - - $tmpPath = \sprintf( - \WCF_DIR . '_data/private/fileUpload/%s/%s/', - $folderA, - $folderB, - ); + $tmpPath = $fileTemporary->getPath(); if (!\is_dir($tmpPath)) { \mkdir($tmpPath, recursive: true); } @@ -101,7 +97,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $resultFilename = $fileTemporary->getResultFilename(); $result = new AtomicWriter($tmpPath . $resultFilename); foreach ($data as $fileChunk) { - $source = new File($fileChunk, 'rb'); + $source = new IoFile($fileChunk, 'rb'); try { while (!$source->eof()) { $result->write($source->read(self::FREAD_BUFFER_SIZE)); @@ -125,7 +121,14 @@ public function handle(ServerRequestInterface $request): ResponseInterface \unlink($fileChunk); } - // TODO: Move the data from the temporary file to the actual "file". + $file = FileEditor::createFromTemporary($fileTemporary); + + (new FileTemporaryEditor($fileTemporary))->delete(); + + // TODO: This is just debug code. + return new JsonResponse([ + 'file' => $file->getPath() . $file->getSourceFilename(), + ]); } return new EmptyResponse(); diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index 3c700be01c2..993d6b0b596 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -17,4 +17,24 @@ */ class File extends DatabaseObject { + public function getPath(): string + { + $folderA = \substr($this->fileHash, 0, 2); + $folderB = \substr($this->fileHash, 2, 2); + + return \sprintf( + \WCF_DIR . '_data/public/fileUpload/%s/%s/', + $folderA, + $folderB, + ); + } + + public function getSourceFilename(): string + { + return \sprintf( + '%d-%s.bin', + $this->fileID, + $this->filename, + ); + } } diff --git a/wcfsetup/install/files/lib/data/file/FileEditor.class.php b/wcfsetup/install/files/lib/data/file/FileEditor.class.php index c632887e30c..62be2675afb 100644 --- a/wcfsetup/install/files/lib/data/file/FileEditor.class.php +++ b/wcfsetup/install/files/lib/data/file/FileEditor.class.php @@ -3,6 +3,7 @@ namespace wcf\data\file; use wcf\data\DatabaseObjectEditor; +use wcf\data\file\temporary\FileTemporary; /** * @author Alexander Ebert @@ -20,4 +21,27 @@ class FileEditor extends DatabaseObjectEditor * @inheritDoc */ protected static $baseClass = File::class; + + public static function createFromTemporary(FileTemporary $fileTemporary): File + { + $fileAction = new FileAction([], 'create', ['data' => [ + 'filename' => $fileTemporary->filename, + 'fileSize' => $fileTemporary->fileSize, + 'fileHash' => $fileTemporary->fileHash, + ]]); + $file = $fileAction->executeAction()['returnValues']; + \assert($file instanceof File); + + $filePath = $file->getPath(); + if (!\is_dir($filePath)) { + \mkdir($filePath, recursive: true); + } + + \rename( + $fileTemporary->getPath() . $fileTemporary->getResultFilename(), + $filePath . $file->getSourceFilename() + ); + + return $file; + } } diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php index bf87bda872a..69ea4929529 100644 --- a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php @@ -41,6 +41,18 @@ public function getResultFilename(): string return \sprintf("%s-final.bin", $this->identifier); } + public function getPath(): string + { + $folderA = \substr($this->identifier, 0, 2); + $folderB = \substr($this->identifier, 2, 2); + + return \sprintf( + \WCF_DIR . '_data/private/fileUpload/%s/%s/', + $folderA, + $folderB, + ); + } + private function getOptimalChunkSize(): int { $postMaxSize = \ini_parse_quantity(\ini_get('post_max_size')); From b663a5f2528ddd2676d2e1a4a88f37ae7fc394ec Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Tue, 16 Jan 2024 12:34:43 +0100 Subject: [PATCH 14/97] Track the number of uploaded chunks Allow for up to 255 chunks and track the state of each uploaded chunks. The `chunks` property is effectively a bitmap whose length represents the number of chunks --- .../lib/action/FileUploadAction.class.php | 4 ++-- .../action/FileUploadPreflightAction.class.php | 11 ++++++++--- .../file/temporary/FileTemporary.class.php | 18 +++++++++++++----- wcfsetup/setup/db/install.sql | 3 ++- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index 906b25bfbd4..49ed69effd3 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -44,8 +44,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface } // Check if this is a valid sequence no. - $numberOfChunks = $fileTemporary->getNumberOfChunks(); - if ($parameters['sequenceNo'] >= $numberOfChunks) { + $numberOfChunks = $fileTemporary->getChunkCount(); + if ($parameters['sequenceNo'] >= $fileTemporary->getChunkCount()) { // TODO: Proper error message throw new IllegalLinkException(); } diff --git a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php index 7ab5871e3c9..92863c0b315 100644 --- a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php @@ -27,8 +27,12 @@ public function handle(ServerRequestInterface $request): ResponseInterface EOT, ); - $fileTemporary = $this->createTemporaryFile($parameters); - $numberOfChunks = $fileTemporary->getNumberOfChunks(); + $numberOfChunks = FileTemporary::getNumberOfChunks($parameters['fileSize']); + if ($numberOfChunks > FileTemporary::MAX_CHUNK_COUNT) { + // TODO: Reject + } + + $fileTemporary = $this->createTemporaryFile($parameters, $numberOfChunks); $endpoints = []; for ($i = 0; $i < $numberOfChunks; $i++) { @@ -46,7 +50,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface ]); } - private function createTemporaryFile(array $parameters): FileTemporary + private function createTemporaryFile(array $parameters, int $numberOfChunks): FileTemporary { $identifier = \bin2hex(\random_bytes(20)); @@ -57,6 +61,7 @@ private function createTemporaryFile(array $parameters): FileTemporary 'filename' => $parameters['filename'], 'fileSize' => $parameters['fileSize'], 'fileHash' => $parameters['fileHash'], + 'chunks' => \str_repeat('0', $numberOfChunks), ], ]); diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php index 69ea4929529..7fd008c72c3 100644 --- a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php @@ -15,6 +15,7 @@ * @property-read string $filename * @property-read int $fileSize * @property-read string $fileHash + * @property-read string $chunks */ class FileTemporary extends DatabaseObject { @@ -22,10 +23,7 @@ class FileTemporary extends DatabaseObject protected static $databaseTableIndexName = 'identifier'; - public function getNumberOfChunks(): int - { - return \ceil($this->fileSize / $this->getOptimalChunkSize()); - } + public const MAX_CHUNK_COUNT = 255; public function getChunkFilename(int $sequenceNo): string { @@ -36,6 +34,11 @@ public function getChunkFilename(int $sequenceNo): string ); } + public function getChunkCount(): int + { + return \strlen($this->chunks); + } + public function getResultFilename(): string { return \sprintf("%s-final.bin", $this->identifier); @@ -53,7 +56,12 @@ public function getPath(): string ); } - private function getOptimalChunkSize(): int + public static function getNumberOfChunks(int $fileSize): int + { + return \ceil($fileSize / self::getOptimalChunkSize()); + } + + private static function getOptimalChunkSize(): int { $postMaxSize = \ini_parse_quantity(\ini_get('post_max_size')); if ($postMaxSize === 0) { diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index 6e4d096a973..0efb7111807 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -607,7 +607,8 @@ CREATE TABLE wcf1_file_temporary ( time INT, filename VARCHAR(255) NOT NULL, fileSize BIGINT NOT NULL, - fileHash CHAR(64) NOT NULL + fileHash CHAR(64) NOT NULL, + chunks VARBINARY(255) NOT NULL ); /* As the flood control table can be a high traffic table and as it is periodically emptied, From 79fa2982431e160cf41a3a579195603c3d5ecd8a Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Tue, 16 Jan 2024 17:24:40 +0100 Subject: [PATCH 15/97] Write the chunks into the file directly This avoids having to buffer the data into separate files which causes a lot of I/O when stitching the file together. --- ts/WoltLabSuite/Core/Component/File/Upload.ts | 8 +- .../Core/Component/File/Upload.js | 7 +- .../lib/action/FileUploadAction.class.php | 90 +++++++++---------- .../FileUploadPreflightAction.class.php | 4 +- .../files/lib/data/file/FileEditor.class.php | 2 +- .../file/temporary/FileTemporary.class.php | 19 ++-- 6 files changed, 65 insertions(+), 65 deletions(-) diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index a9c2a7c1df9..b5554413191 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -29,7 +29,10 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis const checksum = await getSha256Hash(await chunk.arrayBuffer()); endpoint.searchParams.append("checksum", checksum); - await prepareRequest(endpoint.toString()).post(chunk).fetchAsResponse(); + const response = await prepareRequest(endpoint.toString()).post(chunk).fetchAsResponse(); + if (response) { + console.log(await response.text()); + } } } @@ -46,8 +49,5 @@ export function setup(): void { element.addEventListener("upload", (event: CustomEvent) => { void upload(element, event.detail); }); - - const file = new File(["a".repeat(4_000_001)], "test.txt"); - void upload(element, file); }); } 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 9bcf12e246d..09fc21c9f42 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -20,7 +20,10 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co const endpoint = new URL(endpoints[i]); const checksum = await getSha256Hash(await chunk.arrayBuffer()); endpoint.searchParams.append("checksum", checksum); - await (0, Backend_1.prepareRequest)(endpoint.toString()).post(chunk).fetchAsResponse(); + const response = await (0, Backend_1.prepareRequest)(endpoint.toString()).post(chunk).fetchAsResponse(); + if (response) { + console.log(await response.text()); + } } } async function getSha256Hash(data) { @@ -34,8 +37,6 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co element.addEventListener("upload", (event) => { void upload(element, event.detail); }); - const file = new File(["a".repeat(4000001)], "test.txt"); - void upload(element, file); }); } exports.setup = setup; diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index 49ed69effd3..a53d49e8b20 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -50,18 +50,19 @@ public function handle(ServerRequestInterface $request): ResponseInterface throw new IllegalLinkException(); } - // Check if the checksum matches the received data. - $ctx = \hash_init('sha256'); - $stream = $request->getBody(); - while (!$stream->eof()) { - \hash_update($ctx, $stream->read(self::FREAD_BUFFER_SIZE)); + // Check if this chunk has already been written. + if ($fileTemporary->hasChunk($parameters['sequenceNo'])) { + // 409 Conflict + return new EmptyResponse(409); } - $result = \hash_final($ctx); - $stream->rewind(); - if ($result !== $parameters['checksum']) { - // TODO: Proper error message - throw new IllegalLinkException(); + // Validate the chunk size. + $chunkSize = $fileTemporary->getChunkSize(); + $stream = $request->getBody(); + $receivedSize = $stream->getSize(); + if ($receivedSize !== null && $receivedSize > $chunkSize) { + // 413 Content Too Large + return new EmptyResponse(413); } $tmpPath = $fileTemporary->getPath(); @@ -69,58 +70,53 @@ public function handle(ServerRequestInterface $request): ResponseInterface \mkdir($tmpPath, recursive: true); } - // Write the chunk using a buffer to avoid blowing up the memory limit. - // See https://stackoverflow.com/a/61997147 - $result = new AtomicWriter($tmpPath . $fileTemporary->getChunkFilename($parameters['sequenceNo'])); + $file = new IoFile($tmpPath . $fileTemporary->getFilename(), 'cb+'); + $file->lock(\LOCK_EX); + $file->seek($parameters['sequenceNo'] * $chunkSize); + // Check if the checksum matches the received data. + $ctx = \hash_init('sha256'); + $total = 0; while (!$stream->eof()) { - $result->write($stream->read(self::FREAD_BUFFER_SIZE)); - } - - $result->flush(); - - // Check if we have all chunks. - $data = []; - for ($i = 0; $i < $numberOfChunks; $i++) { - $chunkFilename = $fileTemporary->getChunkFilename($i); + // Write the chunk using a buffer to avoid blowing up the memory limit. + // See https://stackoverflow.com/a/61997147 + $chunk = $stream->read(self::FREAD_BUFFER_SIZE); + $total += \strlen($chunk); - if (\file_exists($tmpPath . $chunkFilename)) { - $data[] = $tmpPath . $chunkFilename; + if ($total > $chunkSize) { + // 413 Content Too Large + return new EmptyResponse(413); } + + \hash_update($ctx, $chunk); + $file->write($chunk); } + $file->sync(); + $file->close(); - if (\count($data) === $numberOfChunks) { - // Concatenate the files by reading only a limited buffer at a time - // to avoid blowing up the memory limit. - // See https://stackoverflow.com/a/61997147 + $result = \hash_final($ctx); - $resultFilename = $fileTemporary->getResultFilename(); - $result = new AtomicWriter($tmpPath . $resultFilename); - foreach ($data as $fileChunk) { - $source = new IoFile($fileChunk, 'rb'); - try { - while (!$source->eof()) { - $result->write($source->read(self::FREAD_BUFFER_SIZE)); - } - } finally { - $source->close(); - } - } + if ($result !== $parameters['checksum']) { + // TODO: Proper error message + throw new IllegalLinkException(); + } - $result->flush(); + // Mark the chunk as written. + $chunks = $fileTemporary->chunks; + $chunks[$parameters['sequenceNo']] = '1'; + (new FileTemporaryEditor($fileTemporary))->update([ + 'chunks' => $chunks, + ]); + // Check if we have all chunks. + if ($chunks === \str_repeat('1', $fileTemporary->getChunkCount())) { // Check if the final result matches the expected checksum. - $checksum = \hash_file('sha256', $tmpPath . $resultFilename); + $checksum = \hash_file('sha256', $tmpPath . $fileTemporary->getFilename()); if ($checksum !== $fileTemporary->fileHash) { // TODO: Proper error message throw new IllegalLinkException(); } - // Remove the temporary chunks. - foreach ($data as $fileChunk) { - \unlink($fileChunk); - } - $file = FileEditor::createFromTemporary($fileTemporary); (new FileTemporaryEditor($fileTemporary))->delete(); diff --git a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php index 92863c0b315..b9b172530bb 100644 --- a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php @@ -2,6 +2,7 @@ namespace wcf\action; +use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -29,7 +30,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface $numberOfChunks = FileTemporary::getNumberOfChunks($parameters['fileSize']); if ($numberOfChunks > FileTemporary::MAX_CHUNK_COUNT) { - // TODO: Reject + // 413 Content Too Large + return new EmptyResponse(413); } $fileTemporary = $this->createTemporaryFile($parameters, $numberOfChunks); diff --git a/wcfsetup/install/files/lib/data/file/FileEditor.class.php b/wcfsetup/install/files/lib/data/file/FileEditor.class.php index 62be2675afb..780a3040777 100644 --- a/wcfsetup/install/files/lib/data/file/FileEditor.class.php +++ b/wcfsetup/install/files/lib/data/file/FileEditor.class.php @@ -38,7 +38,7 @@ public static function createFromTemporary(FileTemporary $fileTemporary): File } \rename( - $fileTemporary->getPath() . $fileTemporary->getResultFilename(), + $fileTemporary->getPath() . $fileTemporary->getFilename(), $filePath . $file->getSourceFilename() ); diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php index 7fd008c72c3..09000f5afe8 100644 --- a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php @@ -25,21 +25,22 @@ class FileTemporary extends DatabaseObject public const MAX_CHUNK_COUNT = 255; - public function getChunkFilename(int $sequenceNo): string + public function getChunkCount(): int { - return \sprintf( - "%s-%d.bin", - $this->identifier, - $sequenceNo, - ); + return \strlen($this->chunks); } - public function getChunkCount(): int + public function getChunkSize(): int { - return \strlen($this->chunks); + return \ceil($this->fileSize / $this->getChunkCount()); + } + + public function hasChunk(int $sequenceNo): bool + { + return $this->chunks[$sequenceNo] === '1'; } - public function getResultFilename(): string + public function getFilename(): string { return \sprintf("%s-final.bin", $this->identifier); } From bde14a06fd982166aa165fa5eb17478a3c156614 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Fri, 26 Jan 2024 18:00:25 +0100 Subject: [PATCH 16/97] Add basic support for file processors --- .../shared_messageFormAttachments.tpl | 3 ++ ts/WoltLabSuite/Core/Component/File/Upload.ts | 33 ++++++++++++++++ .../Core/Component/File/Upload.js | 27 ++++++++++++- .../FileUploadPreflightAction.class.php | 22 +++++++++++ .../files/lib/bootstrap/com.woltlab.wcf.php | 5 +++ .../files/lib/data/file/File.class.php | 1 + .../files/lib/data/file/FileEditor.class.php | 1 + .../file/temporary/FileTemporary.class.php | 8 ++++ .../AttachmentFileProcessor.class.php | 22 +++++++++++ .../file/processor/FileProcessor.class.php | 34 ++++++++++++++++ .../file/processor/IFileProcessor.class.php | 16 ++++++++ .../event/FileProcessorCollecting.class.php | 39 +++++++++++++++++++ .../DuplicateFileProcessor.class.php | 19 +++++++++ wcfsetup/setup/db/install.sql | 5 ++- 14 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php create mode 100644 wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php create mode 100644 wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php create mode 100644 wcfsetup/install/files/lib/system/file/processor/event/FileProcessorCollecting.class.php create mode 100644 wcfsetup/install/files/lib/system/file/processor/exception/DuplicateFileProcessor.class.php diff --git a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl index 8b190553d77..6383581ffec 100644 --- a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl +++ b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl @@ -2,6 +2,9 @@
diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index b5554413191..d0d0875f1ff 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -1,11 +1,15 @@ import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; +import { ucfirst } from "WoltLabSuite/Core/StringUtil"; type PreflightResponse = { endpoints: string[]; }; async function upload(element: WoltlabCoreFileUploadElement, file: File): Promise { + const typeName = element.dataset.typeName!; + const context = getContextFromDataAttributes(element); + const fileHash = await getSha256Hash(await file.arrayBuffer()); const response = (await prepareRequest(element.dataset.endpoint!) @@ -13,6 +17,8 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis filename: file.name, fileSize: file.size, fileHash, + typeName, + context, }) .fetchAsJson()) as PreflightResponse; const { endpoints } = response; @@ -36,6 +42,33 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis } } +function getContextFromDataAttributes(element: WoltlabCoreFileUploadElement): Record { + const context = {}; + const prefixContext = "data-context-"; + + for (const attribute of element.attributes) { + if (!attribute.name.startsWith(prefixContext)) { + continue; + } + + const key = attribute.name + .substring(prefixContext.length) + .split("-") + .map((part, index) => { + if (index === 0) { + return part; + } + + return ucfirst(part); + }) + .join(""); + + context[key] = attribute.value; + } + + return context; +} + async function getSha256Hash(data: BufferSource): Promise { const buffer = await window.crypto.subtle.digest("SHA-256", data); 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 09fc21c9f42..289fe0ad5d9 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -1,14 +1,18 @@ -define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector"], function (require, exports, Backend_1, Selector_1) { +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/StringUtil"], function (require, exports, Backend_1, Selector_1, StringUtil_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; async function upload(element, file) { + const typeName = element.dataset.typeName; + const context = getContextFromDataAttributes(element); const fileHash = await getSha256Hash(await file.arrayBuffer()); const response = (await (0, Backend_1.prepareRequest)(element.dataset.endpoint) .post({ filename: file.name, fileSize: file.size, fileHash, + typeName, + context, }) .fetchAsJson()); const { endpoints } = response; @@ -26,6 +30,27 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co } } } + function getContextFromDataAttributes(element) { + const context = {}; + const prefixContext = "data-context-"; + for (const attribute of element.attributes) { + if (!attribute.name.startsWith(prefixContext)) { + continue; + } + const key = attribute.name + .substring(prefixContext.length) + .split("-") + .map((part, index) => { + if (index === 0) { + return part; + } + return (0, StringUtil_1.ucfirst)(part); + }) + .join(""); + context[key] = attribute.value; + } + return context; + } async function getSha256Hash(data) { const buffer = await window.crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(buffer)) diff --git a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php index b9b172530bb..c55675960a6 100644 --- a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php @@ -4,13 +4,18 @@ use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\JsonResponse; +use Masterminds\HTML5\Parser\EventHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use wcf\data\file\temporary\FileTemporary; use wcf\data\file\temporary\FileTemporaryAction; use wcf\http\Helper; +use wcf\system\event\EventHandler as EventEventHandler; +use wcf\system\file\processor\event\FileProcessorCollecting; +use wcf\system\file\processor\FileProcessor; use wcf\system\request\LinkHandler; +use wcf\util\JSON; final class FileUploadPreflightAction implements RequestHandlerInterface { @@ -24,10 +29,25 @@ public function handle(ServerRequestInterface $request): ResponseInterface filename: non-empty-string, fileSize: positive-int, fileHash: non-empty-string, + typeName: non-empty-string, + context: array, } EOT, ); + $fileProcessor = FileProcessor::getInstance()->forTypeName($parameters['typeName']); + if ($fileProcessor === null) { + // 400 Bad Request + return new JsonResponse([ + 'typeName' => 'unknown', + ], 400); + } + + if (!$fileProcessor->acceptUpload($parameters['filename'], $parameters['fileSize'], $parameters['context'])) { + // 403 Permission Denied + return new EmptyResponse(403); + } + $numberOfChunks = FileTemporary::getNumberOfChunks($parameters['fileSize']); if ($numberOfChunks > FileTemporary::MAX_CHUNK_COUNT) { // 413 Content Too Large @@ -63,6 +83,8 @@ private function createTemporaryFile(array $parameters, int $numberOfChunks): Fi 'filename' => $parameters['filename'], 'fileSize' => $parameters['fileSize'], 'fileHash' => $parameters['fileHash'], + 'typeName' => $parameters['typeName'], + 'context' => JSON::encode($parameters['context']), 'chunks' => \str_repeat('0', $numberOfChunks), ], ]); diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index 722d25b6bf5..1ec5b028ef3 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -2,6 +2,7 @@ use wcf\system\cronjob\CronjobScheduler; use wcf\system\event\EventHandler; +use wcf\system\file\processor\event\FileProcessorCollecting; use wcf\system\language\LanguageFactory; use wcf\system\language\preload\command\ResetPreloadCache; use wcf\system\language\preload\PhrasePreloader; @@ -120,6 +121,10 @@ static function (\wcf\event\endpoint\ControllerCollecting $event) { } ); + $eventHandler->register(FileProcessorCollecting::class, static function (FileProcessorCollecting $event) { + $event->register(new \wcf\system\file\processor\AttachmentFileProcessor()); + }); + try { $licenseApi = new LicenseApi(); $licenseData = $licenseApi->readFromFile(); diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index 993d6b0b596..c2b99ffe1bd 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -14,6 +14,7 @@ * @property-read string $filename * @property-read int $fileSize * @property-read string $fileHash + * @property-read string $typeName */ class File extends DatabaseObject { diff --git a/wcfsetup/install/files/lib/data/file/FileEditor.class.php b/wcfsetup/install/files/lib/data/file/FileEditor.class.php index 780a3040777..a70d78b8c6f 100644 --- a/wcfsetup/install/files/lib/data/file/FileEditor.class.php +++ b/wcfsetup/install/files/lib/data/file/FileEditor.class.php @@ -28,6 +28,7 @@ public static function createFromTemporary(FileTemporary $fileTemporary): File 'filename' => $fileTemporary->filename, 'fileSize' => $fileTemporary->fileSize, 'fileHash' => $fileTemporary->fileHash, + 'typeName' => $fileTemporary->typeName, ]]); $file = $fileAction->executeAction()['returnValues']; \assert($file instanceof File); diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php index 09000f5afe8..061dfe5bf75 100644 --- a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php @@ -3,6 +3,7 @@ namespace wcf\data\file\temporary; use wcf\data\DatabaseObject; +use wcf\util\JSON; /** * @author Alexander Ebert @@ -15,6 +16,8 @@ * @property-read string $filename * @property-read int $fileSize * @property-read string $fileHash + * @property-read string $typeName + * @property-read string $context * @property-read string $chunks */ class FileTemporary extends DatabaseObject @@ -57,6 +60,11 @@ public function getPath(): string ); } + public function getContext(): array + { + return JSON::decode($this->context); + } + public static function getNumberOfChunks(int $fileSize): int { return \ceil($fileSize / self::getOptimalChunkSize()); diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php new file mode 100644 index 00000000000..2f58b02d862 --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -0,0 +1,22 @@ + + * @since 6.1 + */ +final class AttachmentFileProcessor implements IFileProcessor +{ + public function getTypeName(): string + { + return 'com.woltlab.wcf.attachment'; + } + + public function acceptUpload(string $filename, int $fileSize, array $context): bool + { + return true; + } +} diff --git a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php new file mode 100644 index 00000000000..31e7f32bbd9 --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php @@ -0,0 +1,34 @@ + + * @since 6.1 + */ +final class FileProcessor extends SingletonFactory +{ + /** + * @var array + */ + private array $processors; + + #[\Override] + public function init(): void + { + $event = new FileProcessorCollecting(); + EventHandler::getInstance()->fire($event); + $this->processors = $event->getProcessors(); + } + + public function forTypeName(string $typeName): ?IFileProcessor + { + return $this->processors[$typeName] ?? null; + } +} diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php new file mode 100644 index 00000000000..1b840c4ff33 --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -0,0 +1,16 @@ + + * @since 6.1 + */ +interface IFileProcessor +{ + public function getTypeName(): string; + + public function acceptUpload(string $filename, int $fileSize, array $context): bool; +} diff --git a/wcfsetup/install/files/lib/system/file/processor/event/FileProcessorCollecting.class.php b/wcfsetup/install/files/lib/system/file/processor/event/FileProcessorCollecting.class.php new file mode 100644 index 00000000000..33b2c6ba69d --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/event/FileProcessorCollecting.class.php @@ -0,0 +1,39 @@ + + * @since 6.1 + */ +final class FileProcessorCollecting implements IEvent +{ + /** + * @var array + */ + private array $data = []; + + public function register(IFileProcessor $fileUploadProcessor): void + { + $typeName = $fileUploadProcessor->getTypeName(); + if (isset($this->data[$typeName])) { + throw new DuplicateFileProcessor($typeName); + } + + $this->data[$typeName] = $fileUploadProcessor; + } + + /** + * @return array + */ + public function getProcessors(): array + { + return $this->data; + } +} diff --git a/wcfsetup/install/files/lib/system/file/processor/exception/DuplicateFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/exception/DuplicateFileProcessor.class.php new file mode 100644 index 00000000000..ce4569236e5 --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/exception/DuplicateFileProcessor.class.php @@ -0,0 +1,19 @@ + + * @since 6.1 + */ +final class DuplicateFileProcessor extends \Exception +{ + public function __construct(string $typeName) + { + parent::__construct( + \sprintf("The file processor '%s' has already been registered", $typeName), + ); + } +} diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index 0efb7111807..ed0b30c7170 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -598,7 +598,8 @@ CREATE TABLE wcf1_file ( fileID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, filename VARCHAR(255) NOT NULL, fileSize BIGINT NOT NULL, - fileHash CHAR(64) NOT NULL + fileHash CHAR(64) NOT NULL, + typeName VARCHAR(255) NOT NULL ); DROP TABLE IF EXISTS wcf1_file_temporary; @@ -608,6 +609,8 @@ CREATE TABLE wcf1_file_temporary ( filename VARCHAR(255) NOT NULL, fileSize BIGINT NOT NULL, fileHash CHAR(64) NOT NULL, + typeName VARCHAR(255) NOT NULL, + context TEXT, chunks VARBINARY(255) NOT NULL ); From ff3e554c0197ab5438c6345903ef830922dd403b Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 27 Jan 2024 17:02:26 +0100 Subject: [PATCH 17/97] Simplify the handling of context data --- .../shared_messageFormAttachments.tpl | 7 +--- ts/WoltLabSuite/Core/Component/File/Upload.ts | 30 +--------------- .../Core/Component/File/Upload.js | 26 ++------------ .../FileUploadPreflightAction.class.php | 19 +++++++---- .../attachment/AttachmentHandler.class.php | 22 ++++++++++++ .../AttachmentFileProcessor.class.php | 34 +++++++++++++++++++ .../file/processor/FileProcessor.class.php | 22 ++++++++++++ 7 files changed, 95 insertions(+), 65 deletions(-) diff --git a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl index 6383581ffec..29ab7fd1b95 100644 --- a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl +++ b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl @@ -1,11 +1,6 @@
diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index d0d0875f1ff..ce6745e7b75 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -8,7 +8,6 @@ type PreflightResponse = { async function upload(element: WoltlabCoreFileUploadElement, file: File): Promise { const typeName = element.dataset.typeName!; - const context = getContextFromDataAttributes(element); const fileHash = await getSha256Hash(await file.arrayBuffer()); @@ -18,7 +17,7 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis fileSize: file.size, fileHash, typeName, - context, + context: element.dataset.context, }) .fetchAsJson()) as PreflightResponse; const { endpoints } = response; @@ -42,33 +41,6 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis } } -function getContextFromDataAttributes(element: WoltlabCoreFileUploadElement): Record { - const context = {}; - const prefixContext = "data-context-"; - - for (const attribute of element.attributes) { - if (!attribute.name.startsWith(prefixContext)) { - continue; - } - - const key = attribute.name - .substring(prefixContext.length) - .split("-") - .map((part, index) => { - if (index === 0) { - return part; - } - - return ucfirst(part); - }) - .join(""); - - context[key] = attribute.value; - } - - return context; -} - async function getSha256Hash(data: BufferSource): Promise { const buffer = await window.crypto.subtle.digest("SHA-256", data); 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 289fe0ad5d9..5f1d6442600 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -1,10 +1,9 @@ -define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/StringUtil"], function (require, exports, Backend_1, Selector_1, StringUtil_1) { +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector"], function (require, exports, Backend_1, Selector_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; async function upload(element, file) { const typeName = element.dataset.typeName; - const context = getContextFromDataAttributes(element); const fileHash = await getSha256Hash(await file.arrayBuffer()); const response = (await (0, Backend_1.prepareRequest)(element.dataset.endpoint) .post({ @@ -12,7 +11,7 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co fileSize: file.size, fileHash, typeName, - context, + context: element.dataset.context, }) .fetchAsJson()); const { endpoints } = response; @@ -30,27 +29,6 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co } } } - function getContextFromDataAttributes(element) { - const context = {}; - const prefixContext = "data-context-"; - for (const attribute of element.attributes) { - if (!attribute.name.startsWith(prefixContext)) { - continue; - } - const key = attribute.name - .substring(prefixContext.length) - .split("-") - .map((part, index) => { - if (index === 0) { - return part; - } - return (0, StringUtil_1.ucfirst)(part); - }) - .join(""); - context[key] = attribute.value; - } - return context; - } async function getSha256Hash(data) { const buffer = await window.crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(buffer)) diff --git a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php index c55675960a6..92cafeb3f00 100644 --- a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php @@ -4,15 +4,13 @@ use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\JsonResponse; -use Masterminds\HTML5\Parser\EventHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use wcf\data\file\temporary\FileTemporary; use wcf\data\file\temporary\FileTemporaryAction; use wcf\http\Helper; -use wcf\system\event\EventHandler as EventEventHandler; -use wcf\system\file\processor\event\FileProcessorCollecting; +use wcf\system\exception\SystemException; use wcf\system\file\processor\FileProcessor; use wcf\system\request\LinkHandler; use wcf\util\JSON; @@ -30,7 +28,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface fileSize: positive-int, fileHash: non-empty-string, typeName: non-empty-string, - context: array, + context: non-empty-string, } EOT, ); @@ -43,7 +41,16 @@ public function handle(ServerRequestInterface $request): ResponseInterface ], 400); } - if (!$fileProcessor->acceptUpload($parameters['filename'], $parameters['fileSize'], $parameters['context'])) { + try { + $decodedContext = JSON::decode($parameters['context']); + } catch (SystemException) { + // 400 Bad Request + return new JsonResponse([ + 'context' => 'invalid', + ], 400); + } + + if (!$fileProcessor->acceptUpload($parameters['filename'], $parameters['fileSize'], $decodedContext)) { // 403 Permission Denied return new EmptyResponse(403); } @@ -84,7 +91,7 @@ private function createTemporaryFile(array $parameters, int $numberOfChunks): Fi 'fileSize' => $parameters['fileSize'], 'fileHash' => $parameters['fileHash'], 'typeName' => $parameters['typeName'], - 'context' => JSON::encode($parameters['context']), + 'context' => $parameters['context'], 'chunks' => \str_repeat('0', $numberOfChunks), ], ]); diff --git a/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php b/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php index 218c8e4769b..bbedfb1d76f 100644 --- a/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php +++ b/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php @@ -8,6 +8,8 @@ use wcf\data\object\type\ObjectTypeCache; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\exception\SystemException; +use wcf\system\file\processor\AttachmentFileProcessor; +use wcf\system\file\processor\FileProcessor; use wcf\system\WCF; /** @@ -55,6 +57,8 @@ class AttachmentHandler implements \Countable */ protected $attachmentList; + private AttachmentFileProcessor $fileProcessor; + /** * Creates a new AttachmentHandler object. * @@ -322,4 +326,22 @@ public function getParentObjectID() { return $this->parentObjectID; } + + public function getHtmlElement(): string + { + return $this->getFileProcessor()->toHtmlElement( + $this->objectType->objectType, + $this->objectID, + $this->parentObjectID + ); + } + + private function getFileProcessor(): AttachmentFileProcessor + { + if (!isset($this->fileProcessor)) { + $this->fileProcessor = FileProcessor::getInstance()->forTypeName('com.woltlab.wcf.attachment'); + } + + return $this->fileProcessor; + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 2f58b02d862..3b551a8c959 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -2,6 +2,8 @@ namespace wcf\system\file\processor; +use wcf\system\attachment\AttachmentHandler; + /** * @author Alexander Ebert * @copyright 2001-2024 WoltLab GmbH @@ -17,6 +19,38 @@ public function getTypeName(): string public function acceptUpload(string $filename, int $fileSize, array $context): bool { + $objectType = $context['objectType'] ?? ''; + $objectID = \intval($context['objectID'] ?? 0); + $parentObjectID = \intval($context['parentObjectID'] ?? 0); + + $attachmentHandler = new AttachmentHandler($objectType, $objectID, '', $parentObjectID); + if (!$attachmentHandler->canUpload()) { + return false; + } + + if ($fileSize > $attachmentHandler->getMaxSize()) { + return false; + } + + $extensions = \implode("|", $attachmentHandler->getAllowedExtensions()); + $extensions = \str_replace('\*', '.*', \preg_quote($extensions), '/'); + $extensionsPattern = '/(' . $extensions . ')$/i'; + if (!\preg_match($extensionsPattern, \mb_strtolower($filename))) { + return false; + } + return true; } + + public function toHtmlElement(string $objectType, int $objectID, int $parentObjectID): string + { + return FileProcessor::getInstance()->getHtmlElement( + $this, + [ + 'objectType' => $objectType, + 'objectID' => $objectID, + 'parentObjectID' => $parentObjectID, + ], + ); + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php index 31e7f32bbd9..2ed15e41d1f 100644 --- a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php @@ -2,9 +2,13 @@ namespace wcf\system\file\processor; +use wcf\action\FileUploadPreflightAction; use wcf\system\event\EventHandler; use wcf\system\file\processor\event\FileProcessorCollecting; +use wcf\system\request\LinkHandler; use wcf\system\SingletonFactory; +use wcf\util\JSON; +use wcf\util\StringUtil; /** * @author Alexander Ebert @@ -31,4 +35,22 @@ public function forTypeName(string $typeName): ?IFileProcessor { return $this->processors[$typeName] ?? null; } + + public function getHtmlElement(IFileProcessor $fileProcessor, array $context): string + { + $endpoint = LinkHandler::getInstance()->getControllerLink(FileUploadPreflightAction::class); + + return \sprintf( + <<<'HTML' + + HTML, + StringUtil::encodeHTML($endpoint), + StringUtil::encodeHTML($fileProcessor->getTypeName()), + StringUtil::encodeHTML(JSON::encode($context)), + ); + } } From da614dc1a6a12fae3bb69959baf1ec33d60d0f34 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sun, 28 Jan 2024 17:11:11 +0100 Subject: [PATCH 18/97] Improve the error handling of the preflight request --- ts/WoltLabSuite/Core/Component/File/Upload.ts | 36 ++++++++++---- .../Core/Component/File/Upload.js | 38 +++++++++++---- .../FileUploadPreflightAction.class.php | 11 ++++- .../files/lib/data/file/File.class.php | 2 +- .../file/temporary/FileTemporary.class.php | 2 +- .../attachment/AttachmentHandler.class.php | 1 + .../AttachmentFileProcessor.class.php | 32 +++++++++---- .../FileProcessorPreflightResult.class.php | 48 +++++++++++++++++++ .../file/processor/IFileProcessor.class.php | 2 +- 9 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 wcfsetup/install/files/lib/system/file/processor/FileProcessorPreflightResult.class.php diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index ce6745e7b75..31cd5eac9f9 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -1,6 +1,7 @@ import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { StatusNotOk } from "WoltLabSuite/Core/Ajax/Error"; +import { isPlainObject } from "WoltLabSuite/Core/Core"; import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; -import { ucfirst } from "WoltLabSuite/Core/StringUtil"; type PreflightResponse = { endpoints: string[]; @@ -11,15 +12,30 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis const fileHash = await getSha256Hash(await file.arrayBuffer()); - const response = (await prepareRequest(element.dataset.endpoint!) - .post({ - filename: file.name, - fileSize: file.size, - fileHash, - typeName, - context: element.dataset.context, - }) - .fetchAsJson()) as PreflightResponse; + let response: PreflightResponse; + try { + response = (await prepareRequest(element.dataset.endpoint!) + .post({ + filename: file.name, + fileSize: file.size, + fileHash, + typeName, + context: element.dataset.context, + }) + .fetchAsJson()) as PreflightResponse; + } catch (e) { + if (e instanceof StatusNotOk) { + const body = await e.response.clone().json(); + if (isPlainObject(body) && isPlainObject(body.error)) { + console.log(body); + return; + } else { + throw e; + } + } else { + throw e; + } + } const { endpoints } = response; const chunkSize = Math.ceil(file.size / endpoints.length); 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 5f1d6442600..51b6873604b 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -1,19 +1,37 @@ -define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector"], function (require, exports, Backend_1, Selector_1) { +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Ajax/Error", "WoltLabSuite/Core/Core", "WoltLabSuite/Core/Helper/Selector"], function (require, exports, Backend_1, Error_1, Core_1, Selector_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; async function upload(element, file) { const typeName = element.dataset.typeName; const fileHash = await getSha256Hash(await file.arrayBuffer()); - const response = (await (0, Backend_1.prepareRequest)(element.dataset.endpoint) - .post({ - filename: file.name, - fileSize: file.size, - fileHash, - typeName, - context: element.dataset.context, - }) - .fetchAsJson()); + let response; + try { + response = (await (0, Backend_1.prepareRequest)(element.dataset.endpoint) + .post({ + filename: file.name, + fileSize: file.size, + fileHash, + typeName, + context: element.dataset.context, + }) + .fetchAsJson()); + } + catch (e) { + if (e instanceof Error_1.StatusNotOk) { + const body = await e.response.clone().json(); + if ((0, Core_1.isPlainObject)(body) && (0, Core_1.isPlainObject)(body.error)) { + console.log(body); + return; + } + else { + throw e; + } + } + else { + throw e; + } + } const { endpoints } = response; const chunkSize = Math.ceil(file.size / endpoints.length); for (let i = 0, length = endpoints.length; i < length; i++) { diff --git a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php index 92cafeb3f00..b8ac8853d73 100644 --- a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php @@ -12,6 +12,7 @@ use wcf\http\Helper; use wcf\system\exception\SystemException; use wcf\system\file\processor\FileProcessor; +use wcf\system\file\processor\FileProcessorPreflightResult; use wcf\system\request\LinkHandler; use wcf\util\JSON; @@ -50,9 +51,15 @@ public function handle(ServerRequestInterface $request): ResponseInterface ], 400); } - if (!$fileProcessor->acceptUpload($parameters['filename'], $parameters['fileSize'], $decodedContext)) { + $validationResult = $fileProcessor->acceptUpload($parameters['filename'], $parameters['fileSize'], $decodedContext); + if (!$validationResult->ok()) { // 403 Permission Denied - return new EmptyResponse(403); + return new JsonResponse([ + 'error' => [ + 'type' => $validationResult->toString(), + 'message' => $validationResult->toErrorMessage(), + ], + ], 403); } $numberOfChunks = FileTemporary::getNumberOfChunks($parameters['fileSize']); diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index c2b99ffe1bd..01c8a93a39e 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -35,7 +35,7 @@ public function getSourceFilename(): string return \sprintf( '%d-%s.bin', $this->fileID, - $this->filename, + $this->fileHash, ); } } diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php index 061dfe5bf75..25fe09cb921 100644 --- a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php @@ -45,7 +45,7 @@ public function hasChunk(int $sequenceNo): bool public function getFilename(): string { - return \sprintf("%s-final.bin", $this->identifier); + return \sprintf("%s.bin", $this->identifier); } public function getPath(): string diff --git a/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php b/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php index bbedfb1d76f..228e46c57f7 100644 --- a/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php +++ b/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php @@ -332,6 +332,7 @@ public function getHtmlElement(): string return $this->getFileProcessor()->toHtmlElement( $this->objectType->objectType, $this->objectID, + \implode(',', $this->tmpHash), $this->parentObjectID ); } diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 3b551a8c959..b327e5c2fd4 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -17,32 +17,45 @@ public function getTypeName(): string return 'com.woltlab.wcf.attachment'; } - public function acceptUpload(string $filename, int $fileSize, array $context): bool + public function acceptUpload(string $filename, int $fileSize, array $context): FileProcessorPreflightResult { + // TODO: Properly validate the shape of `$context`. $objectType = $context['objectType'] ?? ''; $objectID = \intval($context['objectID'] ?? 0); $parentObjectID = \intval($context['parentObjectID'] ?? 0); + $tmpHash = $context['tmpHash'] ?? ''; - $attachmentHandler = new AttachmentHandler($objectType, $objectID, '', $parentObjectID); + $attachmentHandler = new AttachmentHandler($objectType, $objectID, $tmpHash, $parentObjectID); if (!$attachmentHandler->canUpload()) { - return false; + return FileProcessorPreflightResult::InsufficientPermissions; } if ($fileSize > $attachmentHandler->getMaxSize()) { - return false; + return FileProcessorPreflightResult::FileSizeTooLarge; } - $extensions = \implode("|", $attachmentHandler->getAllowedExtensions()); - $extensions = \str_replace('\*', '.*', \preg_quote($extensions), '/'); + // TODO: This is a typical use case and should be provided through a helper function. + $extensions = \implode( + "|", + \array_map( + static function (string $extension) { + $extension = \preg_quote($extension, '/'); + $extension = \str_replace('\*', '.*', $extension); + + return $extension; + }, + $attachmentHandler->getAllowedExtensions() + ) + ); $extensionsPattern = '/(' . $extensions . ')$/i'; if (!\preg_match($extensionsPattern, \mb_strtolower($filename))) { - return false; + return FileProcessorPreflightResult::FileExtensionNotPermitted; } - return true; + return FileProcessorPreflightResult::Passed; } - public function toHtmlElement(string $objectType, int $objectID, int $parentObjectID): string + public function toHtmlElement(string $objectType, int $objectID, string $tmpHash, int $parentObjectID): string { return FileProcessor::getInstance()->getHtmlElement( $this, @@ -50,6 +63,7 @@ public function toHtmlElement(string $objectType, int $objectID, int $parentObje 'objectType' => $objectType, 'objectID' => $objectID, 'parentObjectID' => $parentObjectID, + 'tmpHash' => $tmpHash, ], ); } diff --git a/wcfsetup/install/files/lib/system/file/processor/FileProcessorPreflightResult.class.php b/wcfsetup/install/files/lib/system/file/processor/FileProcessorPreflightResult.class.php new file mode 100644 index 00000000000..0e570c87a36 --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/FileProcessorPreflightResult.class.php @@ -0,0 +1,48 @@ + + * @since 6.1 + */ +enum FileProcessorPreflightResult +{ + case FileExtensionNotPermitted; + case FileSizeTooLarge; + case InsufficientPermissions; + case Passed; + + public function ok(): bool + { + return match ($this) { + self::Passed => true, + default => false, + }; + } + + public function toString(): string + { + return match ($this) { + self::FileExtensionNotPermitted => 'fileExtensionNotPermitted', + self::FileSizeTooLarge => 'fileSizeTooLarge', + self::InsufficientPermissions => 'insufficientPermissions', + self::Passed => 'passed', + }; + } + + public function toErrorMessage(): string + { + if ($this->ok()) { + throw new \RuntimeException("Cannot invoke `toErrorMessage()` on a successful result."); + } + + $phraseSuffix = $this->toString(); + + return WCF::getLanguage()->get("wcf.file.preflight.error.{$phraseSuffix}"); + } +} diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index 1b840c4ff33..52cb5da6124 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -12,5 +12,5 @@ interface IFileProcessor { public function getTypeName(): string; - public function acceptUpload(string $filename, int $fileSize, array $context): bool; + public function acceptUpload(string $filename, int $fileSize, array $context): FileProcessorPreflightResult; } From 103425df536de377ec5900066285b1d308addc81 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sun, 28 Jan 2024 20:09:15 +0100 Subject: [PATCH 19/97] Add support for an extension based filter --- .../WebComponent/woltlab-core-file-upload.ts | 5 +++++ .../files/js/WoltLabSuite/WebComponent.min.js | 2 +- .../processor/AttachmentFileProcessor.class.php | 13 +++++++++++++ .../system/file/processor/FileProcessor.class.php | 15 +++++++++++++++ .../file/processor/IFileProcessor.class.php | 6 ++++-- 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts b/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts index df0752c19e7..413ce7dbf1b 100644 --- a/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts +++ b/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts @@ -34,6 +34,11 @@ } connectedCallback() { + const allowedFileExtensions = this.dataset.fileExtensions || ""; + if (allowedFileExtensions !== "") { + this.#element.accept = allowedFileExtensions; + } + const shadow = this.attachShadow({ mode: "open" }); shadow.append(this.#element); diff --git a/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js b/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js index 5b4f135b95b..f442038ca31 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js +++ b/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js @@ -60,7 +60,7 @@ Expecting `+Z.join(", ")+", got '"+(this.terminals_[T]||T)+"'":ae="Parse error o time::after { content: " (" attr(title) ")"; } - }`,x.append(D)}m&&(this.#a.dateTime=p.toISOString(),this.#a.title=g.DateAndTime.format(p));let k;if(this.static)k=this.#a.title;else if(Et?k=this.#t(x,0):p.getTime()>a?k=this.#t(x,-1):k=x.map(W=>W.value).join(""):k=g.DateAndTime.format(p)}else k=g.Date.format(p);k=k.charAt(0).toUpperCase()+k.slice(1),this.#a.textContent=k}#t(m,p){return m.map(k=>k.type==="weekday"?g.TodayOrYesterday.format(p,"day"):k.value).join("")}}window.customElements.define("woltlab-core-date-time",q);let S=()=>{document.querySelectorAll("woltlab-core-date-time").forEach(h=>h.refresh(!1))},z,P=()=>{z=window.setInterval(()=>{l(),S()},6e4)};document.addEventListener("DOMContentLoaded",()=>P(),{once:!0}),document.addEventListener("visibilitychange",()=>{document.hidden?window.clearInterval(z):(S(),P())})}{class e extends HTMLElement{#e;constructor(){super(),this.#e=document.createElement("input"),this.#e.type="file",this.#e.addEventListener("change",()=>{let{files:i}=this.#e;if(!(i===null||i.length===0))for(let c of i){let t=new CustomEvent("shouldUpload",{cancelable:!0,detail:c});if(this.dispatchEvent(t),t.defaultPrevented)continue;let a=new CustomEvent("upload",{detail:c});this.dispatchEvent(a)}})}connectedCallback(){this.attachShadow({mode:"open"}).append(this.#e);let c=document.createElement("style");c.textContent=` + }`,x.append(D)}m&&(this.#a.dateTime=p.toISOString(),this.#a.title=g.DateAndTime.format(p));let k;if(this.static)k=this.#a.title;else if(Et?k=this.#t(x,0):p.getTime()>a?k=this.#t(x,-1):k=x.map(W=>W.value).join(""):k=g.DateAndTime.format(p)}else k=g.Date.format(p);k=k.charAt(0).toUpperCase()+k.slice(1),this.#a.textContent=k}#t(m,p){return m.map(k=>k.type==="weekday"?g.TodayOrYesterday.format(p,"day"):k.value).join("")}}window.customElements.define("woltlab-core-date-time",q);let S=()=>{document.querySelectorAll("woltlab-core-date-time").forEach(h=>h.refresh(!1))},z,P=()=>{z=window.setInterval(()=>{l(),S()},6e4)};document.addEventListener("DOMContentLoaded",()=>P(),{once:!0}),document.addEventListener("visibilitychange",()=>{document.hidden?window.clearInterval(z):(S(),P())})}{class e extends HTMLElement{#e;constructor(){super(),this.#e=document.createElement("input"),this.#e.type="file",this.#e.addEventListener("change",()=>{let{files:i}=this.#e;if(!(i===null||i.length===0))for(let c of i){let t=new CustomEvent("shouldUpload",{cancelable:!0,detail:c});if(this.dispatchEvent(t),t.defaultPrevented)continue;let a=new CustomEvent("upload",{detail:c});this.dispatchEvent(a)}})}connectedCallback(){let i=this.dataset.fileExtensions||"";i!==""&&(this.#e.accept=i),this.attachShadow({mode:"open"}).append(this.#e);let t=document.createElement("style");t.textContent=` :host { position: relative; } diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index b327e5c2fd4..2c10b31ee15 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -17,6 +17,19 @@ public function getTypeName(): string return 'com.woltlab.wcf.attachment'; } + public function getAllowedFileExtensions(array $context): array + { + // TODO: Properly validate the shape of `$context`. + $objectType = $context['objectType'] ?? ''; + $objectID = \intval($context['objectID'] ?? 0); + $parentObjectID = \intval($context['parentObjectID'] ?? 0); + $tmpHash = $context['tmpHash'] ?? ''; + + $attachmentHandler = new AttachmentHandler($objectType, $objectID, $tmpHash, $parentObjectID); + + return $attachmentHandler->getAllowedExtensions(); + } + public function acceptUpload(string $filename, int $fileSize, array $context): FileProcessorPreflightResult { // TODO: Properly validate the shape of `$context`. diff --git a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php index 2ed15e41d1f..311092be5bf 100644 --- a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php @@ -40,17 +40,32 @@ public function getHtmlElement(IFileProcessor $fileProcessor, array $context): s { $endpoint = LinkHandler::getInstance()->getControllerLink(FileUploadPreflightAction::class); + $allowedFileExtensions = $fileProcessor->getAllowedFileExtensions($context); + if (\in_array('*', $allowedFileExtensions)) { + $allowedFileExtensions = ''; + } else { + $allowedFileExtensions = \implode( + ',', + \array_map( + static fn (string $fileExtension) => ".{$fileExtension}", + $allowedFileExtensions + ) + ); + } + return \sprintf( <<<'HTML' HTML, StringUtil::encodeHTML($endpoint), StringUtil::encodeHTML($fileProcessor->getTypeName()), StringUtil::encodeHTML(JSON::encode($context)), + StringUtil::encodeHTML($allowedFileExtensions), ); } } diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index 52cb5da6124..2184892f491 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -10,7 +10,9 @@ */ interface IFileProcessor { - public function getTypeName(): string; - public function acceptUpload(string $filename, int $fileSize, array $context): FileProcessorPreflightResult; + + public function getAllowedFileExtensions(array $context): array; + + public function getTypeName(): string; } From ff080a368495e97a593ed52d62a2ef0fa9a4e44b Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Wed, 31 Jan 2024 17:24:09 +0100 Subject: [PATCH 20/97] Prototype for the delegation of attachments to the file API --- .../lib/action/FileUploadAction.class.php | 12 ++++++++-- .../lib/data/attachment/Attachment.class.php | 1 + .../files/lib/data/file/File.class.php | 7 ++++++ .../AttachmentFileProcessor.class.php | 22 +++++++++++++++++++ .../file/processor/IFileProcessor.class.php | 4 ++++ wcfsetup/setup/db/install.sql | 4 ++++ 6 files changed, 48 insertions(+), 2 deletions(-) diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index a53d49e8b20..cd28e267793 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -12,7 +12,6 @@ use wcf\data\file\temporary\FileTemporaryEditor; use wcf\http\Helper; use wcf\system\exception\IllegalLinkException; -use wcf\system\io\AtomicWriter; use wcf\system\io\File as IoFile; final class FileUploadAction implements RequestHandlerInterface @@ -44,7 +43,6 @@ public function handle(ServerRequestInterface $request): ResponseInterface } // Check if this is a valid sequence no. - $numberOfChunks = $fileTemporary->getChunkCount(); if ($parameters['sequenceNo'] >= $fileTemporary->getChunkCount()) { // TODO: Proper error message throw new IllegalLinkException(); @@ -119,7 +117,17 @@ public function handle(ServerRequestInterface $request): ResponseInterface $file = FileEditor::createFromTemporary($fileTemporary); + $context = $fileTemporary->getContext(); (new FileTemporaryEditor($fileTemporary))->delete(); + unset($fileTemporary); + + $processor = $file->getProcessor(); + if ($processor === null) { + // TODO: Mark the file as orphaned. + \assert($processor !== null); + } + + $processor->adopt($file, $context); // TODO: This is just debug code. return new JsonResponse([ diff --git a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php index b613c8ae5db..3b18f8713d1 100644 --- a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php +++ b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php @@ -42,6 +42,7 @@ * @property-read int $lastDownloadTime timestamp at which the attachment has been downloaded the last time * @property-read int $uploadTime timestamp at which the attachment has been uploaded * @property-read int $showOrder position of the attachment in relation to the other attachment to the same message + * @property-read int|null $fileID */ class Attachment extends DatabaseObject implements ILinkableObject, IRouteController, IThumbnailFile { diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index 01c8a93a39e..973b75df9a7 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -3,6 +3,8 @@ namespace wcf\data\file; use wcf\data\DatabaseObject; +use wcf\system\file\processor\FileProcessor; +use wcf\system\file\processor\IFileProcessor; /** * @author Alexander Ebert @@ -38,4 +40,9 @@ public function getSourceFilename(): string $this->fileHash, ); } + + public function getProcessor(): ?IFileProcessor + { + return FileProcessor::getInstance()->forTypeName($this->typeName); + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 2c10b31ee15..35f71babc09 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -2,6 +2,8 @@ namespace wcf\system\file\processor; +use wcf\data\attachment\AttachmentEditor; +use wcf\data\file\File; use wcf\system\attachment\AttachmentHandler; /** @@ -30,6 +32,26 @@ public function getAllowedFileExtensions(array $context): array return $attachmentHandler->getAllowedExtensions(); } + public function adopt(File $file, array $context): void + { + // TODO: Properly validate the shape of `$context`. + $objectType = $context['objectType'] ?? ''; + $objectID = \intval($context['objectID'] ?? 0); + $parentObjectID = \intval($context['parentObjectID'] ?? 0); + $tmpHash = $context['tmpHash'] ?? ''; + + $attachmentHandler = new AttachmentHandler($objectType, $objectID, $tmpHash, $parentObjectID); + + // TODO: How do we want to create the attachments? Do we really want to + // keep using the existing attachment table though? + AttachmentEditor::fastCreate([ + 'objectTypeID' => $attachmentHandler->getObjectType()->objectTypeID, + 'objectID' => $attachmentHandler->getObjectID(), + 'tmpHash' => $tmpHash, + 'fileID' => $file->fileID, + ]); + } + public function acceptUpload(string $filename, int $fileSize, array $context): FileProcessorPreflightResult { // TODO: Properly validate the shape of `$context`. diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index 2184892f491..bf695e4c6bc 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -2,6 +2,8 @@ namespace wcf\system\file\processor; +use wcf\data\file\File; + /** * @author Alexander Ebert * @copyright 2001-2024 WoltLab GmbH @@ -12,6 +14,8 @@ interface IFileProcessor { public function acceptUpload(string $filename, int $fileSize, array $context): FileProcessorPreflightResult; + public function adopt(File $file, array $context): void; + public function getAllowedFileExtensions(array $context): array; public function getTypeName(): string; diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index ed0b30c7170..ccd029f64a8 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -223,6 +223,9 @@ CREATE TABLE wcf1_attachment ( lastDownloadTime INT(10) NOT NULL DEFAULT 0, uploadTime INT(10) NOT NULL DEFAULT 0, showOrder SMALLINT(5) NOT NULL DEFAULT 0, + + fileID INT, + KEY (objectTypeID, objectID), KEY (objectTypeID, tmpHash), KEY (objectID, uploadTime) @@ -2001,6 +2004,7 @@ ALTER TABLE wcf1_article_content ADD FOREIGN KEY (teaserImageID) REFERENCES wcf1 ALTER TABLE wcf1_attachment ADD FOREIGN KEY (objectTypeID) REFERENCES wcf1_object_type (objectTypeID) ON DELETE CASCADE; ALTER TABLE wcf1_attachment ADD FOREIGN KEY (userID) REFERENCES wcf1_user (userID) ON DELETE SET NULL; +ALTER TABLE wcf1_attachment ADD FOREIGN KEY (fileID) REFERENCES wcf1_file (fileID) ON DELETE SET NULL; ALTER TABLE wcf1_bbcode ADD FOREIGN KEY (packageID) REFERENCES wcf1_package (packageID) ON DELETE CASCADE; From 1718e6a1861a7af6dcf86d4f6f6308c4abb74a05 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 15 Feb 2024 17:24:45 +0100 Subject: [PATCH 21/97] Delegate attachments to the file upload system --- .../lib/action/FileDownloadAction.class.php | 82 +++++++++++++++++++ .../lib/data/attachment/Attachment.class.php | 64 +++++++++++++-- .../GroupedAttachmentList.class.php | 21 +++++ .../files/lib/data/file/File.class.php | 10 +++ .../statement/PreparedStatement.class.php | 2 +- .../AttachmentFileProcessor.class.php | 11 +++ .../file/processor/IFileProcessor.class.php | 2 + 7 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 wcfsetup/install/files/lib/action/FileDownloadAction.class.php diff --git a/wcfsetup/install/files/lib/action/FileDownloadAction.class.php b/wcfsetup/install/files/lib/action/FileDownloadAction.class.php new file mode 100644 index 00000000000..b7ded2a8fbf --- /dev/null +++ b/wcfsetup/install/files/lib/action/FileDownloadAction.class.php @@ -0,0 +1,82 @@ + + * @since 6.1 + */ +final class FileDownloadAction implements RequestHandlerInterface +{ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $parameters = Helper::mapQueryParameters( + $request->getQueryParams(), + <<<'EOT' + array { + id: positive-int + } + EOT, + ); + + $file = new File($parameters['id']); + if (!$file->fileID) { + throw new IllegalLinkException(); + } + + $processor = $file->getProcessor(); + if ($processor === null) { + throw new IllegalLinkException(); + } + + if (!$processor->canDownload($file)) { + throw new PermissionDeniedException(); + } + + $filename = $file->getPath() . $file->getSourceFilename(); + $response = new Response( + new Stream($filename), + ); + + $mimeType = FileUtil::getMimeType($filename); + + // TODO: This should use `FileReader` instead. + + $inlineMimeTypes = [ + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/x-png', + 'application/pdf', + 'image/pjpeg', + 'image/webp', + ]; + + $dispositionType = \in_array($mimeType, $inlineMimeTypes) ? 'inline' : 'attachment'; + + return $response->withHeader('content-type', $mimeType) + ->withHeader( + 'content-disposition', + \sprintf( + '%s; filename="%s"', + $dispositionType, + $file->filename, + ), + ); + } +} diff --git a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php index 3b18f8713d1..a00eabab289 100644 --- a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php +++ b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php @@ -5,6 +5,7 @@ use wcf\data\DatabaseObject; use wcf\data\ILinkableObject; use wcf\data\IThumbnailFile; +use wcf\data\file\File; use wcf\data\object\type\ObjectTypeCache; use wcf\system\request\IRouteController; use wcf\system\request\LinkHandler; @@ -58,11 +59,18 @@ class Attachment extends DatabaseObject implements ILinkableObject, IRouteContro */ protected $permissions = []; + protected File $file; + /** * @inheritDoc */ public function getLink(): string { + $file = $this->getFile(); + if ($file !== null) { + return $file->getLink(); + } + // Do not use `LinkHandler::getControllerLink()` or `forceFrontend` as attachment // links can be opened in the frontend and in the ACP. return LinkHandler::getInstance()->getLink('Attachment', [ @@ -191,11 +199,7 @@ public function getThumbnailLocation($size = '') */ public function migrateStorage() { - foreach ([ - $this->getLocation(), - $this->getThumbnailLocation(), - $this->getThumbnailLocation('tiny'), - ] as $location) { + foreach ([$this->getLocation(), $this->getThumbnailLocation(), $this->getThumbnailLocation('tiny'),] as $location) { if (!\str_ends_with($location, '.bin')) { \rename($location, $location . '.bin'); } @@ -339,6 +343,56 @@ public function getIconName() return 'paperclip'; } + public function getFile(): ?File + { + // This method is called within `__get()`, therefore we must dereference + // the data array directly to avoid recursion. + $fileID = $this->data['fileID'] ?? null; + + if (!$fileID) { + return null; + } + + if (!isset($this->file)) { + $this->file = new File($fileID); + } + + return $this->file; + } + + public function setFile(File $file): void + { + if ($this->file->fileID === $file->fileID) { + $this->file = $file; + } + } + + #[\Override] + public function __get($name) + { + $file = $this->getFile(); + if ($file !== null) { + return match ($name) { + 'filename' => $file->filename, + 'filesize' => $file->fileSize, + default => parent::__get($name), + }; + } + + return parent::__get($name); + } + + public static function findByFileID(int $fileID): ?Attachment + { + $sql = "SELECT * + FROM wcf1_attachment + WHERE fileID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$fileID]); + + return $statement->fetchObject(Attachment::class); + } + /** * Returns the storage path. */ diff --git a/wcfsetup/install/files/lib/data/attachment/GroupedAttachmentList.class.php b/wcfsetup/install/files/lib/data/attachment/GroupedAttachmentList.class.php index 6f38520a17d..81af576e26d 100644 --- a/wcfsetup/install/files/lib/data/attachment/GroupedAttachmentList.class.php +++ b/wcfsetup/install/files/lib/data/attachment/GroupedAttachmentList.class.php @@ -2,6 +2,7 @@ namespace wcf\data\attachment; +use wcf\data\file\FileList; use wcf\data\object\type\ObjectTypeCache; /** @@ -76,6 +77,8 @@ public function readObjects() { parent::readObjects(); + $fileIDs = []; + // group by object id foreach ($this->objects as $attachmentID => $attachment) { if (!isset($this->groupedObjects[$attachment->objectID])) { @@ -83,6 +86,24 @@ public function readObjects() } $this->groupedObjects[$attachment->objectID][$attachmentID] = $attachment; + + if ($attachment->fileID) { + $fileIDs[] = $attachment->fileID; + } + } + + if ($fileIDs !== []) { + $fileList = new FileList(); + $fileList->setObjectIDs($fileIDs); + $fileList->readObjects(); + $files = $fileList->getObjects(); + + foreach ($this->objects as $attachment) { + if ($attachment->fileID) { + $file = $files[$attachment->fileID]; + $attachment->setFile($file); + } + } } } diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index 973b75df9a7..f1e2b64bf55 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -2,9 +2,11 @@ namespace wcf\data\file; +use wcf\action\FileDownloadAction; use wcf\data\DatabaseObject; use wcf\system\file\processor\FileProcessor; use wcf\system\file\processor\IFileProcessor; +use wcf\system\request\LinkHandler; /** * @author Alexander Ebert @@ -41,6 +43,14 @@ public function getSourceFilename(): string ); } + public function getLink(): string + { + return LinkHandler::getInstance()->getControllerLink( + FileDownloadAction::class, + ['id' => $this->fileID] + ); + } + public function getProcessor(): ?IFileProcessor { return FileProcessor::getInstance()->forTypeName($this->typeName); diff --git a/wcfsetup/install/files/lib/system/database/statement/PreparedStatement.class.php b/wcfsetup/install/files/lib/system/database/statement/PreparedStatement.class.php index 037ce0e6a69..6f25be1a31e 100644 --- a/wcfsetup/install/files/lib/system/database/statement/PreparedStatement.class.php +++ b/wcfsetup/install/files/lib/system/database/statement/PreparedStatement.class.php @@ -207,7 +207,7 @@ public function fetchSingleColumn($columnNumber = 0) * * @template T of DatabaseObject * @param class-string $className - * @return T + * @return T|null */ public function fetchObject($className) { diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 35f71babc09..7aff1b5dd20 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -2,6 +2,7 @@ namespace wcf\system\file\processor; +use wcf\data\attachment\Attachment; use wcf\data\attachment\AttachmentEditor; use wcf\data\file\File; use wcf\system\attachment\AttachmentHandler; @@ -90,6 +91,16 @@ static function (string $extension) { return FileProcessorPreflightResult::Passed; } + public function canDownload(File $file): bool + { + $attachment = Attachment::findByFileID($file->fileID); + if ($attachment === null) { + return false; + } + + return $attachment->canDownload(); + } + public function toHtmlElement(string $objectType, int $objectID, string $tmpHash, int $parentObjectID): string { return FileProcessor::getInstance()->getHtmlElement( diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index bf695e4c6bc..13a0b890034 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -16,6 +16,8 @@ public function acceptUpload(string $filename, int $fileSize, array $context): F public function adopt(File $file, array $context): void; + public function canDownload(File $file): bool; + public function getAllowedFileExtensions(array $context): array; public function getTypeName(): string; From 60a101c5551d3fd74253d2e776974dac08b82912 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 15 Feb 2024 18:43:54 +0100 Subject: [PATCH 22/97] Add the ability to attach custom response data --- ts/WoltLabSuite/Core/Component/File/Upload.ts | 31 +++++++++++++++++-- .../Core/Component/File/Upload.js | 13 ++++++-- .../lib/action/FileUploadAction.class.php | 9 ++++-- .../AttachmentFileProcessor.class.php | 12 +++++++ .../file/processor/IFileProcessor.class.php | 2 ++ 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 31cd5eac9f9..cce4d261b29 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -7,6 +7,18 @@ type PreflightResponse = { endpoints: string[]; }; +type UploadResponse = + | { completed: false } + | ({ + completed: true; + } & UploadCompleted); + +export type UploadCompleted = { + fileID: string; + typeName: string; + data: Record; +}; + async function upload(element: WoltlabCoreFileUploadElement, file: File): Promise { const typeName = element.dataset.typeName!; @@ -50,9 +62,22 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis const checksum = await getSha256Hash(await chunk.arrayBuffer()); endpoint.searchParams.append("checksum", checksum); - const response = await prepareRequest(endpoint.toString()).post(chunk).fetchAsResponse(); - if (response) { - console.log(await response.text()); + try { + const response = (await prepareRequest(endpoint.toString()).post(chunk).fetchAsJson()) as UploadResponse; + if (response.completed) { + const event = new CustomEvent("uploadCompleted", { + detail: { + data: response.data, + fileID: response.fileID, + typeName: response.typeName, + }, + }); + element.dispatchEvent(event); + } + } catch (e) { + // TODO: Handle errors + console.log(e); + throw e; } } } 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 51b6873604b..cfe8f6b43bb 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -41,9 +41,16 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co const endpoint = new URL(endpoints[i]); const checksum = await getSha256Hash(await chunk.arrayBuffer()); endpoint.searchParams.append("checksum", checksum); - const response = await (0, Backend_1.prepareRequest)(endpoint.toString()).post(chunk).fetchAsResponse(); - if (response) { - console.log(await response.text()); + try { + const response = (await (0, Backend_1.prepareRequest)(endpoint.toString()).post(chunk).fetchAsJson()); + if (response.completed) { + console.log(response); + } + } + catch (e) { + // TODO: Handle errors + console.log(e); + throw e; } } } diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index cd28e267793..bda5a61c4f1 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -131,10 +131,15 @@ public function handle(ServerRequestInterface $request): ResponseInterface // TODO: This is just debug code. return new JsonResponse([ - 'file' => $file->getPath() . $file->getSourceFilename(), + 'completed' => true, + 'fileID' => $file->fileID, + 'typeName' => $file->typeName, + 'data' => $processor->getUploadResponse($file), ]); } - return new EmptyResponse(); + return new JsonResponse([ + 'completed' => false, + ]); } } diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 7aff1b5dd20..0ef55e1bb0a 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -101,6 +101,18 @@ public function canDownload(File $file): bool return $attachment->canDownload(); } + public function getUploadResponse(File $file): array + { + $attachment = Attachment::findByFileID($file->fileID); + if ($attachment === null) { + return []; + } + + return [ + 'attachmentID' => $attachment->attachmentID, + ]; + } + public function toHtmlElement(string $objectType, int $objectID, string $tmpHash, int $parentObjectID): string { return FileProcessor::getInstance()->getHtmlElement( diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index 13a0b890034..00bef07067a 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -21,4 +21,6 @@ public function canDownload(File $file): bool; public function getAllowedFileExtensions(array $context): array; public function getTypeName(): string; + + public function getUploadResponse(File $file): array; } From df0a39e11dd2cd8795e3cfec94d32db3262ffe3e Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 15 Feb 2024 19:26:05 +0100 Subject: [PATCH 23/97] Add basic support for thumbnails --- ts/WoltLabSuite/Core/Component/File/Upload.ts | 6 +++++ .../lib/action/FileUploadAction.class.php | 10 +++++++ .../files/lib/data/file/File.class.php | 14 ++++++++++ .../AttachmentFileProcessor.class.php | 26 +++++++++++++++++++ .../file/processor/IFileProcessor.class.php | 5 ++++ .../file/processor/ThumbnailFormat.class.php | 14 ++++++++++ 6 files changed, 75 insertions(+) create mode 100644 wcfsetup/install/files/lib/system/file/processor/ThumbnailFormat.class.php diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index cce4d261b29..73c3a438aa1 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -14,6 +14,7 @@ type UploadResponse = } & UploadCompleted); export type UploadCompleted = { + endpointThumbnails: string; fileID: string; typeName: string; data: Record; @@ -68,11 +69,16 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis const event = new CustomEvent("uploadCompleted", { detail: { data: response.data, + endpointThumbnails: response.endpointThumbnails, fileID: response.fileID, typeName: response.typeName, }, }); element.dispatchEvent(event); + + if (response.endpointThumbnails !== "") { + // TODO: Dispatch the request to generate thumbnails. + } } } catch (e) { // TODO: Handle errors diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index bda5a61c4f1..aa6431a045c 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -129,9 +129,19 @@ public function handle(ServerRequestInterface $request): ResponseInterface $processor->adopt($file, $context); + $endpointThumbnails = ''; + if ($file->isImage()) { + $thumbnailFormats = $processor->getThumbnailFormats(); + if ($thumbnailFormats !== []) { + // TODO: Endpoint to generate thumbnails. + $endpointThumbnails = ''; + } + } + // TODO: This is just debug code. return new JsonResponse([ 'completed' => true, + 'endpointThumbnails' => $endpointThumbnails, 'fileID' => $file->fileID, 'typeName' => $file->typeName, 'data' => $processor->getUploadResponse($file), diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index f1e2b64bf55..4a7fc6e9bb7 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -7,6 +7,7 @@ use wcf\system\file\processor\FileProcessor; use wcf\system\file\processor\IFileProcessor; use wcf\system\request\LinkHandler; +use wcf\util\FileUtil; /** * @author Alexander Ebert @@ -55,4 +56,17 @@ public function getProcessor(): ?IFileProcessor { return FileProcessor::getInstance()->forTypeName($this->typeName); } + + public function isImage(): bool + { + $mimeType = FileUtil::getMimeType($this->getPath() . $this->getSourceFilename()); + + return match ($mimeType) { + 'image/gif' => true, + 'image/jpg', 'image/jpeg' => true, + 'image/png' => true, + 'image/webp' => true, + default => false, + }; + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 0ef55e1bb0a..88c8c92d368 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -15,11 +15,13 @@ */ final class AttachmentFileProcessor implements IFileProcessor { + #[\Override] public function getTypeName(): string { return 'com.woltlab.wcf.attachment'; } + #[\Override] public function getAllowedFileExtensions(array $context): array { // TODO: Properly validate the shape of `$context`. @@ -33,6 +35,7 @@ public function getAllowedFileExtensions(array $context): array return $attachmentHandler->getAllowedExtensions(); } + #[\Override] public function adopt(File $file, array $context): void { // TODO: Properly validate the shape of `$context`. @@ -53,6 +56,7 @@ public function adopt(File $file, array $context): void ]); } + #[\Override] public function acceptUpload(string $filename, int $fileSize, array $context): FileProcessorPreflightResult { // TODO: Properly validate the shape of `$context`. @@ -91,6 +95,7 @@ static function (string $extension) { return FileProcessorPreflightResult::Passed; } + #[\Override] public function canDownload(File $file): bool { $attachment = Attachment::findByFileID($file->fileID); @@ -101,6 +106,7 @@ public function canDownload(File $file): bool return $attachment->canDownload(); } + #[\Override] public function getUploadResponse(File $file): array { $attachment = Attachment::findByFileID($file->fileID); @@ -113,6 +119,7 @@ public function getUploadResponse(File $file): array ]; } + #[\Override] public function toHtmlElement(string $objectType, int $objectID, string $tmpHash, int $parentObjectID): string { return FileProcessor::getInstance()->getHtmlElement( @@ -125,4 +132,23 @@ public function toHtmlElement(string $objectType, int $objectID, string $tmpHash ], ); } + + #[\Override] + public function getThumbnailFormats(): array + { + return [ + new ThumbnailFormat( + 'tiny', + 144, + 144, + false, + ), + new ThumbnailFormat( + 'default', + \ATTACHMENT_THUMBNAIL_HEIGHT, + \ATTACHMENT_THUMBNAIL_WIDTH, + !!\ATTACHMENT_RETAIN_DIMENSIONS, + ), + ]; + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index 00bef07067a..309624991bc 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -23,4 +23,9 @@ public function getAllowedFileExtensions(array $context): array; public function getTypeName(): string; public function getUploadResponse(File $file): array; + + /** + * @return ThumbnailFormat[] + */ + public function getThumbnailFormats(): array; } diff --git a/wcfsetup/install/files/lib/system/file/processor/ThumbnailFormat.class.php b/wcfsetup/install/files/lib/system/file/processor/ThumbnailFormat.class.php new file mode 100644 index 00000000000..41cd45c297b --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/ThumbnailFormat.class.php @@ -0,0 +1,14 @@ + Date: Fri, 16 Feb 2024 18:35:34 +0100 Subject: [PATCH 24/97] Add support for image thumbnails --- ts/WoltLabSuite/Core/Component/File/Upload.ts | 3 +- .../Core/Component/File/Upload.js | 14 ++++- .../FileGenerateThumbnailsAction.class.php | 36 +++++++++++ .../lib/action/FileUploadAction.class.php | 6 +- .../lib/data/attachment/Attachment.class.php | 2 + .../files/lib/data/file/File.class.php | 2 +- .../temporary/FileTemporaryAction.class.php | 1 + .../temporary/FileTemporaryList.class.php | 1 + .../file/thumbnail/FileThumbnail.class.php | 42 +++++++++++++ .../thumbnail/FileThumbnailAction.class.php | 20 ++++++ .../thumbnail/FileThumbnailEditor.class.php | 63 +++++++++++++++++++ .../thumbnail/FileThumbnailList.class.php | 22 +++++++ .../AttachmentFileProcessor.class.php | 36 ++++++++--- .../file/processor/FileProcessor.class.php | 51 +++++++++++++++ .../file/processor/IFileProcessor.class.php | 3 + wcfsetup/setup/db/install.sql | 15 +++++ 16 files changed, 306 insertions(+), 11 deletions(-) create mode 100644 wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php create mode 100644 wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php create mode 100644 wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailAction.class.php create mode 100644 wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailEditor.class.php create mode 100644 wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailList.class.php diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 73c3a438aa1..0d839cf3f0b 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -77,7 +77,8 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis element.dispatchEvent(event); if (response.endpointThumbnails !== "") { - // TODO: Dispatch the request to generate thumbnails. + void (await prepareRequest(response.endpointThumbnails).get().fetchAsResponse()); + // TODO: Handle errors and notify about the new thumbnails. } } } catch (e) { 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 cfe8f6b43bb..6c6784f46d0 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -44,7 +44,19 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co try { const response = (await (0, Backend_1.prepareRequest)(endpoint.toString()).post(chunk).fetchAsJson()); if (response.completed) { - console.log(response); + const event = new CustomEvent("uploadCompleted", { + detail: { + data: response.data, + endpointThumbnails: response.endpointThumbnails, + fileID: response.fileID, + typeName: response.typeName, + }, + }); + element.dispatchEvent(event); + if (response.endpointThumbnails !== "") { + void (await (0, Backend_1.prepareRequest)(response.endpointThumbnails).get().fetchAsResponse()); + // TODO: Handle errors and notify about the new thumbnails. + } } } catch (e) { diff --git a/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php b/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php new file mode 100644 index 00000000000..a98d2875a9e --- /dev/null +++ b/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php @@ -0,0 +1,36 @@ +getQueryParams(), + <<<'EOT' + array { + id: positive-int, + } + EOT, + ); + + $file = new File($parameters['id']); + if (!$file->fileID) { + throw new IllegalLinkException(); + } + + FileProcessor::getInstance()->generateThumbnails($file); + + return new EmptyResponse(); + } +} diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index aa6431a045c..d08bbec8f99 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -13,6 +13,7 @@ use wcf\http\Helper; use wcf\system\exception\IllegalLinkException; use wcf\system\io\File as IoFile; +use wcf\system\request\LinkHandler; final class FileUploadAction implements RequestHandlerInterface { @@ -134,7 +135,10 @@ public function handle(ServerRequestInterface $request): ResponseInterface $thumbnailFormats = $processor->getThumbnailFormats(); if ($thumbnailFormats !== []) { // TODO: Endpoint to generate thumbnails. - $endpointThumbnails = ''; + $endpointThumbnails = LinkHandler::getInstance()->getControllerLink( + FileGenerateThumbnailsAction::class, + ['id' => $file->fileID], + ); } } diff --git a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php index a00eabab289..25643d4688d 100644 --- a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php +++ b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php @@ -44,6 +44,8 @@ * @property-read int $uploadTime timestamp at which the attachment has been uploaded * @property-read int $showOrder position of the attachment in relation to the other attachment to the same message * @property-read int|null $fileID + * @property-read int|null $thumbnailID + * @property-read int|null $tinyThumbnailID */ class Attachment extends DatabaseObject implements ILinkableObject, IRouteController, IThumbnailFile { diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index 4a7fc6e9bb7..6650af41313 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -29,7 +29,7 @@ public function getPath(): string $folderB = \substr($this->fileHash, 2, 2); return \sprintf( - \WCF_DIR . '_data/public/fileUpload/%s/%s/', + \WCF_DIR . '_data/private/fileUpload/%s/%s/', $folderA, $folderB, ); diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php index 404a80ca932..8fbb72adb6a 100644 --- a/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php @@ -8,6 +8,7 @@ * @author Alexander Ebert * @copyright 2001-2023 WoltLab GmbH * @license GNU Lesser General Public License + * @since 6.1 * * @method FileTemporary create() * @method FileTemporaryEditor[] getObjects() diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php index 2bd4f42eafc..fe6c3a835c7 100644 --- a/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php @@ -8,6 +8,7 @@ * @author Alexander Ebert * @copyright 2001-2023 WoltLab GmbH * @license GNU Lesser General Public License + * @since 6.1 * * @method FileTemporary current() * @method FileTemporary[] getObjects() diff --git a/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php new file mode 100644 index 00000000000..84864a79843 --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php @@ -0,0 +1,42 @@ + + * @since 6.1 + * + * @property-read int $thumbnailID + * @property-read int $fileID + * @property-read string $identifier + * @property-read string $fileHash + * @property-read string $fileExtension + */ +class FileThumbnail extends DatabaseObject +{ + public function getPath(): string + { + $folderA = \substr($this->fileHash, 0, 2); + $folderB = \substr($this->fileHash, 2, 2); + + return \sprintf( + \WCF_DIR . '_data/public/thumbnail/%s/%s/', + $folderA, + $folderB, + ); + } + + public function getSourceFilename(): string + { + return \sprintf( + '%d-%s.%s', + $this->thumbnailID, + $this->fileHash, + $this->fileExtension, + ); + } +} diff --git a/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailAction.class.php b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailAction.class.php new file mode 100644 index 00000000000..f502b2e746d --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailAction.class.php @@ -0,0 +1,20 @@ + + * @since 6.1 + * + * @method FileThumbnail create() + * @method FileThumbnailEditor[] getObjects() + * @method FileThumbnailEditor getSingleObject() + */ +class FileThumbnailAction extends AbstractDatabaseObjectAction +{ + protected $className = FileThumbnailEditor::class; +} diff --git a/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailEditor.class.php b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailEditor.class.php new file mode 100644 index 00000000000..cf9d3431a7a --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailEditor.class.php @@ -0,0 +1,63 @@ + + * @since 6.1 + * + * @method static FileThumbnail create(array $parameters = []) + * @method FileThumbnail getDecoratedObject() + * @mixin FileThumbnail + */ +class FileThumbnailEditor extends DatabaseObjectEditor +{ + /** + * @inheritDoc + */ + protected static $baseClass = FileThumbnail::class; + + public static function createFromTemporaryFile( + File $file, + ThumbnailFormat $format, + string $filename + ): FileThumbnail { + $mimeType = FileUtil::getMimeType($filename); + $fileExtension = match ($mimeType) { + 'image/gif' => 'gif', + 'image/jpg', 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + }; + + $action = new FileThumbnailAction([], 'create', [ + 'data' => [ + 'fileID' => $file->fileID, + 'identifier' => $format->identifier, + 'fileHash' => hash_file('sha256', $filename), + 'fileExtension' => $fileExtension, + ], + ]); + $fileThumbnail = $action->executeAction()['returnValues']; + \assert($fileThumbnail instanceof FileThumbnail); + + $filePath = $fileThumbnail->getPath(); + if (!\is_dir($filePath)) { + \mkdir($filePath, recursive: true); + } + + \rename( + $filename, + $filePath . $fileThumbnail->getSourceFilename() + ); + + return $fileThumbnail; + } +} diff --git a/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailList.class.php b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailList.class.php new file mode 100644 index 00000000000..05813ea3535 --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailList.class.php @@ -0,0 +1,22 @@ + + * @since 6.1 + * + * @method FileThumbnail current() + * @method FileThumbnail[] getObjects() + * @method FileThumbnail|null getSingleObject() + * @method FileThumbnail|null search($objectID) + * @property FileThumbnail[] $objects + */ +class FileThumbnailList extends DatabaseObjectList +{ + public $className = FileThumbnail::class; +} diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 88c8c92d368..3c24055005a 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -5,7 +5,9 @@ use wcf\data\attachment\Attachment; use wcf\data\attachment\AttachmentEditor; use wcf\data\file\File; +use wcf\data\file\thumbnail\FileThumbnail; use wcf\system\attachment\AttachmentHandler; +use wcf\system\exception\NotImplementedException; /** * @author Alexander Ebert @@ -119,7 +121,6 @@ public function getUploadResponse(File $file): array ]; } - #[\Override] public function toHtmlElement(string $objectType, int $objectID, string $tmpHash, int $parentObjectID): string { return FileProcessor::getInstance()->getHtmlElement( @@ -137,18 +138,39 @@ public function toHtmlElement(string $objectType, int $objectID, string $tmpHash public function getThumbnailFormats(): array { return [ + new ThumbnailFormat( + '', + \ATTACHMENT_THUMBNAIL_HEIGHT, + \ATTACHMENT_THUMBNAIL_WIDTH, + !!\ATTACHMENT_RETAIN_DIMENSIONS, + ), new ThumbnailFormat( 'tiny', 144, 144, false, ), - new ThumbnailFormat( - 'default', - \ATTACHMENT_THUMBNAIL_HEIGHT, - \ATTACHMENT_THUMBNAIL_WIDTH, - !!\ATTACHMENT_RETAIN_DIMENSIONS, - ), ]; } + + #[\Override] + public function adoptThumbnail(FileThumbnail $thumbnail): void + { + $attachment = Attachment::findByFileID($thumbnail->fileID); + if ($attachment === null) { + // TODO: How to handle this case? + return; + } + + $columnName = match ($thumbnail->identifier) { + '' => 'thumbnailID', + 'tiny'=>'tinyThumbnailID', + 'default'=>throw new \RuntimeException('TODO'), // TODO + }; + + $attachmentEditor = new AttachmentEditor($attachment); + $attachmentEditor->update([ + $columnName => $thumbnail->thumbnailID, + ]); + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php index 311092be5bf..a3a00e01035 100644 --- a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php @@ -3,10 +3,17 @@ namespace wcf\system\file\processor; use wcf\action\FileUploadPreflightAction; +use wcf\data\file\File; +use wcf\data\file\thumbnail\FileThumbnail; +use wcf\data\file\thumbnail\FileThumbnailEditor; +use wcf\data\file\thumbnail\FileThumbnailList; use wcf\system\event\EventHandler; use wcf\system\file\processor\event\FileProcessorCollecting; +use wcf\system\image\adapter\ImageAdapter; +use wcf\system\image\ImageHandler; use wcf\system\request\LinkHandler; use wcf\system\SingletonFactory; +use wcf\util\FileUtil; use wcf\util\JSON; use wcf\util\StringUtil; @@ -68,4 +75,48 @@ public function getHtmlElement(IFileProcessor $fileProcessor, array $context): s StringUtil::encodeHTML($allowedFileExtensions), ); } + + public function generateThumbnails(File $file): void + { + $processor = $file->getProcessor(); + if ($processor === null) { + return; + } + + $formats = $processor->getThumbnailFormats(); + if ($formats === []) { + return; + } + + $thumbnailList = new FileThumbnailList(); + $thumbnailList->getConditionBuilder()->add("fileID = ?", [$file->fileID]); + $thumbnailList->readObjects(); + + $existingThumbnails = []; + foreach ($thumbnailList as $thumbnail) { + \assert($thumbnail instanceof FileThumbnail); + $existingThumbnails[$thumbnail->identifier] = $thumbnail; + } + + $imageAdapter = null; + foreach ($formats as $format) { + if (isset($existingThumbnails[$format->identifier])) { + continue; + } + + if ($imageAdapter === null) { + $imageAdapter = ImageHandler::getInstance()->getAdapter(); + $imageAdapter->loadFile($file->getPath() . $file->getSourceFilename()); + } + + assert($imageAdapter instanceof ImageAdapter); + $image = $imageAdapter->createThumbnail($format->width, $format->height, $format->retainDimensions); + + $filename = FileUtil::getTemporaryFilename(); + $imageAdapter->writeImage($image, $filename); + + $fileThumbnail = FileThumbnailEditor::createFromTemporaryFile($file, $format, $filename); + $processor->adoptThumbnail($fileThumbnail); + } + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index 309624991bc..3f624c1ad3f 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -3,6 +3,7 @@ namespace wcf\system\file\processor; use wcf\data\file\File; +use wcf\data\file\thumbnail\FileThumbnail; /** * @author Alexander Ebert @@ -16,6 +17,8 @@ public function acceptUpload(string $filename, int $fileSize, array $context): F public function adopt(File $file, array $context): void; + public function adoptThumbnail(FileThumbnail $thumbnail): void; + public function canDownload(File $file): bool; public function getAllowedFileExtensions(array $context): array; diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index ccd029f64a8..5fe2e8fbac4 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -225,6 +225,8 @@ CREATE TABLE wcf1_attachment ( showOrder SMALLINT(5) NOT NULL DEFAULT 0, fileID INT, + thumbnailID INT, + tinyThumbnailID INT, KEY (objectTypeID, objectID), KEY (objectTypeID, tmpHash), @@ -617,6 +619,15 @@ CREATE TABLE wcf1_file_temporary ( chunks VARBINARY(255) NOT NULL ); +DROP TABLE IF EXISTS wcf1_file_thumbnail; +CREATE TABLE wcf1_file_thumbnail ( + thumbnailID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + fileID INT NOT NULL, + identifier VARCHAR(50) NOT NULL, + fileHash CHAR(64) NOT NULL, + fileExtension VARCHAR(10) NOT NULL +); + /* As the flood control table can be a high traffic table and as it is periodically emptied, there is no foreign key on the `objectTypeID` to speed up insertions. */ DROP TABLE IF EXISTS wcf1_flood_control; @@ -2005,6 +2016,8 @@ ALTER TABLE wcf1_article_content ADD FOREIGN KEY (teaserImageID) REFERENCES wcf1 ALTER TABLE wcf1_attachment ADD FOREIGN KEY (objectTypeID) REFERENCES wcf1_object_type (objectTypeID) ON DELETE CASCADE; ALTER TABLE wcf1_attachment ADD FOREIGN KEY (userID) REFERENCES wcf1_user (userID) ON DELETE SET NULL; ALTER TABLE wcf1_attachment ADD FOREIGN KEY (fileID) REFERENCES wcf1_file (fileID) ON DELETE SET NULL; +ALTER TABLE wcf1_attachment ADD FOREIGN KEY (thumbnailID) REFERENCES wcf1_file_thumbnail (thumbnailID) ON DELETE SET NULL; +ALTER TABLE wcf1_attachment ADD FOREIGN KEY (tinyThumbnailID) REFERENCES wcf1_file_thumbnail (thumbnailID) ON DELETE SET NULL; ALTER TABLE wcf1_bbcode ADD FOREIGN KEY (packageID) REFERENCES wcf1_package (packageID) ON DELETE CASCADE; @@ -2055,6 +2068,8 @@ ALTER TABLE wcf1_email_log_entry ADD FOREIGN KEY (recipientID) REFERENCES wcf1_u ALTER TABLE wcf1_event_listener ADD FOREIGN KEY (packageID) REFERENCES wcf1_package (packageID) ON DELETE CASCADE; +ALTER TABLE wcf1_file_thumbnail ADD FOREIGN KEY (fileID) REFERENCES wcf1_file (fileID) ON DELETE CASCADE; + ALTER TABLE wcf1_language_item ADD FOREIGN KEY (languageID) REFERENCES wcf1_language (languageID) ON DELETE CASCADE; ALTER TABLE wcf1_language_item ADD FOREIGN KEY (languageCategoryID) REFERENCES wcf1_language_category (languageCategoryID) ON DELETE CASCADE; ALTER TABLE wcf1_language_item ADD FOREIGN KEY (packageID) REFERENCES wcf1_package (packageID) ON DELETE CASCADE; From 1cace1e3e591b045417f4780b07479f5a7944a89 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 29 Feb 2024 18:23:54 +0100 Subject: [PATCH 25/97] Add the `woltlab-core-file` element to represent uploads --- .../Core/Component/File/woltlab-core-file.ts | 226 ++++++++++++++++++ ts/global.d.ts | 2 + 2 files changed, 228 insertions(+) create mode 100644 ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts 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..ed0aacc79a6 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts @@ -0,0 +1,226 @@ +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 { + #state: State = State.Initial; + 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() { + if (this.#state === State.Initial) { + this.#initializeState(); + } + + this.#rebuildElement(); + } + + #initializeState(): void { + // 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.#state = State.Uploading; + + return; + } + + // 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)); + } + } + + this.#state = State.Ready; + } + + #rebuildElement(): void { + switch (this.#state) { + case State.Uploading: + this.#replaceWithIcon().setIcon("spinner"); + break; + + case State.GeneratingThumbnails: + this.#replaceWithIcon().setIcon("spinner"); + break; + + case State.Ready: + if (this.previewUrl) { + this.#replaceWithImage(this.previewUrl); + } else { + const iconName = this.iconName || "file"; + this.#replaceWithIcon().setIcon(iconName); + } + break; + + case State.Failed: + this.#replaceWithIcon().setIcon("times"); + 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(): FaIcon { + let icon = this.querySelector("fa-icon"); + if (icon === null) { + this.innerHTML = ""; + + icon = document.createElement("fa-icon"); + icon.size = 64; + this.append(icon); + } + + return icon; + } + + get fileId(): number | undefined { + const fileId = parseInt(this.dataset.fileId || "0"); + if (fileId === 0) { + return undefined; + } + + return fileId; + } + + get iconName(): string | undefined { + return this.dataset.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(); + } + + uploadFailed(): void { + if (this.#state !== State.Uploading) { + return; + } + + this.#state = State.Failed; + this.#rebuildElement(); + + this.#readyReject(); + } + + uploadCompleted(hasThumbnails: boolean): void { + if (this.#state === State.Uploading) { + 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(); + } + + get thumbnails(): Thumbnail[] { + return [...this.#thumbnails]; + } + + get ready(): Promise { + return this.#readyPromise; + } +} + +export default WoltlabCoreFileElement; + +window.customElements.define("woltlab-core-file", WoltlabCoreFileElement); diff --git a/ts/global.d.ts b/ts/global.d.ts index 997c0faf380..a46ca265281 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; @@ -123,6 +124,7 @@ 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; From 8b9796e1d5cf9079288a12f71d7165f80c4920fd Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Fri, 1 Mar 2024 16:59:29 +0100 Subject: [PATCH 26/97] Add a lifecycle behavior for uploaded files --- .../shared_messageFormAttachments.tpl | 10 +- ts/WoltLabSuite/Core/Bootstrap.ts | 4 + .../Core/Component/Attachment/Entry.ts | 11 + .../Core/Component/Attachment/List.ts | 36 ++++ ts/WoltLabSuite/Core/Component/File/Upload.ts | 85 ++++++-- .../Core/Component/File/woltlab-core-file.ts | 45 ++-- ts/WoltLabSuite/WebComponent/index.ts | 1 + .../files/js/WoltLabSuite/Core/Bootstrap.js | 6 +- .../Core/Component/Attachment/Entry.js | 15 ++ .../Core/Component/Attachment/List.js | 36 ++++ .../Core/Component/File/Upload.js | 56 +++-- .../Core/Component/File/woltlab-core-file.js | 193 ++++++++++++++++++ .../FileGenerateThumbnailsAction.class.php | 25 ++- .../file/thumbnail/FileThumbnail.class.php | 36 +++- 14 files changed, 492 insertions(+), 67 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Component/Attachment/Entry.ts create mode 100644 ts/WoltLabSuite/Core/Component/Attachment/List.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/Entry.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js diff --git a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl index 29ab7fd1b95..8b1c5361bdb 100644 --- a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl +++ b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl @@ -1,7 +1,5 @@
- + {@$attachmentHandler->getHtmlElement()}
@@ -9,6 +7,12 @@ {lang}wcf.attachment.upload.limits{/lang}
+ + {event name='fields'}
diff --git a/ts/WoltLabSuite/Core/Bootstrap.ts b/ts/WoltLabSuite/Core/Bootstrap.ts index 53a30e2f511..3305619a490 100644 --- a/ts/WoltLabSuite/Core/Bootstrap.ts +++ b/ts/WoltLabSuite/Core/Bootstrap.ts @@ -168,7 +168,11 @@ 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()); }); diff --git a/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts b/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts new file mode 100644 index 00000000000..940dbcd5d85 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts @@ -0,0 +1,11 @@ +export class AttachmentEntry { + #attachmentId: number; + readonly #name: string; + + constructor(attachmentId: number, name: string) { + this.#attachmentId = attachmentId; + this.#name = name; + } +} + +export default AttachmentEntry; diff --git a/ts/WoltLabSuite/Core/Component/Attachment/List.ts b/ts/WoltLabSuite/Core/Component/Attachment/List.ts new file mode 100644 index 00000000000..6aaa6e219ad --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Attachment/List.ts @@ -0,0 +1,36 @@ +import WoltlabCoreFileElement from "../File/woltlab-core-file"; + +function upload(fileList: HTMLElement, file: WoltlabCoreFileElement): void { + // TODO: Any sort of upload indicator, meter, spinner, whatever? + const element = document.createElement("li"); + element.classList.add("attachment__list__item"); + element.append(file); + fileList.append(element); + + void file.ready.then(() => { + // TODO: Do something? + }); +} + +export function setup(container: HTMLElement): void { + 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) => { + // TODO: How do we keep track of the files being uploaded? + upload(fileList!, event.detail); + }); +} diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 0d839cf3f0b..0bd958598ee 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -2,6 +2,7 @@ import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; import { StatusNotOk } from "WoltLabSuite/Core/Ajax/Error"; import { isPlainObject } from "WoltLabSuite/Core/Core"; import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; +import WoltlabCoreFileElement from "./woltlab-core-file"; type PreflightResponse = { endpoints: string[]; @@ -15,17 +16,35 @@ type UploadResponse = export type UploadCompleted = { endpointThumbnails: string; - fileID: string; + fileID: number; typeName: string; data: Record; }; +export type ThumbnailsGenerated = { + data: GenerateThumbnailsResponse; + fileID: number; +}; + +type ThumbnailData = { + identifier: string; + link: string; +}; + +type GenerateThumbnailsResponse = ThumbnailData[]; + async function upload(element: WoltlabCoreFileUploadElement, file: File): Promise { const typeName = element.dataset.typeName!; const fileHash = await getSha256Hash(await file.arrayBuffer()); - let response: PreflightResponse; + const fileElement = document.createElement("woltlab-core-file"); + fileElement.dataset.filename = file.name; + + const event = new CustomEvent("uploadStart", { detail: fileElement }); + element.dispatchEvent(event); + + let response: PreflightResponse | undefined = undefined; try { response = (await prepareRequest(element.dataset.endpoint!) .post({ @@ -48,11 +67,18 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis } else { throw e; } + } finally { + if (response === undefined) { + fileElement.uploadFailed(); + } } + const { endpoints } = response; const chunkSize = Math.ceil(file.size / endpoints.length); + // TODO: Can we somehow report any meaningful upload progress? + for (let i = 0, length = endpoints.length; i < length; i++) { const start = i * chunkSize; const end = start + chunkSize; @@ -63,32 +89,51 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis const checksum = await getSha256Hash(await chunk.arrayBuffer()); endpoint.searchParams.append("checksum", checksum); + let response: UploadResponse; try { - const response = (await prepareRequest(endpoint.toString()).post(chunk).fetchAsJson()) as UploadResponse; - if (response.completed) { - const event = new CustomEvent("uploadCompleted", { - detail: { - data: response.data, - endpointThumbnails: response.endpointThumbnails, - fileID: response.fileID, - typeName: response.typeName, - }, - }); - element.dispatchEvent(event); - - if (response.endpointThumbnails !== "") { - void (await prepareRequest(response.endpointThumbnails).get().fetchAsResponse()); - // TODO: Handle errors and notify about the new thumbnails. - } - } + response = (await prepareRequest(endpoint.toString()).post(chunk).fetchAsJson()) as UploadResponse; } catch (e) { // TODO: Handle errors - console.log(e); + console.error(e); + + fileElement.uploadFailed(); throw e; } + + await chunkUploadCompleted(fileElement, response); } } +async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, response: UploadResponse): Promise { + if (!response.completed) { + return; + } + + const hasThumbnails = response.endpointThumbnails !== ""; + fileElement.uploadCompleted(response.fileID, hasThumbnails); + + if (hasThumbnails) { + await generateThumbnails(fileElement, response.endpointThumbnails); + } +} + +async function generateThumbnails( + fileElement: WoltlabCoreFileElement, + endpoint: string, +): Promise { + let response: GenerateThumbnailsResponse; + + try { + response = (await prepareRequest(endpoint).get().fetchAsJson()) as GenerateThumbnailsResponse; + } catch (e) { + // TODO: Handle errors + console.error(e); + throw e; + } + + fileElement.setThumbnails(response); +} + async function getSha256Hash(data: BufferSource): Promise { const buffer = await window.crypto.subtle.digest("SHA-256", data); diff --git a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts index ed0aacc79a6..0aadb05ea1a 100644 --- a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts +++ b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts @@ -30,6 +30,8 @@ export class Thumbnail { } export class WoltlabCoreFileElement extends HTMLElement { + #filename: string = ""; + #fileId: number | undefined = undefined; #state: State = State.Initial; readonly #thumbnails: Thumbnail[] = []; @@ -57,10 +59,18 @@ export class WoltlabCoreFileElement extends HTMLElement { #initializeState(): void { // 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.#state = State.Uploading; + if (this.#fileId === undefined) { + this.#filename = this.dataset.filename || ""; + delete this.dataset.filename; - return; + const fileId = parseInt(this.getAttribute("file-id") || "0"); + if (fileId) { + this.#fileId = fileId; + } else { + this.#state = State.Uploading; + + return; + } } // Initialize the list of thumbnails from the data attribute. @@ -77,11 +87,11 @@ export class WoltlabCoreFileElement extends HTMLElement { #rebuildElement(): void { switch (this.#state) { case State.Uploading: - this.#replaceWithIcon().setIcon("spinner"); + this.#replaceWithIcon("spinner"); break; case State.GeneratingThumbnails: - this.#replaceWithIcon().setIcon("spinner"); + this.#replaceWithIcon("spinner"); break; case State.Ready: @@ -89,12 +99,12 @@ export class WoltlabCoreFileElement extends HTMLElement { this.#replaceWithImage(this.previewUrl); } else { const iconName = this.iconName || "file"; - this.#replaceWithIcon().setIcon(iconName); + this.#replaceWithIcon(iconName); } break; case State.Failed: - this.#replaceWithIcon().setIcon("times"); + this.#replaceWithIcon("times"); break; default: @@ -128,26 +138,24 @@ export class WoltlabCoreFileElement extends HTMLElement { } } - #replaceWithIcon(): FaIcon { + #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 { - const fileId = parseInt(this.dataset.fileId || "0"); - if (fileId === 0) { - return undefined; - } - - return fileId; + return this.#fileId; } get iconName(): string | undefined { @@ -172,6 +180,10 @@ export class WoltlabCoreFileElement extends HTMLElement { this.#rebuildElement(); } + get filename(): string | undefined { + return this.#filename; + } + uploadFailed(): void { if (this.#state !== State.Uploading) { return; @@ -183,8 +195,11 @@ export class WoltlabCoreFileElement extends HTMLElement { this.#readyReject(); } - uploadCompleted(hasThumbnails: boolean): void { + uploadCompleted(fileId: number, hasThumbnails: boolean): void { if (this.#state === State.Uploading) { + this.#fileId = fileId; + this.setAttribute("file-id", fileId.toString()); + if (hasThumbnails) { this.#state = State.GeneratingThumbnails; this.#rebuildElement(); diff --git a/ts/WoltLabSuite/WebComponent/index.ts b/ts/WoltLabSuite/WebComponent/index.ts index 3abdba8e2f8..ceca19a68b6 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.ts" import "./woltlab-core-file-upload.ts" import "./woltlab-core-loading-indicator.ts"; import "./woltlab-core-notice.ts"; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js index 62177dd0e76..6eee3a54187 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js @@ -133,8 +133,12 @@ define(["require", "exports", "tslib", "./Core", "./Date/Picker", "./Devtools", (0, LazyLoader_1.whenFirstSeen)("[data-google-maps-geocoding]", () => { void new Promise((resolve_5, reject_5) => { require(["./Component/GoogleMaps/Geocoding"], resolve_5, reject_5); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); + (0, LazyLoader_1.whenFirstSeen)("woltlab-core-file", () => { + void new Promise((resolve_6, reject_6) => { require(["./Component/File/woltlab-core-file"], resolve_6, reject_6); }).then(tslib_1.__importStar); + }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-file-upload", () => { - void new Promise((resolve_6, reject_6) => { require(["./Component/File/Upload"], resolve_6, reject_6); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + 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/Upload"], resolve_8, reject_8); }).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/Attachment/Entry.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/Entry.js new file mode 100644 index 00000000000..882e3edb835 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/Entry.js @@ -0,0 +1,15 @@ +define(["require", "exports"], function (require, exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.AttachmentEntry = void 0; + class AttachmentEntry { + #attachmentId; + #name; + constructor(attachmentId, name) { + this.#attachmentId = attachmentId; + this.#name = name; + } + } + exports.AttachmentEntry = AttachmentEntry; + exports.default = AttachmentEntry; +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js new file mode 100644 index 00000000000..24f55c43e0b --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js @@ -0,0 +1,36 @@ +define(["require", "exports"], function (require, exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = void 0; + function upload(fileList, file) { + // TODO: Any sort of upload indicator, meter, spinner, whatever? + const element = document.createElement("li"); + element.classList.add("attachment__list__item"); + element.append(file); + fileList.append(element); + void file.ready.then(() => { + // TODO: Do something? + }); + } + function setup(container) { + 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) => { + // TODO: How do we keep track of the files being uploaded? + upload(fileList, event.detail); + }); + } + exports.setup = setup; +}); 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 6c6784f46d0..9be236d22ae 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -5,7 +5,11 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co async function upload(element, file) { const typeName = element.dataset.typeName; const fileHash = await getSha256Hash(await file.arrayBuffer()); - let response; + const fileElement = document.createElement("woltlab-core-file"); + fileElement.dataset.filename = file.name; + const event = new CustomEvent("uploadStart", { detail: fileElement }); + element.dispatchEvent(event); + let response = undefined; try { response = (await (0, Backend_1.prepareRequest)(element.dataset.endpoint) .post({ @@ -32,8 +36,14 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co throw e; } } + finally { + if (response === undefined) { + fileElement.uploadFailed(); + } + } const { endpoints } = response; const chunkSize = Math.ceil(file.size / endpoints.length); + // TODO: Can we somehow report any meaningful upload progress? for (let i = 0, length = endpoints.length; i < length; i++) { const start = i * chunkSize; const end = start + chunkSize; @@ -41,30 +51,40 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co const endpoint = new URL(endpoints[i]); const checksum = await getSha256Hash(await chunk.arrayBuffer()); endpoint.searchParams.append("checksum", checksum); + let response; try { - const response = (await (0, Backend_1.prepareRequest)(endpoint.toString()).post(chunk).fetchAsJson()); - if (response.completed) { - const event = new CustomEvent("uploadCompleted", { - detail: { - data: response.data, - endpointThumbnails: response.endpointThumbnails, - fileID: response.fileID, - typeName: response.typeName, - }, - }); - element.dispatchEvent(event); - if (response.endpointThumbnails !== "") { - void (await (0, Backend_1.prepareRequest)(response.endpointThumbnails).get().fetchAsResponse()); - // TODO: Handle errors and notify about the new thumbnails. - } - } + response = (await (0, Backend_1.prepareRequest)(endpoint.toString()).post(chunk).fetchAsJson()); } catch (e) { // TODO: Handle errors - console.log(e); + console.error(e); + fileElement.uploadFailed(); throw e; } + await chunkUploadCompleted(fileElement, response); + } + } + async function chunkUploadCompleted(fileElement, response) { + if (!response.completed) { + return; + } + const hasThumbnails = response.endpointThumbnails !== ""; + fileElement.uploadCompleted(response.fileID, hasThumbnails); + if (hasThumbnails) { + await generateThumbnails(fileElement, response.endpointThumbnails); + } + } + async function generateThumbnails(fileElement, endpoint) { + let response; + try { + response = (await (0, Backend_1.prepareRequest)(endpoint).get().fetchAsJson()); + } + catch (e) { + // TODO: Handle errors + console.error(e); + throw e; } + fileElement.setThumbnails(response); } async function getSha256Hash(data) { const buffer = await window.crypto.subtle.digest("SHA-256", data); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js new file mode 100644 index 00000000000..cdae36bada5 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js @@ -0,0 +1,193 @@ +define(["require", "exports"], function (require, exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.WoltlabCoreFileElement = exports.Thumbnail = void 0; + class Thumbnail { + #identifier; + #link; + constructor(identifier, link) { + this.#identifier = identifier; + this.#link = link; + } + get identifier() { + return this.#identifier; + } + get link() { + return this.#link; + } + } + exports.Thumbnail = Thumbnail; + class WoltlabCoreFileElement extends HTMLElement { + #filename = ""; + #fileId = undefined; + #state = 0 /* State.Initial */; + #thumbnails = []; + #readyReject; + #readyResolve; + #readyPromise; + constructor() { + super(); + this.#readyPromise = new Promise((resolve, reject) => { + this.#readyResolve = resolve; + this.#readyReject = reject; + }); + } + connectedCallback() { + if (this.#state === 0 /* State.Initial */) { + this.#initializeState(); + } + this.#rebuildElement(); + } + #initializeState() { + // 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 || ""; + delete this.dataset.filename; + const fileId = parseInt(this.getAttribute("file-id") || "0"); + if (fileId) { + this.#fileId = fileId; + } + else { + this.#state = 1 /* State.Uploading */; + return; + } + } + // Initialize the list of thumbnails from the data attribute. + if (this.dataset.thumbnails) { + const thumbnails = JSON.parse(this.dataset.thumbnails); + for (const thumbnail of thumbnails) { + this.#thumbnails.push(new Thumbnail(thumbnail.identifier, thumbnail.link)); + } + } + this.#state = 3 /* State.Ready */; + } + #rebuildElement() { + switch (this.#state) { + case 1 /* State.Uploading */: + this.#replaceWithIcon("spinner"); + break; + case 2 /* State.GeneratingThumbnails */: + this.#replaceWithIcon("spinner"); + break; + case 3 /* State.Ready */: + if (this.previewUrl) { + this.#replaceWithImage(this.previewUrl); + } + else { + const iconName = this.iconName || "file"; + this.#replaceWithIcon(iconName); + } + break; + case 4 /* State.Failed */: + this.#replaceWithIcon("times"); + break; + default: + throw new Error("Unreachable", { + cause: { + state: this.#state, + }, + }); + } + } + #replaceWithImage(src) { + 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) { + 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() { + return this.#fileId; + } + get iconName() { + return this.dataset.iconName; + } + get previewUrl() { + return this.dataset.previewUrl; + } + get unbounded() { + return this.getAttribute("dimensions") === "unbounded"; + } + set unbounded(unbounded) { + if (unbounded) { + this.setAttribute("dimensions", "unbounded"); + } + else { + this.removeAttribute("dimensions"); + } + this.#rebuildElement(); + } + get filename() { + return this.#filename; + } + uploadFailed() { + if (this.#state !== 1 /* State.Uploading */) { + return; + } + this.#state = 4 /* State.Failed */; + this.#rebuildElement(); + this.#readyReject(); + } + uploadCompleted(fileId, hasThumbnails) { + if (this.#state === 1 /* State.Uploading */) { + this.#fileId = fileId; + this.setAttribute("file-id", fileId.toString()); + if (hasThumbnails) { + this.#state = 2 /* State.GeneratingThumbnails */; + this.#rebuildElement(); + } + else { + this.#state = 3 /* State.Ready */; + this.#rebuildElement(); + this.#readyResolve(); + } + } + } + setThumbnails(thumbnails) { + if (this.#state !== 2 /* State.GeneratingThumbnails */) { + return; + } + for (const thumbnail of thumbnails) { + this.#thumbnails.push(new Thumbnail(thumbnail.identifier, thumbnail.link)); + } + this.#state = 3 /* State.Ready */; + this.#rebuildElement(); + this.#readyResolve(); + } + get thumbnails() { + return [...this.#thumbnails]; + } + get ready() { + return this.#readyPromise; + } + } + exports.WoltlabCoreFileElement = WoltlabCoreFileElement; + exports.default = WoltlabCoreFileElement; + window.customElements.define("woltlab-core-file", WoltlabCoreFileElement); +}); diff --git a/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php b/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php index a98d2875a9e..33ef4cdcb5d 100644 --- a/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php +++ b/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php @@ -3,10 +3,13 @@ namespace wcf\action; use Laminas\Diactoros\Response\EmptyResponse; +use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use wcf\data\file\File; +use wcf\data\file\thumbnail\FileThumbnail; +use wcf\data\file\thumbnail\FileThumbnailList; use wcf\http\Helper; use wcf\system\exception\IllegalLinkException; use wcf\system\file\processor\FileProcessor; @@ -31,6 +34,26 @@ public function handle(ServerRequestInterface $request): ResponseInterface FileProcessor::getInstance()->generateThumbnails($file); - return new EmptyResponse(); + $thumbnails = []; + foreach ($this->getThumbnails($file) as $thumbnail) { + $thumbnails[] = [ + 'identifier' => $thumbnail->identifier, + 'link' => $thumbnail->getLink(), + ]; + } + + return new JsonResponse($thumbnails); + } + + /** + * @return FileThumbnail[] + */ + private function getThumbnails(File $file): array + { + $thumbnailList = new FileThumbnailList(); + $thumbnailList->getConditionBuilder()->add("fileID = ?", [$file->fileID]); + $thumbnailList->readObjects(); + + return $thumbnailList->getObjects(); } } diff --git a/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php index 84864a79843..032f50cc65d 100644 --- a/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php +++ b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php @@ -3,6 +3,9 @@ namespace wcf\data\file\thumbnail; use wcf\data\DatabaseObject; +use wcf\data\ILinkableObject; +use wcf\system\application\ApplicationHandler; +use wcf\system\request\LinkHandler; /** * @author Alexander Ebert @@ -16,18 +19,11 @@ * @property-read string $fileHash * @property-read string $fileExtension */ -class FileThumbnail extends DatabaseObject +class FileThumbnail extends DatabaseObject implements ILinkableObject { public function getPath(): string { - $folderA = \substr($this->fileHash, 0, 2); - $folderB = \substr($this->fileHash, 2, 2); - - return \sprintf( - \WCF_DIR . '_data/public/thumbnail/%s/%s/', - $folderA, - $folderB, - ); + return \WCF_DIR . $this->getRelativePath(); } public function getSourceFilename(): string @@ -39,4 +35,26 @@ public function getSourceFilename(): string $this->fileExtension, ); } + + public function getLink(): string + { + return \sprintf( + '%s%s%s', + ApplicationHandler::getInstance()->getWCF()->getPageURL(), + $this->getRelativePath(), + $this->getSourceFilename(), + ); + } + + private function getRelativePath(): string + { + $folderA = \substr($this->fileHash, 0, 2); + $folderB = \substr($this->fileHash, 2, 2); + + return \sprintf( + '_data/public/thumbnail/%s/%s/', + $folderA, + $folderB, + ); + } } From 384f73d14fd7158e9d609944ed77250787aca2fd Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 2 Mar 2024 17:26:26 +0100 Subject: [PATCH 27/97] Add basic support for thumbnails for the file element --- ts/WoltLabSuite/Core/Component/Attachment/List.ts | 10 +++++++--- .../Core/Component/File/woltlab-core-file.ts | 8 ++++++++ .../js/WoltLabSuite/Core/Component/Attachment/List.js | 9 ++++++--- .../Core/Component/File/woltlab-core-file.js | 6 ++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/ts/WoltLabSuite/Core/Component/Attachment/List.ts b/ts/WoltLabSuite/Core/Component/Attachment/List.ts index 6aaa6e219ad..36f31a69935 100644 --- a/ts/WoltLabSuite/Core/Component/Attachment/List.ts +++ b/ts/WoltLabSuite/Core/Component/Attachment/List.ts @@ -1,14 +1,19 @@ import WoltlabCoreFileElement from "../File/woltlab-core-file"; function upload(fileList: HTMLElement, file: WoltlabCoreFileElement): void { - // TODO: Any sort of upload indicator, meter, spinner, whatever? const element = document.createElement("li"); element.classList.add("attachment__list__item"); element.append(file); fileList.append(element); void file.ready.then(() => { - // TODO: Do something? + const thumbnail = file.thumbnails.find((thumbnail) => { + return thumbnail.identifier === "tiny"; + }); + + if (thumbnail !== undefined) { + file.thumbnail = thumbnail; + } }); } @@ -30,7 +35,6 @@ export function setup(container: HTMLElement): void { } uploadButton.addEventListener("uploadStart", (event: CustomEvent) => { - // TODO: How do we keep track of the files being uploaded? upload(fileList!, event.detail); }); } diff --git a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts index 0aadb05ea1a..3f66d707a1d 100644 --- a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts +++ b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts @@ -227,6 +227,14 @@ export class WoltlabCoreFileElement extends HTMLElement { this.#readyResolve(); } + set thumbnail(thumbnail: Thumbnail) { + if (!this.#thumbnails.includes(thumbnail)) { + return; + } + + this.#replaceWithImage(thumbnail.link); + } + get thumbnails(): Thumbnail[] { return [...this.#thumbnails]; } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js index 24f55c43e0b..af4e0241f57 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js @@ -3,13 +3,17 @@ define(["require", "exports"], function (require, exports) { Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; function upload(fileList, file) { - // TODO: Any sort of upload indicator, meter, spinner, whatever? const element = document.createElement("li"); element.classList.add("attachment__list__item"); element.append(file); fileList.append(element); void file.ready.then(() => { - // TODO: Do something? + const thumbnail = file.thumbnails.find((thumbnail) => { + return thumbnail.identifier === "tiny"; + }); + if (thumbnail !== undefined) { + file.thumbnail = thumbnail; + } }); } function setup(container) { @@ -28,7 +32,6 @@ define(["require", "exports"], function (require, exports) { uploadButton.insertAdjacentElement("afterend", fileList); } uploadButton.addEventListener("uploadStart", (event) => { - // TODO: How do we keep track of the files being uploaded? upload(fileList, event.detail); }); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js index cdae36bada5..5339e47a56e 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js @@ -180,6 +180,12 @@ define(["require", "exports"], function (require, exports) { this.#rebuildElement(); this.#readyResolve(); } + set thumbnail(thumbnail) { + if (!this.#thumbnails.includes(thumbnail)) { + return; + } + this.#replaceWithImage(thumbnail.link); + } get thumbnails() { return [...this.#thumbnails]; } From 68470a81f6f4c553730dbd6bb55f253175c58a6f Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 2 Mar 2024 17:54:31 +0100 Subject: [PATCH 28/97] Persistently track the mime type of uploaded files --- .../Core/Component/Attachment/List.ts | 12 ++++---- ts/WoltLabSuite/Core/Component/File/Upload.ts | 8 ++--- .../Core/Component/File/woltlab-core-file.ts | 30 +++++++++++++++++-- .../Core/Component/Attachment/List.js | 12 ++++---- .../Core/Component/File/Upload.js | 2 +- .../Core/Component/File/woltlab-core-file.js | 25 ++++++++++++++-- .../lib/action/FileUploadAction.class.php | 1 + .../files/lib/data/file/File.class.php | 8 ++--- .../files/lib/data/file/FileEditor.class.php | 4 +++ wcfsetup/setup/db/install.sql | 3 +- 10 files changed, 79 insertions(+), 26 deletions(-) diff --git a/ts/WoltLabSuite/Core/Component/Attachment/List.ts b/ts/WoltLabSuite/Core/Component/Attachment/List.ts index 36f31a69935..4d8bac24d62 100644 --- a/ts/WoltLabSuite/Core/Component/Attachment/List.ts +++ b/ts/WoltLabSuite/Core/Component/Attachment/List.ts @@ -7,12 +7,14 @@ function upload(fileList: HTMLElement, file: WoltlabCoreFileElement): void { fileList.append(element); void file.ready.then(() => { - const thumbnail = file.thumbnails.find((thumbnail) => { - return thumbnail.identifier === "tiny"; - }); + if (file.isImage()) { + const thumbnail = file.thumbnails.find((thumbnail) => { + return thumbnail.identifier === "tiny"; + }); - if (thumbnail !== undefined) { - file.thumbnail = thumbnail; + if (thumbnail !== undefined) { + file.thumbnail = thumbnail; + } } }); } diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 0bd958598ee..54f5879625e 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -18,6 +18,7 @@ export type UploadCompleted = { endpointThumbnails: string; fileID: number; typeName: string; + mimeType: string; data: Record; }; @@ -110,17 +111,14 @@ async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, respons } const hasThumbnails = response.endpointThumbnails !== ""; - fileElement.uploadCompleted(response.fileID, hasThumbnails); + fileElement.uploadCompleted(response.fileID, response.mimeType, hasThumbnails); if (hasThumbnails) { await generateThumbnails(fileElement, response.endpointThumbnails); } } -async function generateThumbnails( - fileElement: WoltlabCoreFileElement, - endpoint: string, -): Promise { +async function generateThumbnails(fileElement: WoltlabCoreFileElement, endpoint: string): Promise { let response: GenerateThumbnailsResponse; try { diff --git a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts index 3f66d707a1d..bcb183a8909 100644 --- a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts +++ b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts @@ -32,6 +32,7 @@ export class Thumbnail { export class WoltlabCoreFileElement extends HTMLElement { #filename: string = ""; #fileId: number | undefined = undefined; + #mimeType: string | undefined = undefined; #state: State = State.Initial; readonly #thumbnails: Thumbnail[] = []; @@ -60,9 +61,12 @@ export class WoltlabCoreFileElement extends HTMLElement { // 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 || ""; + this.#filename = this.dataset.filename || "bogus.bin"; delete this.dataset.filename; + 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; @@ -184,6 +188,27 @@ export class WoltlabCoreFileElement extends HTMLElement { return this.#filename; } + get mimeType(): string | undefined { + return this.#mimeType; + } + + 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(): void { if (this.#state !== State.Uploading) { return; @@ -195,9 +220,10 @@ export class WoltlabCoreFileElement extends HTMLElement { this.#readyReject(); } - uploadCompleted(fileId: number, hasThumbnails: boolean): void { + uploadCompleted(fileId: number, mimeType: string, hasThumbnails: boolean): void { if (this.#state === State.Uploading) { this.#fileId = fileId; + this.#mimeType = mimeType; this.setAttribute("file-id", fileId.toString()); if (hasThumbnails) { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js index af4e0241f57..6a5a0aa3eb2 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js @@ -8,11 +8,13 @@ define(["require", "exports"], function (require, exports) { element.append(file); fileList.append(element); void file.ready.then(() => { - const thumbnail = file.thumbnails.find((thumbnail) => { - return thumbnail.identifier === "tiny"; - }); - if (thumbnail !== undefined) { - file.thumbnail = thumbnail; + if (file.isImage()) { + const thumbnail = file.thumbnails.find((thumbnail) => { + return thumbnail.identifier === "tiny"; + }); + if (thumbnail !== undefined) { + file.thumbnail = thumbnail; + } } }); } 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 9be236d22ae..a454d57ac34 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -69,7 +69,7 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co return; } const hasThumbnails = response.endpointThumbnails !== ""; - fileElement.uploadCompleted(response.fileID, hasThumbnails); + fileElement.uploadCompleted(response.fileID, response.mimeType, hasThumbnails); if (hasThumbnails) { await generateThumbnails(fileElement, response.endpointThumbnails); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js index 5339e47a56e..4786b6a7fab 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js @@ -20,6 +20,7 @@ define(["require", "exports"], function (require, exports) { class WoltlabCoreFileElement extends HTMLElement { #filename = ""; #fileId = undefined; + #mimeType = undefined; #state = 0 /* State.Initial */; #thumbnails = []; #readyReject; @@ -42,8 +43,10 @@ define(["require", "exports"], function (require, exports) { // 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 || ""; + this.#filename = this.dataset.filename || "bogus.bin"; delete this.dataset.filename; + 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; @@ -146,6 +149,23 @@ define(["require", "exports"], function (require, exports) { get filename() { return this.#filename; } + get mimeType() { + return this.#mimeType; + } + isImage() { + 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() { if (this.#state !== 1 /* State.Uploading */) { return; @@ -154,9 +174,10 @@ define(["require", "exports"], function (require, exports) { this.#rebuildElement(); this.#readyReject(); } - uploadCompleted(fileId, hasThumbnails) { + uploadCompleted(fileId, mimeType, hasThumbnails) { if (this.#state === 1 /* State.Uploading */) { this.#fileId = fileId; + this.#mimeType = mimeType; this.setAttribute("file-id", fileId.toString()); if (hasThumbnails) { this.#state = 2 /* State.GeneratingThumbnails */; diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index d08bbec8f99..9e0595ceafe 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -148,6 +148,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface 'endpointThumbnails' => $endpointThumbnails, 'fileID' => $file->fileID, 'typeName' => $file->typeName, + 'mimeType' => $file->mimeType, 'data' => $processor->getUploadResponse($file), ]); } diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index 6650af41313..1c7aa7bbffb 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -7,7 +7,6 @@ use wcf\system\file\processor\FileProcessor; use wcf\system\file\processor\IFileProcessor; use wcf\system\request\LinkHandler; -use wcf\util\FileUtil; /** * @author Alexander Ebert @@ -20,6 +19,7 @@ * @property-read int $fileSize * @property-read string $fileHash * @property-read string $typeName + * @property-read string $mimeType */ class File extends DatabaseObject { @@ -59,11 +59,9 @@ public function getProcessor(): ?IFileProcessor public function isImage(): bool { - $mimeType = FileUtil::getMimeType($this->getPath() . $this->getSourceFilename()); - - return match ($mimeType) { + return match ($this->mimeType) { 'image/gif' => true, - 'image/jpg', 'image/jpeg' => true, + 'image/jpeg' => true, 'image/png' => true, 'image/webp' => true, default => false, diff --git a/wcfsetup/install/files/lib/data/file/FileEditor.class.php b/wcfsetup/install/files/lib/data/file/FileEditor.class.php index a70d78b8c6f..88af30cd7b1 100644 --- a/wcfsetup/install/files/lib/data/file/FileEditor.class.php +++ b/wcfsetup/install/files/lib/data/file/FileEditor.class.php @@ -4,6 +4,7 @@ use wcf\data\DatabaseObjectEditor; use wcf\data\file\temporary\FileTemporary; +use wcf\util\FileUtil; /** * @author Alexander Ebert @@ -24,11 +25,14 @@ class FileEditor extends DatabaseObjectEditor public static function createFromTemporary(FileTemporary $fileTemporary): File { + $mimeType = FileUtil::getMimeType($fileTemporary->getPath() . $fileTemporary->getFilename()); + $fileAction = new FileAction([], 'create', ['data' => [ 'filename' => $fileTemporary->filename, 'fileSize' => $fileTemporary->fileSize, 'fileHash' => $fileTemporary->fileHash, 'typeName' => $fileTemporary->typeName, + 'mimeType' => $mimeType, ]]); $file = $fileAction->executeAction()['returnValues']; \assert($file instanceof File); diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index 5fe2e8fbac4..95a179cc5e3 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -604,7 +604,8 @@ CREATE TABLE wcf1_file ( filename VARCHAR(255) NOT NULL, fileSize BIGINT NOT NULL, fileHash CHAR(64) NOT NULL, - typeName VARCHAR(255) NOT NULL + typeName VARCHAR(255) NOT NULL, + mimeType VARCHAR(255) NOT NULL, ); DROP TABLE IF EXISTS wcf1_file_temporary; From 03af9ecbda97d3ff5513f642a5eb7c0702bee49f Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 2 Mar 2024 18:22:48 +0100 Subject: [PATCH 29/97] Implement a button to insert the `[attach]` BBCode into the editor --- .../shared_messageFormAttachments.tpl | 2 +- .../Core/Component/Attachment/List.ts | 39 +++++++++++++++++-- ts/WoltLabSuite/Core/Component/File/Upload.ts | 2 + .../Core/Component/File/woltlab-core-file.ts | 1 + .../Core/Component/Attachment/List.js | 34 ++++++++++++++-- .../Core/Component/File/Upload.js | 2 + .../Core/Component/File/woltlab-core-file.js | 1 + 7 files changed, 73 insertions(+), 8 deletions(-) diff --git a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl index 8b1c5361bdb..851dd4cb4f6 100644 --- a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl +++ b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl @@ -10,7 +10,7 @@ diff --git a/ts/WoltLabSuite/Core/Component/Attachment/List.ts b/ts/WoltLabSuite/Core/Component/Attachment/List.ts index 4d8bac24d62..71de360164e 100644 --- a/ts/WoltLabSuite/Core/Component/Attachment/List.ts +++ b/ts/WoltLabSuite/Core/Component/Attachment/List.ts @@ -1,12 +1,15 @@ +import { dispatchToCkeditor } from "../Ckeditor/Event"; import WoltlabCoreFileElement from "../File/woltlab-core-file"; -function upload(fileList: HTMLElement, file: WoltlabCoreFileElement): void { +function upload(fileList: HTMLElement, file: WoltlabCoreFileElement, editorId: string): void { const element = document.createElement("li"); element.classList.add("attachment__list__item"); element.append(file); fileList.append(element); void file.ready.then(() => { + element.append(getInsertAttachBbcodeButton(file, editorId)); + if (file.isImage()) { const thumbnail = file.thumbnails.find((thumbnail) => { return thumbnail.identifier === "tiny"; @@ -19,7 +22,35 @@ function upload(fileList: HTMLElement, file: WoltlabCoreFileElement): void { }); } -export function setup(container: HTMLElement): void { +function getInsertAttachBbcodeButton(file: WoltlabCoreFileElement, editorId: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button", "small"); + button.textContent = "TODO: insert"; + + button.addEventListener("click", () => { + const editor = document.getElementById(editorId); + if (editor === null) { + // TODO: error handling + return; + } + + dispatchToCkeditor(editor).insertAttachment({ + attachmentId: 123, // TODO: how do we get the id? + url: "", + }); + }); + + return button; +} + +export function setup(editorId: string): void { + const container = document.getElementById(`attachments_${editorId}`); + if (container === null) { + // TODO: error handling + return; + } + const uploadButton = container.querySelector("woltlab-core-file-upload"); if (uploadButton === null) { throw new Error("Expected the container to contain an upload button", { @@ -37,6 +68,8 @@ export function setup(container: HTMLElement): void { } uploadButton.addEventListener("uploadStart", (event: CustomEvent) => { - upload(fileList!, event.detail); + // TODO: We need to forward the attachment data from the event once it + // becoems available. + upload(fileList!, event.detail, editorId); }); } diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 54f5879625e..c9fd7f9f28a 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -111,6 +111,8 @@ async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, respons } const hasThumbnails = response.endpointThumbnails !== ""; + // TODO: The response contains the `.data` property which holds important data + // returned by the file processor that needs to be forwarded. fileElement.uploadCompleted(response.fileID, response.mimeType, hasThumbnails); if (hasThumbnails) { diff --git a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts index bcb183a8909..47983fa1ff2 100644 --- a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts +++ b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts @@ -220,6 +220,7 @@ export class WoltlabCoreFileElement extends HTMLElement { this.#readyReject(); } + // TODO: We need to forward the extra data from the file processor. uploadCompleted(fileId: number, mimeType: string, hasThumbnails: boolean): void { if (this.#state === State.Uploading) { this.#fileId = fileId; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js index 6a5a0aa3eb2..6e7664e0171 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js @@ -1,13 +1,14 @@ -define(["require", "exports"], function (require, exports) { +define(["require", "exports", "../Ckeditor/Event"], function (require, exports, Event_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; - function upload(fileList, file) { + function upload(fileList, file, editorId) { const element = document.createElement("li"); element.classList.add("attachment__list__item"); element.append(file); fileList.append(element); void file.ready.then(() => { + element.append(getInsertAttachBbcodeButton(file, editorId)); if (file.isImage()) { const thumbnail = file.thumbnails.find((thumbnail) => { return thumbnail.identifier === "tiny"; @@ -18,7 +19,30 @@ define(["require", "exports"], function (require, exports) { } }); } - function setup(container) { + function getInsertAttachBbcodeButton(file, editorId) { + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button", "small"); + button.textContent = "TODO: insert"; + button.addEventListener("click", () => { + const editor = document.getElementById(editorId); + if (editor === null) { + // TODO: error handling + return; + } + (0, Event_1.dispatchToCkeditor)(editor).insertAttachment({ + attachmentId: 123, + url: "", + }); + }); + return button; + } + function setup(editorId) { + const container = document.getElementById(`attachments_${editorId}`); + if (container === null) { + // TODO: error handling + return; + } const uploadButton = container.querySelector("woltlab-core-file-upload"); if (uploadButton === null) { throw new Error("Expected the container to contain an upload button", { @@ -34,7 +58,9 @@ define(["require", "exports"], function (require, exports) { uploadButton.insertAdjacentElement("afterend", fileList); } uploadButton.addEventListener("uploadStart", (event) => { - upload(fileList, event.detail); + // TODO: We need to forward the attachment data from the event once it + // becoems available. + upload(fileList, event.detail, editorId); }); } exports.setup = setup; 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 a454d57ac34..75d77e5cc34 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -69,6 +69,8 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co return; } const hasThumbnails = response.endpointThumbnails !== ""; + // TODO: The response contains the `.data` property which holds important data + // returned by the file processor that needs to be forwarded. fileElement.uploadCompleted(response.fileID, response.mimeType, hasThumbnails); if (hasThumbnails) { await generateThumbnails(fileElement, response.endpointThumbnails); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js index 4786b6a7fab..e68ccc71794 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js @@ -174,6 +174,7 @@ define(["require", "exports"], function (require, exports) { this.#rebuildElement(); this.#readyReject(); } + // TODO: We need to forward the extra data from the file processor. uploadCompleted(fileId, mimeType, hasThumbnails) { if (this.#state === 1 /* State.Uploading */) { this.#fileId = fileId; From ae0dda79a3ef53de852864c899c4bac322d8277a Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sun, 3 Mar 2024 18:04:48 +0100 Subject: [PATCH 30/97] Forward the extra data from the file processor --- ts/WoltLabSuite/Core/Component/File/Upload.ts | 4 +--- ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts | 9 +++++++-- .../files/js/WoltLabSuite/Core/Component/File/Upload.js | 4 +--- .../Core/Component/File/woltlab-core-file.js | 8 ++++++-- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index c9fd7f9f28a..2e66e60f66a 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -111,9 +111,7 @@ async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, respons } const hasThumbnails = response.endpointThumbnails !== ""; - // TODO: The response contains the `.data` property which holds important data - // returned by the file processor that needs to be forwarded. - fileElement.uploadCompleted(response.fileID, response.mimeType, hasThumbnails); + fileElement.uploadCompleted(response.fileID, response.mimeType, response.data, hasThumbnails); if (hasThumbnails) { await generateThumbnails(fileElement, response.endpointThumbnails); diff --git a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts index 47983fa1ff2..0e3d6f38962 100644 --- a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts +++ b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts @@ -30,6 +30,7 @@ export class Thumbnail { } export class WoltlabCoreFileElement extends HTMLElement { + #data: Record | undefined = undefined; #filename: string = ""; #fileId: number | undefined = undefined; #mimeType: string | undefined = undefined; @@ -192,6 +193,10 @@ export class WoltlabCoreFileElement extends HTMLElement { return this.#mimeType; } + get data(): Record | undefined { + return this.#data; + } + isImage(): boolean { if (this.mimeType === undefined) { return false; @@ -220,9 +225,9 @@ export class WoltlabCoreFileElement extends HTMLElement { this.#readyReject(); } - // TODO: We need to forward the extra data from the file processor. - uploadCompleted(fileId: number, mimeType: string, hasThumbnails: boolean): void { + uploadCompleted(fileId: number, mimeType: string, data: Record, hasThumbnails: boolean): void { if (this.#state === State.Uploading) { + this.#data = data; this.#fileId = fileId; this.#mimeType = mimeType; this.setAttribute("file-id", fileId.toString()); 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 75d77e5cc34..6afa68289fe 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -69,9 +69,7 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co return; } const hasThumbnails = response.endpointThumbnails !== ""; - // TODO: The response contains the `.data` property which holds important data - // returned by the file processor that needs to be forwarded. - fileElement.uploadCompleted(response.fileID, response.mimeType, hasThumbnails); + fileElement.uploadCompleted(response.fileID, response.mimeType, response.data, hasThumbnails); if (hasThumbnails) { await generateThumbnails(fileElement, response.endpointThumbnails); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js index e68ccc71794..d47f88472c7 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js @@ -18,6 +18,7 @@ define(["require", "exports"], function (require, exports) { } exports.Thumbnail = Thumbnail; class WoltlabCoreFileElement extends HTMLElement { + #data = undefined; #filename = ""; #fileId = undefined; #mimeType = undefined; @@ -152,6 +153,9 @@ define(["require", "exports"], function (require, exports) { get mimeType() { return this.#mimeType; } + get data() { + return this.#data; + } isImage() { if (this.mimeType === undefined) { return false; @@ -174,9 +178,9 @@ define(["require", "exports"], function (require, exports) { this.#rebuildElement(); this.#readyReject(); } - // TODO: We need to forward the extra data from the file processor. - uploadCompleted(fileId, mimeType, hasThumbnails) { + uploadCompleted(fileId, mimeType, data, hasThumbnails) { if (this.#state === 1 /* State.Uploading */) { + this.#data = data; this.#fileId = fileId; this.#mimeType = mimeType; this.setAttribute("file-id", fileId.toString()); From 73693da3d9bb6e5eba70a1904070ddee9276e1e9 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sun, 3 Mar 2024 18:05:10 +0100 Subject: [PATCH 31/97] =?UTF-8?q?Add=20the=20button=20to=20insert=20an=20i?= =?UTF-8?q?mage=E2=80=99s=20thumbnail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Component/Attachment/List.ts | 51 +++++++++++++++---- .../Core/Component/Attachment/List.js | 40 ++++++++++++--- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/ts/WoltLabSuite/Core/Component/Attachment/List.ts b/ts/WoltLabSuite/Core/Component/Attachment/List.ts index 71de360164e..1f0faafaf9f 100644 --- a/ts/WoltLabSuite/Core/Component/Attachment/List.ts +++ b/ts/WoltLabSuite/Core/Component/Attachment/List.ts @@ -1,6 +1,10 @@ import { dispatchToCkeditor } from "../Ckeditor/Event"; import WoltlabCoreFileElement from "../File/woltlab-core-file"; +type FileProcessorData = { + attachmentID: number; +}; + function upload(fileList: HTMLElement, file: WoltlabCoreFileElement, editorId: string): void { const element = document.createElement("li"); element.classList.add("attachment__list__item"); @@ -8,21 +12,29 @@ function upload(fileList: HTMLElement, file: WoltlabCoreFileElement, editorId: s fileList.append(element); void file.ready.then(() => { - element.append(getInsertAttachBbcodeButton(file, editorId)); + const data = file.data; + if (data === undefined) { + // TODO: error handling + return; + } - if (file.isImage()) { - const thumbnail = file.thumbnails.find((thumbnail) => { - return thumbnail.identifier === "tiny"; - }); + element.append(getInsertAttachBbcodeButton((data as FileProcessorData).attachmentID, editorId)); + 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) { + element.append(getInsertThumbnailButton((data as FileProcessorData).attachmentID, url, editorId)); + } } }); } -function getInsertAttachBbcodeButton(file: WoltlabCoreFileElement, editorId: string): HTMLButtonElement { +function getInsertAttachBbcodeButton(attachmentId: number, editorId: string): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.classList.add("button", "small"); @@ -35,8 +47,9 @@ function getInsertAttachBbcodeButton(file: WoltlabCoreFileElement, editorId: str return; } + // TODO: Insert the original image if it is available. dispatchToCkeditor(editor).insertAttachment({ - attachmentId: 123, // TODO: how do we get the id? + attachmentId, url: "", }); }); @@ -44,6 +57,28 @@ function getInsertAttachBbcodeButton(file: WoltlabCoreFileElement, editorId: str return button; } +function getInsertThumbnailButton(attachmentId: number, url: string, editorId: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button", "small"); + button.textContent = "TODO: insert thumbnail"; + + button.addEventListener("click", () => { + const editor = document.getElementById(editorId); + if (editor === null) { + // TODO: error handling + return; + } + + dispatchToCkeditor(editor).insertAttachment({ + attachmentId, + url, + }); + }); + + return button; +} + export function setup(editorId: string): void { const container = document.getElementById(`attachments_${editorId}`); if (container === null) { @@ -68,8 +103,6 @@ export function setup(editorId: string): void { } uploadButton.addEventListener("uploadStart", (event: CustomEvent) => { - // TODO: We need to forward the attachment data from the event once it - // becoems available. upload(fileList!, event.detail, editorId); }); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js index 6e7664e0171..b06d616b2c5 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js @@ -8,18 +8,25 @@ define(["require", "exports", "../Ckeditor/Event"], function (require, exports, element.append(file); fileList.append(element); void file.ready.then(() => { - element.append(getInsertAttachBbcodeButton(file, editorId)); + const data = file.data; + if (data === undefined) { + // TODO: error handling + return; + } + element.append(getInsertAttachBbcodeButton(data.attachmentID, editorId)); if (file.isImage()) { - const thumbnail = file.thumbnails.find((thumbnail) => { - return thumbnail.identifier === "tiny"; - }); + 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) { + element.append(getInsertThumbnailButton(data.attachmentID, url, editorId)); + } } }); } - function getInsertAttachBbcodeButton(file, editorId) { + function getInsertAttachBbcodeButton(attachmentId, editorId) { const button = document.createElement("button"); button.type = "button"; button.classList.add("button", "small"); @@ -30,13 +37,32 @@ define(["require", "exports", "../Ckeditor/Event"], function (require, exports, // TODO: error handling return; } + // TODO: Insert the original image if it is available. (0, Event_1.dispatchToCkeditor)(editor).insertAttachment({ - attachmentId: 123, + attachmentId, url: "", }); }); return button; } + function getInsertThumbnailButton(attachmentId, url, editorId) { + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button", "small"); + button.textContent = "TODO: insert thumbnail"; + button.addEventListener("click", () => { + const editor = document.getElementById(editorId); + if (editor === null) { + // TODO: error handling + return; + } + (0, Event_1.dispatchToCkeditor)(editor).insertAttachment({ + attachmentId, + url, + }); + }); + return button; + } function setup(editorId) { const container = document.getElementById(`attachments_${editorId}`); if (container === null) { @@ -58,8 +84,6 @@ define(["require", "exports", "../Ckeditor/Event"], function (require, exports, uploadButton.insertAdjacentElement("afterend", fileList); } uploadButton.addEventListener("uploadStart", (event) => { - // TODO: We need to forward the attachment data from the event once it - // becoems available. upload(fileList, event.detail, editorId); }); } From ec4ceaa2f10f6a5243d5da5167a88136798e4ce4 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sun, 3 Mar 2024 18:13:09 +0100 Subject: [PATCH 32/97] Add the link to the uploaded file --- ts/WoltLabSuite/Core/Component/Attachment/List.ts | 13 +++++++++---- ts/WoltLabSuite/Core/Component/File/Upload.ts | 3 ++- .../Core/Component/File/woltlab-core-file.ts | 14 +++++++++++++- .../WoltLabSuite/Core/Component/Attachment/List.js | 7 +++---- .../js/WoltLabSuite/Core/Component/File/Upload.js | 2 +- .../Core/Component/File/woltlab-core-file.js | 7 ++++++- .../files/lib/action/FileUploadAction.class.php | 1 + 7 files changed, 35 insertions(+), 12 deletions(-) diff --git a/ts/WoltLabSuite/Core/Component/Attachment/List.ts b/ts/WoltLabSuite/Core/Component/Attachment/List.ts index 1f0faafaf9f..8b2b47b5208 100644 --- a/ts/WoltLabSuite/Core/Component/Attachment/List.ts +++ b/ts/WoltLabSuite/Core/Component/Attachment/List.ts @@ -18,7 +18,13 @@ function upload(fileList: HTMLElement, file: WoltlabCoreFileElement, editorId: s return; } - element.append(getInsertAttachBbcodeButton((data as FileProcessorData).attachmentID, editorId)); + element.append( + getInsertAttachBbcodeButton( + (data as FileProcessorData).attachmentID, + file.isImage() && file.link ? file.link : "", + editorId, + ), + ); if (file.isImage()) { const thumbnail = file.thumbnails.find((thumbnail) => thumbnail.identifier === "tiny"); @@ -34,7 +40,7 @@ function upload(fileList: HTMLElement, file: WoltlabCoreFileElement, editorId: s }); } -function getInsertAttachBbcodeButton(attachmentId: number, editorId: string): HTMLButtonElement { +function getInsertAttachBbcodeButton(attachmentId: number, url: string, editorId: string): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.classList.add("button", "small"); @@ -47,10 +53,9 @@ function getInsertAttachBbcodeButton(attachmentId: number, editorId: string): HT return; } - // TODO: Insert the original image if it is available. dispatchToCkeditor(editor).insertAttachment({ attachmentId, - url: "", + url, }); }); diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 2e66e60f66a..b9d30bb437c 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -19,6 +19,7 @@ export type UploadCompleted = { fileID: number; typeName: string; mimeType: string; + link: string; data: Record; }; @@ -111,7 +112,7 @@ async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, respons } const hasThumbnails = response.endpointThumbnails !== ""; - fileElement.uploadCompleted(response.fileID, response.mimeType, response.data, hasThumbnails); + fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, hasThumbnails); if (hasThumbnails) { await generateThumbnails(fileElement, response.endpointThumbnails); diff --git a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts index 0e3d6f38962..1148cd1c89c 100644 --- a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts +++ b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts @@ -33,6 +33,7 @@ export class WoltlabCoreFileElement extends HTMLElement { #data: Record | undefined = undefined; #filename: string = ""; #fileId: number | undefined = undefined; + #link: string | undefined = undefined; #mimeType: string | undefined = undefined; #state: State = State.Initial; readonly #thumbnails: Thumbnail[] = []; @@ -197,6 +198,10 @@ export class WoltlabCoreFileElement extends HTMLElement { return this.#data; } + get link(): string | undefined { + return this.#link; + } + isImage(): boolean { if (this.mimeType === undefined) { return false; @@ -225,10 +230,17 @@ export class WoltlabCoreFileElement extends HTMLElement { this.#readyReject(); } - uploadCompleted(fileId: number, mimeType: string, data: Record, hasThumbnails: boolean): void { + 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()); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js index b06d616b2c5..241a0a82fdb 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js @@ -13,7 +13,7 @@ define(["require", "exports", "../Ckeditor/Event"], function (require, exports, // TODO: error handling return; } - element.append(getInsertAttachBbcodeButton(data.attachmentID, editorId)); + element.append(getInsertAttachBbcodeButton(data.attachmentID, file.isImage() && file.link ? file.link : "", editorId)); if (file.isImage()) { const thumbnail = file.thumbnails.find((thumbnail) => thumbnail.identifier === "tiny"); if (thumbnail !== undefined) { @@ -26,7 +26,7 @@ define(["require", "exports", "../Ckeditor/Event"], function (require, exports, } }); } - function getInsertAttachBbcodeButton(attachmentId, editorId) { + function getInsertAttachBbcodeButton(attachmentId, url, editorId) { const button = document.createElement("button"); button.type = "button"; button.classList.add("button", "small"); @@ -37,10 +37,9 @@ define(["require", "exports", "../Ckeditor/Event"], function (require, exports, // TODO: error handling return; } - // TODO: Insert the original image if it is available. (0, Event_1.dispatchToCkeditor)(editor).insertAttachment({ attachmentId, - url: "", + url, }); }); return button; 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 6afa68289fe..31bc27703ad 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -69,7 +69,7 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co return; } const hasThumbnails = response.endpointThumbnails !== ""; - fileElement.uploadCompleted(response.fileID, response.mimeType, response.data, hasThumbnails); + fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, hasThumbnails); if (hasThumbnails) { await generateThumbnails(fileElement, response.endpointThumbnails); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js index d47f88472c7..dc7ef5b8096 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/woltlab-core-file.js @@ -21,6 +21,7 @@ define(["require", "exports"], function (require, exports) { #data = undefined; #filename = ""; #fileId = undefined; + #link = undefined; #mimeType = undefined; #state = 0 /* State.Initial */; #thumbnails = []; @@ -156,6 +157,9 @@ define(["require", "exports"], function (require, exports) { get data() { return this.#data; } + get link() { + return this.#link; + } isImage() { if (this.mimeType === undefined) { return false; @@ -178,10 +182,11 @@ define(["require", "exports"], function (require, exports) { this.#rebuildElement(); this.#readyReject(); } - uploadCompleted(fileId, mimeType, data, hasThumbnails) { + uploadCompleted(fileId, mimeType, link, data, hasThumbnails) { if (this.#state === 1 /* State.Uploading */) { this.#data = data; this.#fileId = fileId; + this.#link = link; this.#mimeType = mimeType; this.setAttribute("file-id", fileId.toString()); if (hasThumbnails) { diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index 9e0595ceafe..210c1400e0c 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -149,6 +149,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface 'fileID' => $file->fileID, 'typeName' => $file->typeName, 'mimeType' => $file->mimeType, + 'link' => $file->getLink(), 'data' => $processor->getUploadResponse($file), ]); } From 4bd184420c65047d09ce9a55d85cbd79e4681fb5 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 21 Mar 2024 14:08:57 +0100 Subject: [PATCH 33/97] Migrate the file upload preflight to the new API --- ts/WoltLabSuite/Core/Api/Files/Upload.ts | 34 +++++++ ts/WoltLabSuite/Core/Component/File/Upload.ts | 49 ++++------ .../js/WoltLabSuite/Core/Api/Files/Upload.js | 24 +++++ .../Core/Component/File/Upload.js | 48 +++------- .../files/lib/bootstrap/com.woltlab.wcf.php | 1 + .../core/files/PostUpload.class.php | 95 +++++++++++++++++++ 6 files changed, 184 insertions(+), 67 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Api/Files/Upload.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Upload.js create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/files/PostUpload.class.php diff --git a/ts/WoltLabSuite/Core/Api/Files/Upload.ts b/ts/WoltLabSuite/Core/Api/Files/Upload.ts new file mode 100644 index 00000000000..6125d2b3c71 --- /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, + typeName: string, + context: string, +): Promise> { + const url = new URL(window.WSC_API_URL + "index.php?api/rpc/core/files/upload"); + + const payload = { + filename, + fileSize, + fileHash, + typeName, + 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/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index b9d30bb437c..1636aba3c29 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -2,12 +2,9 @@ import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; import { StatusNotOk } from "WoltLabSuite/Core/Ajax/Error"; import { isPlainObject } from "WoltLabSuite/Core/Core"; import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; +import { upload as filesUpload } from "WoltLabSuite/Core/Api/Files/Upload"; import WoltlabCoreFileElement from "./woltlab-core-file"; -type PreflightResponse = { - endpoints: string[]; -}; - type UploadResponse = | { completed: false } | ({ @@ -46,47 +43,33 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis const event = new CustomEvent("uploadStart", { detail: fileElement }); element.dispatchEvent(event); - let response: PreflightResponse | undefined = undefined; - try { - response = (await prepareRequest(element.dataset.endpoint!) - .post({ - filename: file.name, - fileSize: file.size, - fileHash, - typeName, - context: element.dataset.context, - }) - .fetchAsJson()) as PreflightResponse; - } catch (e) { - if (e instanceof StatusNotOk) { - const body = await e.response.clone().json(); - if (isPlainObject(body) && isPlainObject(body.error)) { - console.log(body); - return; - } else { - throw e; - } - } else { - throw e; - } - } finally { - if (response === undefined) { + const response = await filesUpload(file.name, file.size, fileHash, typeName, element.dataset.context || ""); + if (!response.ok) { + const validationError = response.error.getValidationError(); + if (validationError === undefined) { fileElement.uploadFailed(); + + throw response.error; } + + console.log(validationError); + return; } - const { endpoints } = response; + const { identifier, numberOfChunks } = response.value; - const chunkSize = Math.ceil(file.size / endpoints.length); + const chunkSize = Math.ceil(file.size / numberOfChunks); // TODO: Can we somehow report any meaningful upload progress? - for (let i = 0, length = endpoints.length; i < length; i++) { + for (let i = 0; i < numberOfChunks; i++) { const start = i * chunkSize; const end = start + chunkSize; const chunk = file.slice(start, end); - const endpoint = new URL(endpoints[i]); + // TODO fix the URL + throw new Error("TODO: fix the url"); + const endpoint = new URL(String(i)); const checksum = await getSha256Hash(await chunk.arrayBuffer()); endpoint.searchParams.append("checksum", checksum); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Upload.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Upload.js new file mode 100644 index 00000000000..fa70e0af233 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Upload.js @@ -0,0 +1,24 @@ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.upload = void 0; + async function upload(filename, fileSize, fileHash, typeName, context) { + const url = new URL(window.WSC_API_URL + "index.php?api/rpc/core/files/upload"); + const payload = { + filename, + fileSize, + fileHash, + typeName, + context, + }; + let response; + try { + response = (await (0, Backend_1.prepareRequest)(url).post(payload).fetchAsJson()); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)(response); + } + exports.upload = upload; +}); 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 31bc27703ad..bf96359f6cb 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -1,4 +1,4 @@ -define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Ajax/Error", "WoltLabSuite/Core/Core", "WoltLabSuite/Core/Helper/Selector"], function (require, exports, Backend_1, Error_1, Core_1, Selector_1) { +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload"], function (require, exports, Backend_1, Selector_1, Upload_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; @@ -9,46 +9,26 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co fileElement.dataset.filename = file.name; const event = new CustomEvent("uploadStart", { detail: fileElement }); element.dispatchEvent(event); - let response = undefined; - try { - response = (await (0, Backend_1.prepareRequest)(element.dataset.endpoint) - .post({ - filename: file.name, - fileSize: file.size, - fileHash, - typeName, - context: element.dataset.context, - }) - .fetchAsJson()); - } - catch (e) { - if (e instanceof Error_1.StatusNotOk) { - const body = await e.response.clone().json(); - if ((0, Core_1.isPlainObject)(body) && (0, Core_1.isPlainObject)(body.error)) { - console.log(body); - return; - } - else { - throw e; - } - } - else { - throw e; - } - } - finally { - if (response === undefined) { + const response = await (0, Upload_1.upload)(file.name, file.size, fileHash, typeName, element.dataset.context || ""); + if (!response.ok) { + const validationError = response.error.getValidationError(); + if (validationError === undefined) { fileElement.uploadFailed(); + throw response.error; } + console.log(validationError); + return; } - const { endpoints } = response; - const chunkSize = Math.ceil(file.size / endpoints.length); + 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, length = endpoints.length; i < length; i++) { + for (let i = 0; i < numberOfChunks; i++) { const start = i * chunkSize; const end = start + chunkSize; const chunk = file.slice(start, end); - const endpoint = new URL(endpoints[i]); + // TODO fix the URL + throw new Error("TODO: fix the url"); + const endpoint = new URL(String(i)); const checksum = await getSha256Hash(await chunk.arrayBuffer()); endpoint.searchParams.append("checksum", checksum); let response; diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index 1ec5b028ef3..3255fe34ca3 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -116,6 +116,7 @@ static function (\wcf\event\acp\dashboard\box\BoxCollecting $event) { $eventHandler->register( \wcf\event\endpoint\ControllerCollecting::class, static function (\wcf\event\endpoint\ControllerCollecting $event) { + $event->register(new \wcf\system\endpoint\controller\core\files\PostUpload); $event->register(new \wcf\system\endpoint\controller\core\messages\GetMentionSuggestions); $event->register(new \wcf\system\endpoint\controller\core\sessions\DeleteSession); } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/PostUpload.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/PostUpload.class.php new file mode 100644 index 00000000000..39e2d62acc2 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/PostUpload.class.php @@ -0,0 +1,95 @@ +forTypeName($parameters->typeName); + if ($fileProcessor === null) { + throw new UserInputException('typeName', 'unknown'); + } + + try { + $decodedContext = JSON::decode($parameters->context); + } catch (SystemException) { + throw new UserInputException('context', 'invalid'); + } + + $validationResult = $fileProcessor->acceptUpload($parameters->filename, $parameters->fileSize, $decodedContext); + if (!$validationResult->ok()) { + throw new UserInputException('filename', $validationResult->toString()); + } + + $numberOfChunks = FileTemporary::getNumberOfChunks($parameters->fileSize); + if ($numberOfChunks > FileTemporary::MAX_CHUNK_COUNT) { + throw new UserInputException('fileSize', 'tooLarge'); + } + + $fileTemporary = $this->createTemporaryFile($parameters, $numberOfChunks); + + return new JsonResponse([ + 'identifier' => $fileTemporary->identifier, + 'numberOfChunks' => $numberOfChunks, + ]); + } + + private function createTemporaryFile(PostUploadParameters $parameters, int $numberOfChunks): FileTemporary + { + $identifier = \bin2hex(\random_bytes(20)); + + $action = new FileTemporaryAction([], 'create', [ + 'data' => [ + 'identifier' => $identifier, + 'time' => \TIME_NOW, + 'filename' => $parameters->filename, + 'fileSize' => $parameters->fileSize, + 'fileHash' => $parameters->fileHash, + 'typeName' => $parameters->typeName, + 'context' => $parameters->context, + 'chunks' => \str_repeat('0', $numberOfChunks), + ], + ]); + + return $action->executeAction()['returnValues']; + } +} + +/** @internal */ +final class PostUploadParameters +{ + public function __construct( + /** @var non-empty-string */ + public readonly string $filename, + + /** @var positive-int **/ + public readonly int $fileSize, + + /** @var non-empty-string */ + public readonly string $fileHash, + + /** @var non-empty-string */ + public readonly string $typeName, + + /** @var non-empty-string */ + public readonly string $context, + ) { + } +} From 302725fe6bf6b9a12f182b27f449706d599916c2 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 21 Mar 2024 18:36:14 +0100 Subject: [PATCH 34/97] Remove the old controller for the preflight request --- .../FileUploadPreflightAction.class.php | 108 ------------------ .../file/processor/FileProcessor.class.php | 6 - 2 files changed, 114 deletions(-) delete mode 100644 wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php diff --git a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php b/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php deleted file mode 100644 index b8ac8853d73..00000000000 --- a/wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php +++ /dev/null @@ -1,108 +0,0 @@ -getParsedBody(), - <<<'EOT' - array { - filename: non-empty-string, - fileSize: positive-int, - fileHash: non-empty-string, - typeName: non-empty-string, - context: non-empty-string, - } - EOT, - ); - - $fileProcessor = FileProcessor::getInstance()->forTypeName($parameters['typeName']); - if ($fileProcessor === null) { - // 400 Bad Request - return new JsonResponse([ - 'typeName' => 'unknown', - ], 400); - } - - try { - $decodedContext = JSON::decode($parameters['context']); - } catch (SystemException) { - // 400 Bad Request - return new JsonResponse([ - 'context' => 'invalid', - ], 400); - } - - $validationResult = $fileProcessor->acceptUpload($parameters['filename'], $parameters['fileSize'], $decodedContext); - if (!$validationResult->ok()) { - // 403 Permission Denied - return new JsonResponse([ - 'error' => [ - 'type' => $validationResult->toString(), - 'message' => $validationResult->toErrorMessage(), - ], - ], 403); - } - - $numberOfChunks = FileTemporary::getNumberOfChunks($parameters['fileSize']); - if ($numberOfChunks > FileTemporary::MAX_CHUNK_COUNT) { - // 413 Content Too Large - return new EmptyResponse(413); - } - - $fileTemporary = $this->createTemporaryFile($parameters, $numberOfChunks); - - $endpoints = []; - for ($i = 0; $i < $numberOfChunks; $i++) { - $endpoints[] = LinkHandler::getInstance()->getControllerLink( - FileUploadAction::class, - [ - 'identifier' => $fileTemporary->identifier, - 'sequenceNo' => $i, - ] - ); - } - - return new JsonResponse([ - 'endpoints' => $endpoints, - ]); - } - - private function createTemporaryFile(array $parameters, int $numberOfChunks): FileTemporary - { - $identifier = \bin2hex(\random_bytes(20)); - - $action = new FileTemporaryAction([], 'create', [ - 'data' => [ - 'identifier' => $identifier, - 'time' => \TIME_NOW, - 'filename' => $parameters['filename'], - 'fileSize' => $parameters['fileSize'], - 'fileHash' => $parameters['fileHash'], - 'typeName' => $parameters['typeName'], - 'context' => $parameters['context'], - 'chunks' => \str_repeat('0', $numberOfChunks), - ], - ]); - - return $action->executeAction()['returnValues']; - } -} diff --git a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php index a3a00e01035..b639589b566 100644 --- a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php @@ -2,7 +2,6 @@ namespace wcf\system\file\processor; -use wcf\action\FileUploadPreflightAction; use wcf\data\file\File; use wcf\data\file\thumbnail\FileThumbnail; use wcf\data\file\thumbnail\FileThumbnailEditor; @@ -11,7 +10,6 @@ use wcf\system\file\processor\event\FileProcessorCollecting; use wcf\system\image\adapter\ImageAdapter; use wcf\system\image\ImageHandler; -use wcf\system\request\LinkHandler; use wcf\system\SingletonFactory; use wcf\util\FileUtil; use wcf\util\JSON; @@ -45,8 +43,6 @@ public function forTypeName(string $typeName): ?IFileProcessor public function getHtmlElement(IFileProcessor $fileProcessor, array $context): string { - $endpoint = LinkHandler::getInstance()->getControllerLink(FileUploadPreflightAction::class); - $allowedFileExtensions = $fileProcessor->getAllowedFileExtensions($context); if (\in_array('*', $allowedFileExtensions)) { $allowedFileExtensions = ''; @@ -63,13 +59,11 @@ public function getHtmlElement(IFileProcessor $fileProcessor, array $context): s return \sprintf( <<<'HTML' HTML, - StringUtil::encodeHTML($endpoint), StringUtil::encodeHTML($fileProcessor->getTypeName()), StringUtil::encodeHTML(JSON::encode($context)), StringUtil::encodeHTML($allowedFileExtensions), From f2fe03eb8e5f97756a034898a6ae0661d77c054e Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Fri, 22 Mar 2024 17:57:23 +0100 Subject: [PATCH 35/97] Migrate the chunk upload to the new API --- ts/WoltLabSuite/Core/Ajax/Backend.ts | 16 +++- ts/WoltLabSuite/Core/Api/Files/Chunk/Chunk.ts | 37 ++++++++ ts/WoltLabSuite/Core/Component/File/Upload.ts | 47 +++-------- .../js/WoltLabSuite/Core/Ajax/Backend.js | 13 ++- .../Core/Api/Files/Chunk/Chunk.js | 20 +++++ .../Core/Component/File/Upload.js | 27 ++---- .../files/lib/bootstrap/com.woltlab.wcf.php | 1 + .../core/files/upload/PostChunk.class.php} | 84 ++++++++----------- 8 files changed, 133 insertions(+), 112 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Api/Files/Chunk/Chunk.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Chunk/Chunk.js rename wcfsetup/install/files/lib/{action/FileUploadAction.class.php => system/endpoint/controller/core/files/upload/PostChunk.class.php} (59%) diff --git a/ts/WoltLabSuite/Core/Ajax/Backend.ts b/ts/WoltLabSuite/Core/Ajax/Backend.ts index ad3fa8cdd97..46d9e8779b3 100644 --- a/ts/WoltLabSuite/Core/Ajax/Backend.ts +++ b/ts/WoltLabSuite/Core/Ajax/Backend.ts @@ -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", 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..84dd41ea9f7 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Files/Chunk/Chunk.ts @@ -0,0 +1,37 @@ +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../../Result"; + +export type Response = + | { + completed: false; + } + | { + completed: true; + generateThumbnails: boolean; + fileID: number; + typeName: string; + mimeType: string; + link: string; + data: Record; + }; + +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/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 1636aba3c29..c1b62d2d72a 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -1,24 +1,8 @@ import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; -import { StatusNotOk } from "WoltLabSuite/Core/Ajax/Error"; -import { isPlainObject } from "WoltLabSuite/Core/Core"; import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; import { upload as filesUpload } from "WoltLabSuite/Core/Api/Files/Upload"; import WoltlabCoreFileElement from "./woltlab-core-file"; - -type UploadResponse = - | { completed: false } - | ({ - completed: true; - } & UploadCompleted); - -export type UploadCompleted = { - endpointThumbnails: string; - fileID: number; - typeName: string; - mimeType: string; - link: string; - data: Record; -}; +import { Response as UploadChunkResponse, uploadChunk } from "WoltLabSuite/Core/Api/Files/Chunk/Chunk"; export type ThumbnailsGenerated = { data: GenerateThumbnailsResponse; @@ -67,38 +51,29 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis const end = start + chunkSize; const chunk = file.slice(start, end); - // TODO fix the URL - throw new Error("TODO: fix the url"); - const endpoint = new URL(String(i)); - const checksum = await getSha256Hash(await chunk.arrayBuffer()); - endpoint.searchParams.append("checksum", checksum); - - let response: UploadResponse; - try { - response = (await prepareRequest(endpoint.toString()).post(chunk).fetchAsJson()) as UploadResponse; - } catch (e) { - // TODO: Handle errors - console.error(e); + const response = await uploadChunk(identifier, i, checksum, chunk); + if (!response.ok) { fileElement.uploadFailed(); - throw e; + + throw response.error; } - await chunkUploadCompleted(fileElement, response); + await chunkUploadCompleted(fileElement, response.value); } } -async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, response: UploadResponse): Promise { +async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, response: UploadChunkResponse): Promise { if (!response.completed) { return; } - const hasThumbnails = response.endpointThumbnails !== ""; - fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, hasThumbnails); + fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, response.generateThumbnails); - if (hasThumbnails) { - await generateThumbnails(fileElement, response.endpointThumbnails); + if (response.generateThumbnails) { + throw new Error("TODO: endpoint to generate thumbnails"); + await generateThumbnails(fileElement, "todo"); } } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js index 46f8132f269..e423cf4fa23 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js @@ -29,6 +29,7 @@ define(["require", "exports", "tslib", "./Status", "./Error", "../Core"], functi let ignoreConnectionErrors = false; window.addEventListener("beforeunload", () => (ignoreConnectionErrors = true)); class BackendRequest { + #headers = new Map(); #url; #type; #payload; @@ -50,6 +51,10 @@ define(["require", "exports", "tslib", "./Status", "./Error", "../Core"], functi this.#showLoadingIndicator = false; return this; } + withHeader(key, value) { + this.#headers.set(key, value); + return this; + } allowCaching() { this.#allowCaching = true; return this; @@ -82,11 +87,11 @@ define(["require", "exports", "tslib", "./Status", "./Error", "../Core"], functi } async #fetch(requestOptions = {}) { (0, Error_1.registerGlobalRejectionHandler)(); + this.#headers.set("X-Requested-With", "XMLHttpRequest"); + this.#headers.set("X-XSRF-TOKEN", (0, Core_1.getXsrfToken)()); + const headers = Object.fromEntries(this.#headers); const init = (0, Core_1.extend)({ - headers: { - "X-Requested-With": "XMLHttpRequest", - "X-XSRF-TOKEN": (0, Core_1.getXsrfToken)(), - }, + headers, mode: "same-origin", credentials: "same-origin", cache: this.#allowCaching ? "default" : "no-store", diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Chunk/Chunk.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Chunk/Chunk.js new file mode 100644 index 00000000000..26d78260afe --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Chunk/Chunk.js @@ -0,0 +1,20 @@ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.uploadChunk = void 0; + async function uploadChunk(identifier, sequenceNo, checksum, payload) { + const url = new URL(`${window.WSC_API_URL}index.php?api/rpc/core/files/upload/${identifier}/chunk/${sequenceNo}`); + let response; + try { + response = (await (0, Backend_1.prepareRequest)(url) + .post(payload) + .withHeader("chunk-checksum-sha256", checksum) + .fetchAsJson()); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)(response); + } + exports.uploadChunk = uploadChunk; +}); 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 bf96359f6cb..3f73cf85df3 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -1,4 +1,4 @@ -define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload"], function (require, exports, Backend_1, Selector_1, Upload_1) { +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk"], function (require, exports, Backend_1, Selector_1, Upload_1, Chunk_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; @@ -26,32 +26,23 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co const start = i * chunkSize; const end = start + chunkSize; const chunk = file.slice(start, end); - // TODO fix the URL - throw new Error("TODO: fix the url"); - const endpoint = new URL(String(i)); const checksum = await getSha256Hash(await chunk.arrayBuffer()); - endpoint.searchParams.append("checksum", checksum); - let response; - try { - response = (await (0, Backend_1.prepareRequest)(endpoint.toString()).post(chunk).fetchAsJson()); - } - catch (e) { - // TODO: Handle errors - console.error(e); + const response = await (0, Chunk_1.uploadChunk)(identifier, i, checksum, chunk); + if (!response.ok) { fileElement.uploadFailed(); - throw e; + throw response.error; } - await chunkUploadCompleted(fileElement, response); + await chunkUploadCompleted(fileElement, response.value); } } async function chunkUploadCompleted(fileElement, response) { if (!response.completed) { return; } - const hasThumbnails = response.endpointThumbnails !== ""; - fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, hasThumbnails); - if (hasThumbnails) { - await generateThumbnails(fileElement, response.endpointThumbnails); + fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, response.generateThumbnails); + if (response.generateThumbnails) { + throw new Error("TODO: endpoint to generate thumbnails"); + await generateThumbnails(fileElement, "todo"); } } async function generateThumbnails(fileElement, endpoint) { diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index 3255fe34ca3..1a773ae12fd 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -117,6 +117,7 @@ static function (\wcf\event\acp\dashboard\box\BoxCollecting $event) { \wcf\event\endpoint\ControllerCollecting::class, static function (\wcf\event\endpoint\ControllerCollecting $event) { $event->register(new \wcf\system\endpoint\controller\core\files\PostUpload); + $event->register(new \wcf\system\endpoint\controller\core\files\upload\PostChunk); $event->register(new \wcf\system\endpoint\controller\core\messages\GetMentionSuggestions); $event->register(new \wcf\system\endpoint\controller\core\sessions\DeleteSession); } diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/PostChunk.class.php similarity index 59% rename from wcfsetup/install/files/lib/action/FileUploadAction.class.php rename to wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/PostChunk.class.php index 210c1400e0c..41ea4ce3725 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/PostChunk.class.php @@ -1,21 +1,20 @@ getQueryParams(), - <<<'EOT' - array { - checksum: non-empty-string, - identifier: non-empty-string, - sequenceNo: int, - } - EOT, - ); - - $fileTemporary = new FileTemporary($parameters['identifier']); + $checksum = \current($request->getHeader('chunk-checksum-sha256')); + if ($checksum === false) { + throw new UserInputException('chunk-checksum-sha256'); + } + + $identifier = $variables['identifier']; + $sequenceNo = $variables['sequenceNo']; + + $fileTemporary = new FileTemporary($identifier); if (!$fileTemporary->identifier) { - // TODO: Proper error message - throw new IllegalLinkException(); + throw new UserInputException('identifier'); } // Check if this is a valid sequence no. - if ($parameters['sequenceNo'] >= $fileTemporary->getChunkCount()) { - // TODO: Proper error message - throw new IllegalLinkException(); + if ($sequenceNo >= $fileTemporary->getChunkCount()) { + throw new UserInputException('sequenceNo', 'outOfRange'); } // Check if this chunk has already been written. - if ($fileTemporary->hasChunk($parameters['sequenceNo'])) { - // 409 Conflict - return new EmptyResponse(409); + if ($fileTemporary->hasChunk($sequenceNo)) { + throw new UserInputException('sequenceNo', 'alreadyExists'); } // Validate the chunk size. @@ -60,8 +52,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $stream = $request->getBody(); $receivedSize = $stream->getSize(); if ($receivedSize !== null && $receivedSize > $chunkSize) { - // 413 Content Too Large - return new EmptyResponse(413); + throw new UserInputException('payload', 'tooLarge'); } $tmpPath = $fileTemporary->getPath(); @@ -69,9 +60,9 @@ public function handle(ServerRequestInterface $request): ResponseInterface \mkdir($tmpPath, recursive: true); } - $file = new IoFile($tmpPath . $fileTemporary->getFilename(), 'cb+'); + $file = new File($tmpPath . $fileTemporary->getFilename(), 'cb+'); $file->lock(\LOCK_EX); - $file->seek($parameters['sequenceNo'] * $chunkSize); + $file->seek($sequenceNo * $chunkSize); // Check if the checksum matches the received data. $ctx = \hash_init('sha256'); @@ -83,8 +74,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $total += \strlen($chunk); if ($total > $chunkSize) { - // 413 Content Too Large - return new EmptyResponse(413); + throw new UserInputException('file', 'exceedsFileSize'); } \hash_update($ctx, $chunk); @@ -95,14 +85,13 @@ public function handle(ServerRequestInterface $request): ResponseInterface $result = \hash_final($ctx); - if ($result !== $parameters['checksum']) { - // TODO: Proper error message - throw new IllegalLinkException(); + if ($result !== $checksum) { + throw new UserInputException('payload', 'checksum'); } // Mark the chunk as written. $chunks = $fileTemporary->chunks; - $chunks[$parameters['sequenceNo']] = '1'; + $chunks[$sequenceNo] = '1'; (new FileTemporaryEditor($fileTemporary))->update([ 'chunks' => $chunks, ]); @@ -112,8 +101,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface // Check if the final result matches the expected checksum. $checksum = \hash_file('sha256', $tmpPath . $fileTemporary->getFilename()); if ($checksum !== $fileTemporary->fileHash) { - // TODO: Proper error message - throw new IllegalLinkException(); + throw new UserInputException('file', 'checksum'); } $file = FileEditor::createFromTemporary($fileTemporary); @@ -130,22 +118,18 @@ public function handle(ServerRequestInterface $request): ResponseInterface $processor->adopt($file, $context); - $endpointThumbnails = ''; + $generateThumbnails = false; if ($file->isImage()) { $thumbnailFormats = $processor->getThumbnailFormats(); if ($thumbnailFormats !== []) { - // TODO: Endpoint to generate thumbnails. - $endpointThumbnails = LinkHandler::getInstance()->getControllerLink( - FileGenerateThumbnailsAction::class, - ['id' => $file->fileID], - ); + $generateThumbnails = true; } } // TODO: This is just debug code. return new JsonResponse([ 'completed' => true, - 'endpointThumbnails' => $endpointThumbnails, + 'generateThumbnails' => $generateThumbnails, 'fileID' => $file->fileID, 'typeName' => $file->typeName, 'mimeType' => $file->mimeType, From 542134b63825bfff01f18fffad60c944368c7237 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Fri, 22 Mar 2024 18:11:37 +0100 Subject: [PATCH 36/97] Migrate the generation of thumbnails to the new API --- .../Core/Api/Files/GenerateThumbnails.ts | 21 +++++++++++++ ts/WoltLabSuite/Core/Api/Files/Upload.ts | 2 +- ts/WoltLabSuite/Core/Component/File/Upload.ts | 27 +++++------------ .../Core/Api/Files/GenerateThumbnails.js | 17 +++++++++++ .../js/WoltLabSuite/Core/Api/Files/Upload.js | 2 +- .../Core/Component/File/Upload.js | 26 +++++----------- .../files/lib/bootstrap/com.woltlab.wcf.php | 1 + .../files/PostGenerateThumbnails.class.php} | 30 +++++++------------ 8 files changed, 65 insertions(+), 61 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/GenerateThumbnails.js rename wcfsetup/install/files/lib/{action/FileGenerateThumbnailsAction.class.php => system/endpoint/controller/core/files/PostGenerateThumbnails.class.php} (59%) 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 index 6125d2b3c71..2ba066b5778 100644 --- a/ts/WoltLabSuite/Core/Api/Files/Upload.ts +++ b/ts/WoltLabSuite/Core/Api/Files/Upload.ts @@ -13,7 +13,7 @@ export async function upload( typeName: string, context: string, ): Promise> { - const url = new URL(window.WSC_API_URL + "index.php?api/rpc/core/files/upload"); + const url = new URL(`${window.WSC_API_URL}index.php?api/rpc/core/files/upload`); const payload = { filename, diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index c1b62d2d72a..0916346a1b4 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -3,6 +3,7 @@ import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; import { upload as filesUpload } from "WoltLabSuite/Core/Api/Files/Upload"; import WoltlabCoreFileElement from "./woltlab-core-file"; import { Response as UploadChunkResponse, uploadChunk } from "WoltLabSuite/Core/Api/Files/Chunk/Chunk"; +import { generateThumbnails } from "WoltLabSuite/Core/Api/Files/GenerateThumbnails"; export type ThumbnailsGenerated = { data: GenerateThumbnailsResponse; @@ -64,33 +65,19 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis } } -async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, response: UploadChunkResponse): Promise { - if (!response.completed) { +async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, result: UploadChunkResponse): Promise { + if (!result.completed) { return; } - fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, response.generateThumbnails); + fileElement.uploadCompleted(result.fileID, result.mimeType, result.link, result.data, result.generateThumbnails); - if (response.generateThumbnails) { - throw new Error("TODO: endpoint to generate thumbnails"); - await generateThumbnails(fileElement, "todo"); + if (result.generateThumbnails) { + const response = await generateThumbnails(result.fileID); + fileElement.setThumbnails(response.unwrap()); } } -async function generateThumbnails(fileElement: WoltlabCoreFileElement, endpoint: string): Promise { - let response: GenerateThumbnailsResponse; - - try { - response = (await prepareRequest(endpoint).get().fetchAsJson()) as GenerateThumbnailsResponse; - } catch (e) { - // TODO: Handle errors - console.error(e); - throw e; - } - - fileElement.setThumbnails(response); -} - async function getSha256Hash(data: BufferSource): Promise { const buffer = await window.crypto.subtle.digest("SHA-256", data); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/GenerateThumbnails.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/GenerateThumbnails.js new file mode 100644 index 00000000000..8332a8dc7e4 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/GenerateThumbnails.js @@ -0,0 +1,17 @@ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.generateThumbnails = void 0; + async function generateThumbnails(fileID) { + const url = new URL(`${window.WSC_API_URL}index.php?api/rpc/core/files/${fileID}/generatethumbnails`); + let response; + try { + response = (await (0, Backend_1.prepareRequest)(url).post().fetchAsJson()); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)(response); + } + exports.generateThumbnails = generateThumbnails; +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Upload.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Upload.js index fa70e0af233..c4694b416c6 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Upload.js @@ -3,7 +3,7 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], fu Object.defineProperty(exports, "__esModule", { value: true }); exports.upload = void 0; async function upload(filename, fileSize, fileHash, typeName, context) { - const url = new URL(window.WSC_API_URL + "index.php?api/rpc/core/files/upload"); + const url = new URL(`${window.WSC_API_URL}index.php?api/rpc/core/files/upload`); const payload = { filename, fileSize, 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 3f73cf85df3..752856b0be9 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -1,4 +1,4 @@ -define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk"], function (require, exports, Backend_1, Selector_1, Upload_1, Chunk_1) { +define(["require", "exports", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk", "WoltLabSuite/Core/Api/Files/GenerateThumbnails"], function (require, exports, Selector_1, Upload_1, Chunk_1, GenerateThumbnails_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; @@ -35,28 +35,16 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co await chunkUploadCompleted(fileElement, response.value); } } - async function chunkUploadCompleted(fileElement, response) { - if (!response.completed) { + async function chunkUploadCompleted(fileElement, result) { + if (!result.completed) { return; } - fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, response.generateThumbnails); - if (response.generateThumbnails) { - throw new Error("TODO: endpoint to generate thumbnails"); - await generateThumbnails(fileElement, "todo"); + fileElement.uploadCompleted(result.fileID, result.mimeType, result.link, result.data, result.generateThumbnails); + if (result.generateThumbnails) { + const response = await (0, GenerateThumbnails_1.generateThumbnails)(result.fileID); + fileElement.setThumbnails(response.unwrap()); } } - async function generateThumbnails(fileElement, endpoint) { - let response; - try { - response = (await (0, Backend_1.prepareRequest)(endpoint).get().fetchAsJson()); - } - catch (e) { - // TODO: Handle errors - console.error(e); - throw e; - } - fileElement.setThumbnails(response); - } async function getSha256Hash(data) { const buffer = await window.crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(buffer)) diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index 1a773ae12fd..dda6c7d7edc 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -116,6 +116,7 @@ static function (\wcf\event\acp\dashboard\box\BoxCollecting $event) { $eventHandler->register( \wcf\event\endpoint\ControllerCollecting::class, static function (\wcf\event\endpoint\ControllerCollecting $event) { + $event->register(new \wcf\system\endpoint\controller\core\files\PostGenerateThumbnails); $event->register(new \wcf\system\endpoint\controller\core\files\PostUpload); $event->register(new \wcf\system\endpoint\controller\core\files\upload\PostChunk); $event->register(new \wcf\system\endpoint\controller\core\messages\GetMentionSuggestions); diff --git a/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/PostGenerateThumbnails.class.php similarity index 59% rename from wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php rename to wcfsetup/install/files/lib/system/endpoint/controller/core/files/PostGenerateThumbnails.class.php index 33ef4cdcb5d..761cb1f1cec 100644 --- a/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/PostGenerateThumbnails.class.php @@ -1,35 +1,25 @@ getQueryParams(), - <<<'EOT' - array { - id: positive-int, - } - EOT, - ); - - $file = new File($parameters['id']); + $file = new File($variables['id']); if (!$file->fileID) { - throw new IllegalLinkException(); + throw new UserInputException('id'); } FileProcessor::getInstance()->generateThumbnails($file); From 83a5e8486bfc7cb965e1a120a5f245af2603d379 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sun, 24 Mar 2024 16:47:45 +0100 Subject: [PATCH 37/97] Add an API endpoint to delete files --- ts/WoltLabSuite/Core/Ajax/Backend.ts | 2 + ts/WoltLabSuite/Core/Api/Files/DeleteFile.ts | 12 ++++++ .../Core/Component/Attachment/List.ts | 40 +++++++++++++++++++ ts/WoltLabSuite/Core/Component/File/Upload.ts | 1 - .../js/WoltLabSuite/Core/Ajax/Backend.js | 3 ++ .../WoltLabSuite/Core/Api/Files/DeleteFile.js | 15 +++++++ .../Core/Component/Attachment/List.js | 30 +++++++++++++- .../files/lib/bootstrap/com.woltlab.wcf.php | 1 + .../files/lib/data/file/File.class.php | 10 +++++ .../core/files/DeleteFile.class.php | 36 +++++++++++++++++ .../AttachmentFileProcessor.class.php | 7 ++++ .../file/processor/IFileProcessor.class.php | 2 + 12 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Api/Files/DeleteFile.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/DeleteFile.js create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/files/DeleteFile.class.php diff --git a/ts/WoltLabSuite/Core/Ajax/Backend.ts b/ts/WoltLabSuite/Core/Ajax/Backend.ts index 46d9e8779b3..40b6a9cb6f8 100644 --- a/ts/WoltLabSuite/Core/Ajax/Backend.ts +++ b/ts/WoltLabSuite/Core/Ajax/Backend.ts @@ -154,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/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/Component/Attachment/List.ts b/ts/WoltLabSuite/Core/Component/Attachment/List.ts index 8b2b47b5208..bedf2d13a3d 100644 --- a/ts/WoltLabSuite/Core/Component/Attachment/List.ts +++ b/ts/WoltLabSuite/Core/Component/Attachment/List.ts @@ -1,3 +1,4 @@ +import { deleteFile } from "WoltLabSuite/Core/Api/Files/DeleteFile"; import { dispatchToCkeditor } from "../Ckeditor/Event"; import WoltlabCoreFileElement from "../File/woltlab-core-file"; @@ -18,7 +19,14 @@ function upload(fileList: HTMLElement, file: WoltlabCoreFileElement, editorId: s return; } + const fileId = file.fileId; + if (fileId === undefined) { + // TODO: error handling + return; + } + element.append( + getDeleteAttachButton(fileId, (data as FileProcessorData).attachmentID, editorId, element), getInsertAttachBbcodeButton( (data as FileProcessorData).attachmentID, file.isImage() && file.link ? file.link : "", @@ -40,6 +48,38 @@ function upload(fileList: HTMLElement, file: WoltlabCoreFileElement, editorId: s }); } +function getDeleteAttachButton( + fileId: number, + attachmentId: number, + editorId: string, + element: HTMLElement, +): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button", "small"); + button.textContent = "TODO: delete"; + + button.addEventListener("click", () => { + const editor = document.getElementById(editorId); + if (editor === null) { + // TODO: error handling + return; + } + + void deleteFile(fileId).then((result) => { + result.unwrap(); + + dispatchToCkeditor(editor).removeAttachment({ + attachmentId, + }); + + element.remove(); + }); + }); + + return button; +} + function getInsertAttachBbcodeButton(attachmentId: number, url: string, editorId: string): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 0916346a1b4..6369decf747 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -1,4 +1,3 @@ -import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; import { upload as filesUpload } from "WoltLabSuite/Core/Api/Files/Upload"; import WoltlabCoreFileElement from "./woltlab-core-file"; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js index e423cf4fa23..5f4e4a47c32 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js @@ -114,6 +114,9 @@ define(["require", "exports", "tslib", "./Status", "./Error", "../Core"], functi } } } + else if (this.#type === 0 /* RequestType.DELETE */) { + init.method = "DELETE"; + } else { init.method = "GET"; } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/DeleteFile.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/DeleteFile.js new file mode 100644 index 00000000000..6d6c419df76 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/DeleteFile.js @@ -0,0 +1,15 @@ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.deleteFile = void 0; + async function deleteFile(fileId) { + try { + await (0, Backend_1.prepareRequest)(`${window.WSC_API_URL}index.php?api/rpc/core/files/${fileId}`).delete().fetchAsJson(); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)([]); + } + exports.deleteFile = deleteFile; +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js index 241a0a82fdb..d84d83ad825 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js @@ -1,4 +1,4 @@ -define(["require", "exports", "../Ckeditor/Event"], function (require, exports, Event_1) { +define(["require", "exports", "WoltLabSuite/Core/Api/Files/DeleteFile", "../Ckeditor/Event"], function (require, exports, DeleteFile_1, Event_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = void 0; @@ -13,7 +13,12 @@ define(["require", "exports", "../Ckeditor/Event"], function (require, exports, // TODO: error handling return; } - element.append(getInsertAttachBbcodeButton(data.attachmentID, file.isImage() && file.link ? file.link : "", editorId)); + const fileId = file.fileId; + if (fileId === undefined) { + // TODO: error handling + return; + } + element.append(getDeleteAttachButton(fileId, data.attachmentID, editorId, element), getInsertAttachBbcodeButton(data.attachmentID, file.isImage() && file.link ? file.link : "", editorId)); if (file.isImage()) { const thumbnail = file.thumbnails.find((thumbnail) => thumbnail.identifier === "tiny"); if (thumbnail !== undefined) { @@ -26,6 +31,27 @@ define(["require", "exports", "../Ckeditor/Event"], function (require, exports, } }); } + function getDeleteAttachButton(fileId, attachmentId, editorId, element) { + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button", "small"); + button.textContent = "TODO: delete"; + button.addEventListener("click", () => { + const editor = document.getElementById(editorId); + if (editor === null) { + // TODO: error handling + return; + } + void (0, DeleteFile_1.deleteFile)(fileId).then((result) => { + result.unwrap(); + (0, Event_1.dispatchToCkeditor)(editor).removeAttachment({ + attachmentId, + }); + element.remove(); + }); + }); + return button; + } function getInsertAttachBbcodeButton(attachmentId, url, editorId) { const button = document.createElement("button"); button.type = "button"; diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index dda6c7d7edc..c4a44d9d1c5 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -116,6 +116,7 @@ static function (\wcf\event\acp\dashboard\box\BoxCollecting $event) { $eventHandler->register( \wcf\event\endpoint\ControllerCollecting::class, static function (\wcf\event\endpoint\ControllerCollecting $event) { + $event->register(new \wcf\system\endpoint\controller\core\files\DeleteFile); $event->register(new \wcf\system\endpoint\controller\core\files\PostGenerateThumbnails); $event->register(new \wcf\system\endpoint\controller\core\files\PostUpload); $event->register(new \wcf\system\endpoint\controller\core\files\upload\PostChunk); diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index 1c7aa7bbffb..fcba2c03a07 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -67,4 +67,14 @@ public function isImage(): bool default => false, }; } + + public function canDelete(): bool + { + $processor = $this->getProcessor(); + if ($processor === null) { + return true; + } + + return $processor->canDelete($this); + } } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/DeleteFile.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/DeleteFile.class.php new file mode 100644 index 00000000000..9c686ec1d36 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/DeleteFile.class.php @@ -0,0 +1,36 @@ +fileID) { + throw new UserInputException('id'); + } + + if (!$file->canDelete()) { + throw new PermissionDeniedException(); + } + + // TODO: How do we handle the cleanup of files? + $fileAction = new FileAction([$file], 'delete'); + $fileAction->executeAction(); + + return new JsonResponse([]); + } +} diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 3c24055005a..5e954d9fd65 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -97,6 +97,13 @@ static function (string $extension) { return FileProcessorPreflightResult::Passed; } + #[\Override] + public function canDelete(File $file): bool + { + // TODO + return true; + } + #[\Override] public function canDownload(File $file): bool { diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index 3f624c1ad3f..d18a4845986 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -19,6 +19,8 @@ public function adopt(File $file, array $context): void; public function adoptThumbnail(FileThumbnail $thumbnail): void; + public function canDelete(File $file): bool; + public function canDownload(File $file): bool; public function getAllowedFileExtensions(array $context): array; From 9a415820269312e1c61a9abba137412e6cd13510 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 25 Mar 2024 11:42:11 +0100 Subject: [PATCH 38/97] Fix the handling of validation errors --- ts/WoltLabSuite/WebComponent/index.ts | 1 - ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts | 3 +++ .../install/files/js/WoltLabSuite/WebComponent.min.js | 2 +- .../file/processor/AttachmentFileProcessor.class.php | 8 ++++++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/ts/WoltLabSuite/WebComponent/index.ts b/ts/WoltLabSuite/WebComponent/index.ts index ceca19a68b6..3abdba8e2f8 100644 --- a/ts/WoltLabSuite/WebComponent/index.ts +++ b/ts/WoltLabSuite/WebComponent/index.ts @@ -12,7 +12,6 @@ import "./fa-metadata.js"; import "./fa-brand.ts"; import "./fa-icon.ts"; import "./woltlab-core-date-time.ts"; -import "./woltlab-core-file.ts" import "./woltlab-core-file-upload.ts" import "./woltlab-core-loading-indicator.ts"; import "./woltlab-core-notice.ts"; diff --git a/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts b/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts index 413ce7dbf1b..ecd9da278e3 100644 --- a/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts +++ b/ts/WoltLabSuite/WebComponent/woltlab-core-file-upload.ts @@ -30,6 +30,9 @@ }); this.dispatchEvent(uploadEvent); } + + // Reset the selected file. + this.#element.value = ""; }); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js b/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js index f442038ca31..b9a1f2dbb41 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js +++ b/wcfsetup/install/files/js/WoltLabSuite/WebComponent.min.js @@ -60,7 +60,7 @@ Expecting `+Z.join(", ")+", got '"+(this.terminals_[T]||T)+"'":ae="Parse error o time::after { content: " (" attr(title) ")"; } - }`,x.append(D)}m&&(this.#a.dateTime=p.toISOString(),this.#a.title=g.DateAndTime.format(p));let k;if(this.static)k=this.#a.title;else if(Et?k=this.#t(x,0):p.getTime()>a?k=this.#t(x,-1):k=x.map(W=>W.value).join(""):k=g.DateAndTime.format(p)}else k=g.Date.format(p);k=k.charAt(0).toUpperCase()+k.slice(1),this.#a.textContent=k}#t(m,p){return m.map(k=>k.type==="weekday"?g.TodayOrYesterday.format(p,"day"):k.value).join("")}}window.customElements.define("woltlab-core-date-time",q);let S=()=>{document.querySelectorAll("woltlab-core-date-time").forEach(h=>h.refresh(!1))},z,P=()=>{z=window.setInterval(()=>{l(),S()},6e4)};document.addEventListener("DOMContentLoaded",()=>P(),{once:!0}),document.addEventListener("visibilitychange",()=>{document.hidden?window.clearInterval(z):(S(),P())})}{class e extends HTMLElement{#e;constructor(){super(),this.#e=document.createElement("input"),this.#e.type="file",this.#e.addEventListener("change",()=>{let{files:i}=this.#e;if(!(i===null||i.length===0))for(let c of i){let t=new CustomEvent("shouldUpload",{cancelable:!0,detail:c});if(this.dispatchEvent(t),t.defaultPrevented)continue;let a=new CustomEvent("upload",{detail:c});this.dispatchEvent(a)}})}connectedCallback(){let i=this.dataset.fileExtensions||"";i!==""&&(this.#e.accept=i),this.attachShadow({mode:"open"}).append(this.#e);let t=document.createElement("style");t.textContent=` + }`,x.append(D)}m&&(this.#a.dateTime=p.toISOString(),this.#a.title=g.DateAndTime.format(p));let k;if(this.static)k=this.#a.title;else if(Et?k=this.#t(x,0):p.getTime()>a?k=this.#t(x,-1):k=x.map(W=>W.value).join(""):k=g.DateAndTime.format(p)}else k=g.Date.format(p);k=k.charAt(0).toUpperCase()+k.slice(1),this.#a.textContent=k}#t(m,p){return m.map(k=>k.type==="weekday"?g.TodayOrYesterday.format(p,"day"):k.value).join("")}}window.customElements.define("woltlab-core-date-time",q);let S=()=>{document.querySelectorAll("woltlab-core-date-time").forEach(h=>h.refresh(!1))},z,P=()=>{z=window.setInterval(()=>{l(),S()},6e4)};document.addEventListener("DOMContentLoaded",()=>P(),{once:!0}),document.addEventListener("visibilitychange",()=>{document.hidden?window.clearInterval(z):(S(),P())})}{class e extends HTMLElement{#e;constructor(){super(),this.#e=document.createElement("input"),this.#e.type="file",this.#e.addEventListener("change",()=>{let{files:i}=this.#e;if(!(i===null||i.length===0)){for(let c of i){let t=new CustomEvent("shouldUpload",{cancelable:!0,detail:c});if(this.dispatchEvent(t),t.defaultPrevented)continue;let a=new CustomEvent("upload",{detail:c});this.dispatchEvent(a)}this.#e.value=""}})}connectedCallback(){let i=this.dataset.fileExtensions||"";i!==""&&(this.#e.accept=i),this.attachShadow({mode:"open"}).append(this.#e);let t=document.createElement("style");t.textContent=` :host { position: relative; } diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 5e954d9fd65..a3847e976e5 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -100,8 +100,12 @@ static function (string $extension) { #[\Override] public function canDelete(File $file): bool { - // TODO - return true; + $attachment = Attachment::findByFileID($file->fileID); + if ($attachment === null) { + return false; + } + + return $attachment->canDelete(); } #[\Override] From fb7a1897c23759415ab874016ce82a0636e3b0e6 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Wed, 27 Mar 2024 23:28:14 +0100 Subject: [PATCH 39/97] Add support for attachment thumbnail --- .../lib/data/attachment/Attachment.class.php | 52 +++++++++++++------ .../files/lib/data/file/File.class.php | 14 +++++ .../system/bbcode/AttachmentBBCode.class.php | 11 ++-- ...mentMessageEmbeddedObjectHandler.class.php | 45 +++++++++++++++- 4 files changed, 98 insertions(+), 24 deletions(-) diff --git a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php index 25643d4688d..30ae35d4335 100644 --- a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php +++ b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php @@ -235,17 +235,21 @@ final protected function getLocationHelper($location) */ public function getThumbnailLink($size = '') { - $parameters = [ - 'object' => $this, - ]; + $file = $this->getFile(); + if ($file === null) { + return ''; + } - if ($size == 'tiny') { - $parameters['tiny'] = 1; - } elseif ($size == 'thumbnail') { - $parameters['thumbnail'] = 1; + if ($size === '') { + return $file->getLink(); + } + + $thumbnail = $file->getThumbnail($size !== 'tiny' ? '' : $size); + if ($this === null) { + return ''; } - return LinkHandler::getInstance()->getLink('Attachment', $parameters); + return $thumbnail->getLink(); } /** @@ -373,15 +377,33 @@ public function setFile(File $file): void public function __get($name) { $file = $this->getFile(); - if ($file !== null) { - return match ($name) { - 'filename' => $file->filename, - 'filesize' => $file->fileSize, - default => parent::__get($name), - }; + if ($file === null) { + return parent::__get($name); } - return parent::__get($name); + return match ($name) { + 'filename' => $file->filename, + 'filesize' => $file->fileSize, + 'fileType' => $file->mimeType, + 'isImage' => $file->isImage(), + + // TODO: Do we want to cache this data? + 'height' => \getimagesize($file->getPath() . $file->getSourceFilename())[1], + // TODO: Do we want to cache this data? + 'width' => \getimagesize($file->getPath() . $file->getSourceFilename())[0], + + // TODO: This is awful. + 'thumbnailType' => $file->getThumbnail('') ? $file->mimeType : '', + 'thumbnailWidth' => $file->getThumbnail('') ? \getimagesize($file->getThumbnail('')->getPath() . $file->getThumbnail('')->getSourceFilename())[0] : 0, + 'thumbnailHeight' => $file->getThumbnail('') ? \getimagesize($file->getThumbnail('')->getPath() . $file->getThumbnail('')->getSourceFilename())[1] : 0, + + // TODO: This is awful. + 'tinyThumbnailType' => $file->getThumbnail('tiny') ? $file->mimeType : '', + 'tinyThumbnailWidth' => $file->getThumbnail('tiny') ? \getimagesize($file->getThumbnail('tiny')->getPath() . $file->getThumbnail('tiny')->getSourceFilename())[0] : 0, + 'tinyThumbnailHeight' => $file->getThumbnail('tiny') ? \getimagesize($file->getThumbnail('tiny')->getPath() . $file->getThumbnail('tiny')->getSourceFilename())[1] : 0, + + default => parent::__get($name), + }; } public static function findByFileID(int $fileID): ?Attachment diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index fcba2c03a07..455f41b4320 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -4,6 +4,7 @@ use wcf\action\FileDownloadAction; use wcf\data\DatabaseObject; +use wcf\data\file\thumbnail\FileThumbnail; use wcf\system\file\processor\FileProcessor; use wcf\system\file\processor\IFileProcessor; use wcf\system\request\LinkHandler; @@ -23,6 +24,9 @@ */ class File extends DatabaseObject { + /** @var array */ + private array $thumbnails = []; + public function getPath(): string { $folderA = \substr($this->fileHash, 0, 2); @@ -77,4 +81,14 @@ public function canDelete(): bool return $processor->canDelete($this); } + + public function addThumbnail(FileThumbnail $thumbnail): void + { + $this->thumbnails[$thumbnail->identifier] = $thumbnail; + } + + public function getThumbnail(string $identifier): ?FileThumbnail + { + return $this->thumbnails[$identifier] ?? null; + } } diff --git a/wcfsetup/install/files/lib/system/bbcode/AttachmentBBCode.class.php b/wcfsetup/install/files/lib/system/bbcode/AttachmentBBCode.class.php index f8ed9bcbae5..ef4af5bc774 100644 --- a/wcfsetup/install/files/lib/system/bbcode/AttachmentBBCode.class.php +++ b/wcfsetup/install/files/lib/system/bbcode/AttachmentBBCode.class.php @@ -134,13 +134,6 @@ private function showImageAsThumbnail(Attachment $attachment, string $alignment, FontAwesomeIcon::fromValues('magnifying-glass')->toHtml(24), ); - $linkParameters = [ - 'object' => $attachment, - ]; - if ($attachment->hasThumbnail()) { - $linkParameters['thumbnail'] = 1; - } - $class = match ($alignment) { "left" => "messageFloatObjectLeft", "right" => "messageFloatObjectRight", @@ -156,9 +149,11 @@ private function showImageAsThumbnail(Attachment $attachment, string $alignment, $imageClasses .= ' ' . $class; } + $src = $attachment->hasThumbnail() ? $attachment->getThumbnailLink('thumbnail') : $attachment->getLink(); + $imageElement = \sprintf( '', - StringUtil::encodeHTML(LinkHandler::getInstance()->getLink('Attachment', $linkParameters)), + StringUtil::encodeHTML($src), $imageClasses, $attachment->hasThumbnail() ? $attachment->thumbnailWidth : $attachment->width, $attachment->hasThumbnail() ? $attachment->thumbnailHeight : $attachment->height, diff --git a/wcfsetup/install/files/lib/system/message/embedded/object/AttachmentMessageEmbeddedObjectHandler.class.php b/wcfsetup/install/files/lib/system/message/embedded/object/AttachmentMessageEmbeddedObjectHandler.class.php index 218198f9b03..eca8bdf9571 100644 --- a/wcfsetup/install/files/lib/system/message/embedded/object/AttachmentMessageEmbeddedObjectHandler.class.php +++ b/wcfsetup/install/files/lib/system/message/embedded/object/AttachmentMessageEmbeddedObjectHandler.class.php @@ -2,7 +2,10 @@ namespace wcf\system\message\embedded\object; +use wcf\data\attachment\Attachment; use wcf\data\attachment\AttachmentList; +use wcf\data\file\FileList; +use wcf\data\file\thumbnail\FileThumbnailList; use wcf\data\object\type\ObjectTypeCache; use wcf\system\html\input\HtmlInputProcessor; @@ -71,6 +74,46 @@ public function loadObjects(array $objectIDs) } } - return $attachmentList->getObjects(); + $attachments = $attachmentList->getObjects(); + + $this->loadFiles($attachments); + + return $attachments; + } + + /** + * @param Attachment[] $attachments + */ + private function loadFiles(array $attachments): void + { + $fileIDs = []; + foreach ($attachments as $attachment) { + if ($attachment->fileID) { + $fileIDs[] = $attachment->fileID; + } + } + + if ($fileIDs === []) { + return; + } + + $fileList = new FileList(); + $fileList->getConditionBuilder()->add("fileID IN (?)", [$fileIDs]); + $fileList->readObjects(); + $files = $fileList->getObjects(); + + $thumbnailList = new FileThumbnailList(); + $thumbnailList->getConditionBuilder()->add("fileID IN (?)", [$fileIDs]); + $thumbnailList->readObjects(); + foreach ($thumbnailList as $thumbnail) { + $files[$thumbnail->fileID]->addThumbnail($thumbnail); + } + + foreach ($attachments as $attachment) { + $file = $files[$attachment->fileID] ?? null; + if ($file !== null) { + $attachment->setFile($file); + } + } } } From e9de281f3bb02f6fc5f69be88db73a801a81cc9e Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 28 Mar 2024 12:31:58 +0100 Subject: [PATCH 40/97] Always load the file and thumbnails for attachments --- .../data/attachment/AttachmentList.class.php | 38 +++++++++++++++++ ...mentMessageEmbeddedObjectHandler.class.php | 42 +------------------ 2 files changed, 39 insertions(+), 41 deletions(-) diff --git a/wcfsetup/install/files/lib/data/attachment/AttachmentList.class.php b/wcfsetup/install/files/lib/data/attachment/AttachmentList.class.php index 52cb09ac706..dfcc2b7476a 100644 --- a/wcfsetup/install/files/lib/data/attachment/AttachmentList.class.php +++ b/wcfsetup/install/files/lib/data/attachment/AttachmentList.class.php @@ -3,6 +3,8 @@ namespace wcf\data\attachment; use wcf\data\DatabaseObjectList; +use wcf\data\file\FileList; +use wcf\data\file\thumbnail\FileThumbnailList; /** * Represents a list of attachments. @@ -23,4 +25,40 @@ class AttachmentList extends DatabaseObjectList * @inheritDoc */ public $className = Attachment::class; + + #[\Override] + public function readObjects() + { + parent::readObjects(); + + $fileIDs = []; + foreach ($this->objects as $attachment) { + if ($attachment->fileID) { + $fileIDs[] = $attachment->fileID; + } + } + + if ($fileIDs === []) { + return; + } + + $fileList = new FileList(); + $fileList->getConditionBuilder()->add("fileID IN (?)", [$fileIDs]); + $fileList->readObjects(); + $files = $fileList->getObjects(); + + $thumbnailList = new FileThumbnailList(); + $thumbnailList->getConditionBuilder()->add("fileID IN (?)", [$fileIDs]); + $thumbnailList->readObjects(); + foreach ($thumbnailList as $thumbnail) { + $files[$thumbnail->fileID]->addThumbnail($thumbnail); + } + + foreach ($this->objects as $attachment) { + $file = $files[$attachment->fileID] ?? null; + if ($file !== null) { + $attachment->setFile($file); + } + } + } } diff --git a/wcfsetup/install/files/lib/system/message/embedded/object/AttachmentMessageEmbeddedObjectHandler.class.php b/wcfsetup/install/files/lib/system/message/embedded/object/AttachmentMessageEmbeddedObjectHandler.class.php index eca8bdf9571..12f98d8ba16 100644 --- a/wcfsetup/install/files/lib/system/message/embedded/object/AttachmentMessageEmbeddedObjectHandler.class.php +++ b/wcfsetup/install/files/lib/system/message/embedded/object/AttachmentMessageEmbeddedObjectHandler.class.php @@ -74,46 +74,6 @@ public function loadObjects(array $objectIDs) } } - $attachments = $attachmentList->getObjects(); - - $this->loadFiles($attachments); - - return $attachments; - } - - /** - * @param Attachment[] $attachments - */ - private function loadFiles(array $attachments): void - { - $fileIDs = []; - foreach ($attachments as $attachment) { - if ($attachment->fileID) { - $fileIDs[] = $attachment->fileID; - } - } - - if ($fileIDs === []) { - return; - } - - $fileList = new FileList(); - $fileList->getConditionBuilder()->add("fileID IN (?)", [$fileIDs]); - $fileList->readObjects(); - $files = $fileList->getObjects(); - - $thumbnailList = new FileThumbnailList(); - $thumbnailList->getConditionBuilder()->add("fileID IN (?)", [$fileIDs]); - $thumbnailList->readObjects(); - foreach ($thumbnailList as $thumbnail) { - $files[$thumbnail->fileID]->addThumbnail($thumbnail); - } - - foreach ($attachments as $attachment) { - $file = $files[$attachment->fileID] ?? null; - if ($file !== null) { - $attachment->setFile($file); - } - } + return $attachmentList->getObjects(); } } From 31d2fc9011b28aba65ef1828d574bd988adbc5c8 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 28 Mar 2024 12:34:56 +0100 Subject: [PATCH 41/97] Prototype to render files as HTML elements --- .../shared_messageFormAttachments.tpl | 8 ++++-- .../lib/data/attachment/Attachment.class.php | 13 +++++++++ .../data/attachment/AttachmentList.class.php | 9 ++++++ .../files/lib/data/file/File.class.php | 28 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl index 851dd4cb4f6..958dd19742e 100644 --- a/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl +++ b/com.woltlab.wcf/templates/shared_messageFormAttachments.tpl @@ -1,13 +1,17 @@
- {@$attachmentHandler->getHtmlElement()} + {unsafe:$attachmentHandler->getHtmlElement()}
-
+
{lang}wcf.attachment.upload.limits{/lang}
+ {foreach from=$attachmentHandler->getAttachmentList() item=attachment} + {unsafe:$attachment->toHtmlElement()} + {/foreach} + -{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_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 @@ -
    getAttachmentHandler()->getAttachmentList()|count} style="display: none"{/if}{* -*}> - {foreach from=$field->getAttachmentHandler()->getAttachmentList() item=$attachment} -
  • - {if $attachment->tinyThumbnailType} - - {else} - {icon size=64 name=$attachment->getIconName()} - {/if} - -
    - - -
      -
    • - {if $attachment->isImage} - {if $attachment->thumbnailType} -
    • - {/if} -
    • - {else} -
    • - {/if} -
    -
    -
  • - {/foreach} -
-
+
+ {unsafe:$field->getAttachmentHandler()->getHtmlElement()} - +
+ {foreach from=$field->getAttachmentHandler()->getAttachmentList() item=attachment} + {unsafe:$attachment->toHtmlElement()} + {/foreach} +
+ +
+
+
+
+ {lang}wcf.attachment.upload.limits{/lang} +
+
- + +
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'}