Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6dbb186
Add `woltlab/webp-exif` as a composer dependency
dtdesign Jul 17, 2025
0ce531e
Add support for EXIF data from WebP images
dtdesign Jul 17, 2025
ea8e235
Store EXIF data of uploaded images
dtdesign Jul 17, 2025
495b186
Extract EXIF data and store it outside the file
dtdesign Jul 18, 2025
a898b0c
Export resized images as WebP and strip EXIF
dtdesign Jul 18, 2025
06c0a35
Always set the export quality to 80%
dtdesign Jul 18, 2025
29bc9b8
Default to exporting files as WebP for new installations
dtdesign Jul 18, 2025
5cb1e4d
Clean up of WebP variants of files replaced with WebP
dtdesign Jul 19, 2025
f6b71bb
Add an option to force convert images to WebP
dtdesign Jul 19, 2025
981feee
Require the return value of `convertImageFormat` to be used
dtdesign Jul 19, 2025
89b1e41
Parse the raw EXIF bytes before storing
dtdesign Jul 19, 2025
e66130d
Add an option to strip EXIF data from files
dtdesign Jul 19, 2025
1c03ff8
Add the missing phrases for the options
dtdesign Jul 20, 2025
6bbb242
Update the composer dependencies
dtdesign Jul 20, 2025
12a728b
Skip potentially animated GIF images when replacing them with WebP
dtdesign Jul 21, 2025
034bfb0
Fix the usage of the `Exif` chunk
dtdesign Jul 21, 2025
0e549ff
Add an update script to adjust the default behavior after upgrading
dtdesign Jul 22, 2025
87ed949
Preserve the filename when stripping EXIF data
dtdesign Jul 22, 2025
163890c
Fix the format conversion not happening due to outdated data
dtdesign Jul 22, 2025
d58780e
Move the format conversion and EXIF removal into `SaveChunk`
dtdesign Jul 23, 2025
e43f044
Revert "Move the format conversion and EXIF removal into `SaveChunk`"
dtdesign Jul 23, 2025
8bac3d3
Update the file information after format changes
dtdesign Jul 23, 2025
9db9f76
Fix the replacement of converted images
dtdesign Jul 23, 2025
12ddcf9
Fix the handling of EXIF data
dtdesign Jul 23, 2025
76940f7
Improve the type safety
dtdesign Jul 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions com.woltlab.wcf/option.xml
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,18 @@
<selectoptions>gd:wcf.acp.option.image_adapter_type.gd
imagick:wcf.acp.option.image_adapter_type.imagick</selectoptions>
</option>
<option name="image_convert_format">
<categoryname>general.system.image</categoryname>
<optiontype>radioButton</optiontype>
<defaultvalue>webp</defaultvalue>
<selectoptions>keep:wcf.acp.option.image_convert_format.keep
webp:wcf.acp.option.image_convert_format.webp</selectoptions>
</option>
<option name="image_strip_exif">
<categoryname>general.system.image</categoryname>
<optiontype>boolean</optiontype>
<defaultvalue>1</defaultvalue>
</option>
<!-- /general.system.image -->
<!-- general.system.search -->
<option name="search_engine">
Expand Down Expand Up @@ -922,18 +934,10 @@ redis:wcf.acp.option.cache_source_type.redis</selectoptions>
<option name="attachment_image_autoscale_file_type">
<categoryname>message.attachment.autoscale</categoryname>
<optiontype>radioButton</optiontype>
<defaultvalue>keep</defaultvalue>
<defaultvalue>image/jpeg</defaultvalue>
<selectoptions>keep:wcf.acp.option.attachment_image_autoscale_file_type.keep
image/jpeg:wcf.acp.option.attachment_image_autoscale_file_type.jpeg</selectoptions>
</option>
<option name="attachment_image_autoscale_quality">
<categoryname>message.attachment.autoscale</categoryname>
<optiontype>integer</optiontype>
<defaultvalue>80</defaultvalue>
<minvalue>1</minvalue>
<maxvalue>100</maxvalue>
<suffix>percent</suffix>
</option>
<!-- message.general.edit -->
<option name="module_edit_history">
<categoryname>message.general.edit</categoryname>
Expand Down Expand Up @@ -1635,5 +1639,7 @@ DESC:wcf.global.sortOrder.descending</selectoptions>
<option name="message_sidebar_enable_articles"/>
<category name="security.blacklist"/>
<category name="security.blacklist.custom"/>

