Skip to content

Commit 3240741

Browse files
authored
Merge pull request #6393 from WoltLab/62-webp-exif
Improvements to the Upload Pipeline
2 parents 912e965 + 76940f7 commit 3240741

File tree

115 files changed

+7246
-79
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

115 files changed

+7246
-79
lines changed

com.woltlab.wcf/option.xml

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,18 @@
532532
<selectoptions>gd:wcf.acp.option.image_adapter_type.gd
533533
imagick:wcf.acp.option.image_adapter_type.imagick</selectoptions>
534534
</option>
535+
<option name="image_convert_format">
536+
<categoryname>general.system.image</categoryname>
537+
<optiontype>radioButton</optiontype>
538+
<defaultvalue>webp</defaultvalue>
539+
<selectoptions>keep:wcf.acp.option.image_convert_format.keep
540+
webp:wcf.acp.option.image_convert_format.webp</selectoptions>
541+
</option>
542+
<option name="image_strip_exif">
543+
<categoryname>general.system.image</categoryname>
544+
<optiontype>boolean</optiontype>
545+
<defaultvalue>1</defaultvalue>
546+
</option>
535547
<!-- /general.system.image -->
536548
<!-- general.system.search -->
537549
<option name="search_engine">
@@ -922,18 +934,10 @@ redis:wcf.acp.option.cache_source_type.redis</selectoptions>
922934
<option name="attachment_image_autoscale_file_type">
923935
<categoryname>message.attachment.autoscale</categoryname>
924936
<optiontype>radioButton</optiontype>
925-
<defaultvalue>keep</defaultvalue>
937+
<defaultvalue>image/jpeg</defaultvalue>
926938
<selectoptions>keep:wcf.acp.option.attachment_image_autoscale_file_type.keep
927939
image/jpeg:wcf.acp.option.attachment_image_autoscale_file_type.jpeg</selectoptions>
928940
</option>
929-
<option name="attachment_image_autoscale_quality">
930-
<categoryname>message.attachment.autoscale</categoryname>
931-
<optiontype>integer</optiontype>
932-
<defaultvalue>80</defaultvalue>
933-
<minvalue>1</minvalue>
934-
<maxvalue>100</maxvalue>
935-
<suffix>percent</suffix>
936-
</option>
937941
<!-- message.general.edit -->
938942
<option name="module_edit_history">
939943
<categoryname>message.general.edit</categoryname>
@@ -1635,5 +1639,7 @@ DESC:wcf.global.sortOrder.descending</selectoptions>
16351639
<option name="message_sidebar_enable_articles"/>
16361640
<category name="security.blacklist"/>
16371641
<category name="security.blacklist.custom"/>
1642+
1643+
<option name="attachment_image_autoscale_quality"/>
16381644
</delete>
16391645
</data>

com.woltlab.wcf/package.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,6 @@
5656
<instruction type="database" run="standalone">acp/database/update_com.woltlab.wcf_62_step1.php</instruction>
5757
<instruction type="script">acp/update_com.woltlab.wcf_6.2_contactOptions.php</instruction>
5858
<instruction type="database" run="standalone">acp/database/update_com.woltlab.wcf_62_step2.php</instruction>
59+
<instruction type="script">acp/update_com.woltlab.wcf_6.2_option.php</instruction>
5960
-->
6061
</package>

constants.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,7 @@
201201
\define("ATTACHMENT_IMAGE_AUTOSCALE", 1);
202202
\define("ATTACHMENT_IMAGE_AUTOSCALE_MAX_WIDTH", 1024);
203203
\define("ATTACHMENT_IMAGE_AUTOSCALE_MAX_HEIGHT", 1024);
204-
\define("ATTACHMENT_IMAGE_AUTOSCALE_FILE_TYPE", '');
205-
\define("ATTACHMENT_IMAGE_AUTOSCALE_QUALITY", 80);
204+
\define("ATTACHMENT_IMAGE_AUTOSCALE_FILE_TYPE", 'image/jpeg');
206205
\define('LOG_MISSING_LANGUAGE_ITEMS', 0);
207206
\define('PRUNE_IP_ADDRESS', 30);
208207
\define('BREADCRUMBS_HOME_USE_PAGE_TITLE', 1);
@@ -228,3 +227,5 @@
228227
\define('SERVICE_WORKER_PRIVATE_KEY', '');
229228
\define('SERVICE_WORKER_PUBLIC_KEY', '');
230229
\define('RECAPTCHA_PRIVATEKEY_V3', '');
230+
\define('IMAGE_CONVERT_FORMAT', 'webp');
231+
\define('IMAGE_STRIP_EXIF', 1);