<option name="attachment_image_autoscale_quality"/>
</delete>
</data>
1 change: 1 addition & 0 deletions com.woltlab.wcf/package.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,6 @@
<instruction type="database" run="standalone">acp/database/update_com.woltlab.wcf_62_step1.php</instruction>
<instruction type="script">acp/update_com.woltlab.wcf_6.2_contactOptions.php</instruction>
<instruction type="database" run="standalone">acp/database/update_com.woltlab.wcf_62_step2.php</instruction>
<instruction type="script">acp/update_com.woltlab.wcf_6.2_option.php</instruction>
-->
</package>
5 changes: 3 additions & 2 deletions constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,7 @@
\define("ATTACHMENT_IMAGE_AUTOSCALE", 1);
\define("ATTACHMENT_IMAGE_AUTOSCALE_MAX_WIDTH", 1024);
\define("ATTACHMENT_IMAGE_AUTOSCALE_MAX_HEIGHT", 1024);
\define("ATTACHMENT_IMAGE_AUTOSCALE_FILE_TYPE", '');
\define("ATTACHMENT_IMAGE_AUTOSCALE_QUALITY", 80);
\define("ATTACHMENT_IMAGE_AUTOSCALE_FILE_TYPE", 'image/jpeg');
\define('LOG_MISSING_LANGUAGE_ITEMS', 0);
\define('PRUNE_IP_ADDRESS', 30);
\define('BREADCRUMBS_HOME_USE_PAGE_TITLE', 1);
Expand All @@ -228,3 +227,5 @@
\define('SERVICE_WORKER_PRIVATE_KEY', '');
\define('SERVICE_WORKER_PUBLIC_KEY', '');
\define('RECAPTCHA_PRIVATEKEY_V3', '');
\define('IMAGE_CONVERT_FORMAT', 'webp');
\define('IMAGE_STRIP_EXIF', 1);
2 changes: 2 additions & 0 deletions phpstan-ambient.neon
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,5 @@ parameters:
- SERVICE_WORKER_PRIVATE_KEY
- SERVICE_WORKER_PUBLIC_KEY
- RECAPTCHA_PRIVATEKEY_V3
- IMAGE_CONVERT_FORMAT
- IMAGE_STRIP_EXIF
7 changes: 6 additions & 1 deletion ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ type Thumbnail = {
identifier: string;
link: string;
};
type Response = Thumbnail[];
type Response = {
filename: string;
fileSize: number;
mimeType: string;
thumbnails: Thumbnail[];
};

export async function generateThumbnails(fileID: number): Promise<ApiResult<Response>> {
const url = new URL(`${window.WSC_RPC_API_URL}core/files/${fileID}/generate-thumbnails`);
Expand Down
11 changes: 11 additions & 0 deletions ts/WoltLabSuite/Core/Api/Files/Upload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result";
import type { Exif } from "WoltLabSuite/Core/Image/ExifUtil";

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

let exifData: string | null = null;
if (exifBytes !== null) {
exifData = "";
for (let i = 0, length = exifBytes.length; i < length; i++) {
exifData += exifBytes[i].toString(16).padStart(2, "0");
}
}

const payload = {
filename,
fileSize,
fileHash,
objectType,
context,
exifData,
};

let response: Response;
Expand Down
5 changes: 5 additions & 0 deletions ts/WoltLabSuite/Core/Component/Attachment/Entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
insertFileInformation,
removeUploadProgress,
trackUploadProgress,
updateFileInformation,
} from "WoltLabSuite/Core/Component/File/Helper";

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

insertFileInformation(element, file);

file.addEventListener("file:update-data", () => {
updateFileInformation(element, file);
});

void file.ready
.then(() => {
fileInitializationCompleted(element, file, editor);
Expand Down
8 changes: 8 additions & 0 deletions ts/WoltLabSuite/Core/Component/File/Helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,11 @@ export function insertFileInformation(container: HTMLElement, file: WoltlabCoreF

container.append(fileWrapper, filename, fileSize);
}

export function updateFileInformation(container: HTMLElement, file: WoltlabCoreFileElement): void {
const filename = container.querySelector(".fileList__item__filename")!;
filename.textContent = file.filename || file.dataset.filename!;

const fileSize = container.querySelector(".fileList__item__fileSize")!;
fileSize.textContent = formatFilesize(file.fileSize || parseInt(file.dataset.fileSize!));
}
97 changes: 72 additions & 25 deletions ts/WoltLabSuite/Core/Component/File/Upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { innerError } from "WoltLabSuite/Core/Dom/Util";
import { getPhrase } from "WoltLabSuite/Core/Language";
import { createSHA256 } from "hash-wasm";
import { cropImage, CropperConfiguration } from "WoltLabSuite/Core/Component/Image/Cropper";
import { Exif, getExifBytesFromJpeg, getExifBytesFromWebP } from "WoltLabSuite/Core/Image/ExifUtil";

export type CkeditorDropEvent = {
file: File;
Expand Down Expand Up @@ -44,6 +45,7 @@ async function upload(
element: WoltlabCoreFileUploadElement,
file: File,
fileHash: string,
exifData: Exif | null,
): Promise<ResponseCompleted | undefined> {
const objectType = element.dataset.objectType!;

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

const response = await filesUpload(file.name, file.size, fileHash, objectType, element.dataset.context || "");
const response = await filesUpload(
file.name,
file.size,
fileHash,
objectType,
element.dataset.context || "",
exifData,
);
if (!response.ok) {
const validationError = response.error.getValidationError();
if (validationError === undefined) {
Expand Down Expand Up @@ -118,8 +127,9 @@ async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, result:
fileElement.uploadCompleted(result.fileID, result.mimeType, result.link, result.data, result.generateThumbnails);

if (result.generateThumbnails) {
const response = await generateThumbnails(result.fileID);
fileElement.setThumbnails(response.unwrap());
const { filename, fileSize, mimeType, thumbnails } = (await generateThumbnails(result.fileID)).unwrap();
fileElement.setThumbnails(thumbnails);
fileElement.updateFileData(filename, fileSize, mimeType);
}
}

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

const resizer = new ImageResizer();
const { image, exif } = await resizer.loadFile(file);
const { image } = await resizer.loadFile(file);

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

let fileType: string = resizeConfiguration.fileType;
if (fileType === "image/jpeg" || fileType === "image/webp") {
fileType = "image/jpeg";
fileType = "image/webp";
} else {
fileType = file.type;
}

const resizedFile = await resizer.saveFile(
{
exif,
image: canvas,
},
file.name,
Expand Down Expand Up @@ -290,6 +299,29 @@ function reportError(element: WoltlabCoreFileUploadElement, file: File | null, m
innerError(element, message);
}

async function getExifBytes(file: File): Promise<Exif | null> {
if (file.type === "image/jpeg") {
try {
const bytes = await getExifBytesFromJpeg(file);

// ExifUtil returns the entire section but we only need the app data.
// Removing the first 10 bytes drops the 0xFF 0xE1 marker followed by two
// bytes for the length and then 6 bytes for the "Exif\x00\x00" header.
return bytes.slice(10);
} catch {
return null;
}
} else if (file.type === "image/webp") {
try {
return await getExifBytesFromWebP(file);
} catch {
return null;
}
}

return null;
}

export function setup(): void {
wheneverFirstSeen("woltlab-core-file-upload", (element: WoltlabCoreFileUploadElement) => {
element.addEventListener("upload:files", (event: CustomEvent<{ files: File[] }>) => {
Expand All @@ -311,11 +343,15 @@ export function setup(): void {

element.markAsBusy();

const exifData = new Map<File, Exif | null>();

let processImage: (file: File) => Promise<File>;
if (element.dataset.cropperConfiguration) {
const cropperConfiguration = JSON.parse(element.dataset.cropperConfiguration) as CropperConfiguration;

processImage = async (file) => {
exifData.set(file, await getExifBytes(file));

try {
return await cropImage(element, file, cropperConfiguration);
} catch (e) {
Expand All @@ -325,7 +361,11 @@ export function setup(): void {
}
};
} else {
processImage = async (file) => resizeImage(element, file);
processImage = async (file) => {
exifData.set(file, await getExifBytes(file));

return resizeImage(element, file);
};
}

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

if (result.status === "fulfilled") {
void upload(element, validFiles[i], result.value);
const exif = exifData.get(validFiles[i]) || null;
void upload(element, validFiles[i], result.value, exif);
} else {
throw new Error(result.reason);
}
Expand Down Expand Up @@ -394,26 +435,32 @@ export function setup(): void {
return;
}

void resizeImage(element, file).then(async (resizeFile) => {
try {
const checksum = await getSha256Hash(resizeFile);
const data = await upload(element, resizeFile, checksum);
if (data === undefined || typeof data.data.attachmentID !== "number") {
let exifData: Exif | null;
void getExifBytes(file)
.then((exif) => {
exifData = exif;
})
.then(() => resizeImage(element, file))
.then(async (resizeFile) => {
try {
const checksum = await getSha256Hash(resizeFile);
const data = await upload(element, resizeFile, checksum, exifData);
if (data === undefined || typeof data.data.attachmentID !== "number") {
promiseReject();
} else {
const attachmentData: AttachmentData = {
attachmentId: data.data.attachmentID,
url: data.link,
};

promiseResolve(attachmentData);
}
} catch (e) {
promiseReject();
} else {
const attachmentData: AttachmentData = {
attachmentId: data.data.attachmentID,
url: data.link,
};

promiseResolve(attachmentData);
throw e;
}
} catch (e) {
promiseReject();

throw e;
}
});
});
});
});
}
30 changes: 30 additions & 0 deletions ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class Thumbnail {
}
}

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

/**
* Updates the filename, file size and mime type. These can change for images
* that are being converted to a different file format,
*
* @internal
*/
updateFileData(filename: string, fileSize: number, mimeType: string): void {
this.#filename = filename;
this.#fileSize = fileSize;
this.#mimeType = mimeType;

this.dispatchEvent(new CustomEvent<void>("file:update-data"));
}

isFailedUpload(): boolean {
return this.#state === State.Failed;
}
Expand All @@ -346,6 +361,21 @@ export class WoltlabCoreFileElement extends HTMLElement {
}
}

interface WoltlabCoreFileElementEventMap {
"file:update-data": CustomEvent<void>;
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface WoltlabCoreFileElement extends HTMLElement {
addEventListener: {
<T extends keyof WoltlabCoreFileElementEventMap>(
type: T,
listener: (this: Selection, ev: WoltlabCoreFileElementEventMap[T]) => any,
options?: boolean | AddEventListenerOptions,
): void;
} & HTMLElement["addEventListener"];
}

export default WoltlabCoreFileElement;

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