phpstan-ambient.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,5 @@ parameters:
230230
- SERVICE_WORKER_PRIVATE_KEY
231231
- SERVICE_WORKER_PUBLIC_KEY
232232
- RECAPTCHA_PRIVATEKEY_V3
233+
- IMAGE_CONVERT_FORMAT
234+
- IMAGE_STRIP_EXIF

ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ type Thumbnail = {
55
identifier: string;
66
link: string;
77
};
8-
type Response = Thumbnail[];
8+
type Response = {
9+
filename: string;
10+
fileSize: number;
11+
mimeType: string;
12+
thumbnails: Thumbnail[];
13+
};
914

1015
export async function generateThumbnails(fileID: number): Promise<ApiResult<Response>> {
1116
const url = new URL(`${window.WSC_RPC_API_URL}core/files/${fileID}/generate-thumbnails`);

ts/WoltLabSuite/Core/Api/Files/Upload.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
22
import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result";
3+
import type { Exif } from "WoltLabSuite/Core/Image/ExifUtil";
34

45
type Response = {
56
identifier: string;
@@ -12,15 +13,25 @@ export async function upload(
1213
fileHash: string,
1314
objectType: string,
1415
context: string,
16+
exifBytes: Exif | null = null,
1517
): Promise<ApiResult<Response>> {
1618
const url = new URL(`${window.WSC_RPC_API_URL}core/files/upload`);
1719

20+
let exifData: string | null = null;
21+
if (exifBytes !== null) {
22+
exifData = "";
23+
for (let i = 0, length = exifBytes.length; i < length; i++) {
24+
exifData += exifBytes[i].toString(16).padStart(2, "0");
25+
}
26+
}
27+
1828
const payload = {
1929
filename,
2030
fileSize,
2131
fileHash,
2232
objectType,
2333
context,
34+
exifData,
2435
};
2536

2637
let response: Response;

ts/WoltLabSuite/Core/Component/Attachment/Entry.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
insertFileInformation,
1010
removeUploadProgress,
1111
trackUploadProgress,
12+
updateFileInformation,
1213
} from "WoltLabSuite/Core/Component/File/Helper";
1314

1415
type FileProcessorData = {
@@ -176,6 +177,10 @@ export function createAttachmentFromFile(file: WoltlabCoreFileElement, editor: H
176177

177178
insertFileInformation(element, file);
178179

180+
file.addEventListener("file:update-data", () => {
181+
updateFileInformation(element, file);
182+
});
183+
179184
void file.ready
180185
.then(() => {
181186
fileInitializationCompleted(element, file, editor);

ts/WoltLabSuite/Core/Component/File/Helper.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,11 @@ export function insertFileInformation(container: HTMLElement, file: WoltlabCoreF
103103

104104
container.append(fileWrapper, filename, fileSize);
105105
}
106+
107+
export function updateFileInformation(container: HTMLElement, file: WoltlabCoreFileElement): void {
108+
const filename = container.querySelector(".fileList__item__filename")!;
109+
filename.textContent = file.filename || file.dataset.filename!;
110+
111+
const fileSize = container.querySelector(".fileList__item__fileSize")!;
112+
fileSize.textContent = formatFilesize(file.fileSize || parseInt(file.dataset.fileSize!));
113+
}

ts/WoltLabSuite/Core/Component/File/Upload.ts

Lines changed: 72 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { innerError } from "WoltLabSuite/Core/Dom/Util";
1313
import { getPhrase } from "WoltLabSuite/Core/Language";
1414
import { createSHA256 } from "hash-wasm";
1515
import { cropImage, CropperConfiguration } from "WoltLabSuite/Core/Component/Image/Cropper";
16+
import { Exif, getExifBytesFromJpeg, getExifBytesFromWebP } from "WoltLabSuite/Core/Image/ExifUtil";
1617

1718
export type CkeditorDropEvent = {
1819
file: File;
@@ -44,6 +45,7 @@ async function upload(
4445
element: WoltlabCoreFileUploadElement,
4546
file: File,
4647
fileHash: string,
48+
exifData: Exif | null,
4749
): Promise<ResponseCompleted | undefined> {
4850
const objectType = element.dataset.objectType!;
4951

@@ -54,7 +56,14 @@ async function upload(
5456
const event = new CustomEvent<WoltlabCoreFileElement>("uploadStart", { detail: fileElement });
5557
element.dispatchEvent(event);
5658

57-
const response = await filesUpload(file.name, file.size, fileHash, objectType, element.dataset.context || "");
59+
const response = await filesUpload(
60+
file.name,
61+
file.size,
62+
fileHash,
63+
objectType,
64+
element.dataset.context || "",
65+
exifData,
66+
);
5867
if (!response.ok) {
5968
const validationError = response.error.getValidationError();
6069
if (validationError === undefined) {
@@ -118,8 +127,9 @@ async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, result:
118127
fileElement.uploadCompleted(result.fileID, result.mimeType, result.link, result.data, result.generateThumbnails);
119128

120129
if (result.generateThumbnails) {
121-
const response = await generateThumbnails(result.fileID);
122-
fileElement.setThumbnails(response.unwrap());
130+
const { filename, fileSize, mimeType, thumbnails } = (await generateThumbnails(result.fileID)).unwrap();
131+
fileElement.setThumbnails(thumbnails);
132+
fileElement.updateFileData(filename, fileSize, mimeType);
123133
}
124134
}
125135

@@ -163,7 +173,7 @@ async function resizeImage(element: WoltlabCoreFileUploadElement, file: File): P
163173
const resizeConfiguration = JSON.parse(element.dataset.resizeConfiguration!) as ResizeConfiguration;
164174

165175
const resizer = new ImageResizer();
166-
const { image, exif } = await resizer.loadFile(file);
176+
const { image } = await resizer.loadFile(file);
167177

168178
const maxHeight = resizeConfiguration.maxHeight === -1 ? image.height : resizeConfiguration.maxHeight;
169179
let maxWidth = resizeConfiguration.maxWidth === -1 ? image.width : resizeConfiguration.maxWidth;
@@ -187,14 +197,13 @@ async function resizeImage(element: WoltlabCoreFileUploadElement, file: File): P
187197

188198
let fileType: string = resizeConfiguration.fileType;
189199
if (fileType === "image/jpeg" || fileType === "image/webp") {
190-
fileType = "image/jpeg";
200+
fileType = "image/webp";
191201
} else {
192202
fileType = file.type;
193203
}
194204

195205
const resizedFile = await resizer.saveFile(
196206
{
197-
exif,
198207
image: canvas,
199208
},
200209
file.name,
@@ -290,6 +299,29 @@ function reportError(element: WoltlabCoreFileUploadElement, file: File | null, m
290299
innerError(element, message);
291300
}
292301

302+
async function getExifBytes(file: File): Promise<Exif | null> {
303+
if (file.type === "image/jpeg") {
304+
try {
305+
const bytes = await getExifBytesFromJpeg(file);
306+
307+
// ExifUtil returns the entire section but we only need the app data.
308+
// Removing the first 10 bytes drops the 0xFF 0xE1 marker followed by two
309+
// bytes for the length and then 6 bytes for the "Exif\x00\x00" header.
310+
return bytes.slice(10);
311+
} catch {
312+
return null;
313+
}
314+
} else if (file.type === "image/webp") {
315+
try {
316+
return await getExifBytesFromWebP(file);
317+
} catch {
318+
return null;
319+
}
320+
}
321+
322+
return null;
323+
}
324+
293325
export function setup(): void {
294326
wheneverFirstSeen("woltlab-core-file-upload", (element: WoltlabCoreFileUploadElement) => {
295327
element.addEventListener("upload:files", (event: CustomEvent<{ files: File[] }>) => {
@@ -311,11 +343,15 @@ export function setup(): void {
311343

312344
element.markAsBusy();
313345

346+
const exifData = new Map<File, Exif | null>();
347+
314348
let processImage: (file: File) => Promise<File>;
315349
if (element.dataset.cropperConfiguration) {
316350
const cropperConfiguration = JSON.parse(element.dataset.cropperConfiguration) as CropperConfiguration;
317351

318352
processImage = async (file) => {
353+
exifData.set(file, await getExifBytes(file));
354+
319355
try {
320356
return await cropImage(element, file, cropperConfiguration);
321357
} catch (e) {
@@ -325,7 +361,11 @@ export function setup(): void {
325361
}
326362
};
327363
} else {
328-
processImage = async (file) => resizeImage(element, file);
364+
processImage = async (file) => {
365+
exifData.set(file, await getExifBytes(file));
366+
367+
return resizeImage(element, file);
368+
};
329369
}
330370

331371
// Resize all files in parallel but keep the original order. This ensures
@@ -359,7 +399,8 @@ export function setup(): void {
359399
const result = checksums[i];
360400

361401
if (result.status === "fulfilled") {
362-
void upload(element, validFiles[i], result.value);
402+
const exif = exifData.get(validFiles[i]) || null;
403+
void upload(element, validFiles[i], result.value, exif);
363404
} else {
364405
throw new Error(result.reason);
365406
}
@@ -394,26 +435,32 @@ export function setup(): void {
394435
return;
395436
}
396437

397-
void resizeImage(element, file).then(async (resizeFile) => {
398-
try {
399-
const checksum = await getSha256Hash(resizeFile);
400-
const data = await upload(element, resizeFile, checksum);
401-
if (data === undefined || typeof data.data.attachmentID !== "number") {
438+
let exifData: Exif | null;
439+
void getExifBytes(file)
440+
.then((exif) => {
441+
exifData = exif;
442+
})
443+
.then(() => resizeImage(element, file))
444+
.then(async (resizeFile) => {
445+
try {
446+
const checksum = await getSha256Hash(resizeFile);
447+
const data = await upload(element, resizeFile, checksum, exifData);
448+
if (data === undefined || typeof data.data.attachmentID !== "number") {
449+
promiseReject();
450+
} else {
451+
const attachmentData: AttachmentData = {
452+
attachmentId: data.data.attachmentID,
453+
url: data.link,
454+
};
455+
456+
promiseResolve(attachmentData);
457+
}
458+
} catch (e) {
402459
promiseReject();
403-
} else {
404-
const attachmentData: AttachmentData = {
405-
attachmentId: data.data.attachmentID,
406-
url: data.link,
407-
};
408460

409-
promiseResolve(attachmentData);
461+
throw e;
410462
}
411-
} catch (e) {
412-
promiseReject();
413-
414-
throw e;
415-
}
416-
});
463+
});
417464
});
418465
});
419466
}

ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export class Thumbnail {
3232
}
3333
}
3434

35+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
3536
export class WoltlabCoreFileElement extends HTMLElement {
3637
#data: Record<string, unknown> | undefined = undefined;
3738
#filename: string = "";
@@ -321,6 +322,20 @@ export class WoltlabCoreFileElement extends HTMLElement {
321322
this.#readyResolve();
322323
}
323324

325+
/**
326+
* Updates the filename, file size and mime type. These can change for images
327+
* that are being converted to a different file format,
328+
*
329+
* @internal
330+
*/
331+
updateFileData(filename: string, fileSize: number, mimeType: string): void {
332+
this.#filename = filename;
333+
this.#fileSize = fileSize;
334+
this.#mimeType = mimeType;
335+
336+
this.dispatchEvent(new CustomEvent<void>("file:update-data"));
337+
}
338+
324339
isFailedUpload(): boolean {
325340
return this.#state === State.Failed;
326341
}
@@ -346,6 +361,21 @@ export class WoltlabCoreFileElement extends HTMLElement {
346361
}
347362
}
348363

364+
interface WoltlabCoreFileElementEventMap {
365+
"file:update-data": CustomEvent<void>;
366+
}
367+
368+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
369+
export interface WoltlabCoreFileElement extends HTMLElement {
370+
addEventListener: {
371+
<T extends keyof WoltlabCoreFileElementEventMap>(
372+
type: T,
373+
listener: (this: Selection, ev: WoltlabCoreFileElementEventMap[T]) => any,
374+
options?: boolean | AddEventListenerOptions,
375+
): void;
376+
} & HTMLElement["addEventListener"];
377+
}
378+
349379
export default WoltlabCoreFileElement;
350380

351381
window.customElements.define("woltlab-core-file", WoltlabCoreFileElement);

0 commit comments

Comments
 (0)