diff --git a/com.woltlab.wcf/option.xml b/com.woltlab.wcf/option.xml
index c4e71741061..ad66f85401c 100644
--- a/com.woltlab.wcf/option.xml
+++ b/com.woltlab.wcf/option.xml
@@ -532,6 +532,18 @@
gd:wcf.acp.option.image_adapter_type.gd
imagick:wcf.acp.option.image_adapter_type.imagick
+
+
-
+
+
diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml
index b0598667426..6b796907282 100644
--- a/com.woltlab.wcf/package.xml
+++ b/com.woltlab.wcf/package.xml
@@ -56,5 +56,6 @@
acp/database/update_com.woltlab.wcf_62_step1.php
acp/update_com.woltlab.wcf_6.2_contactOptions.php
acp/database/update_com.woltlab.wcf_62_step2.php
+ acp/update_com.woltlab.wcf_6.2_option.php
-->
diff --git a/constants.php b/constants.php
index 3d6f0e5b977..878010706cc 100644
--- a/constants.php
+++ b/constants.php
@@ -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);
@@ -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);
diff --git a/phpstan-ambient.neon b/phpstan-ambient.neon
index 53c4a563e75..aa425a2068b 100644
--- a/phpstan-ambient.neon
+++ b/phpstan-ambient.neon
@@ -230,3 +230,5 @@ parameters:
- SERVICE_WORKER_PRIVATE_KEY
- SERVICE_WORKER_PUBLIC_KEY
- RECAPTCHA_PRIVATEKEY_V3
+ - IMAGE_CONVERT_FORMAT
+ - IMAGE_STRIP_EXIF
diff --git a/ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts b/ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts
index e46d6fea5e0..484b67e773f 100644
--- a/ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts
+++ b/ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts
@@ -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> {
const url = new URL(`${window.WSC_RPC_API_URL}core/files/${fileID}/generate-thumbnails`);
diff --git a/ts/WoltLabSuite/Core/Api/Files/Upload.ts b/ts/WoltLabSuite/Core/Api/Files/Upload.ts
index 1c0e1ac6892..e52d58c7057 100644
--- a/ts/WoltLabSuite/Core/Api/Files/Upload.ts
+++ b/ts/WoltLabSuite/Core/Api/Files/Upload.ts
@@ -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;
@@ -12,15 +13,25 @@ export async function upload(
fileHash: string,
objectType: string,
context: string,
+ exifBytes: Exif | null = null,
): Promise> {
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;
diff --git a/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts b/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts
index 3119e199b71..1b8d7b2f76c 100644
--- a/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts
+++ b/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts
@@ -9,6 +9,7 @@ import {
insertFileInformation,
removeUploadProgress,
trackUploadProgress,
+ updateFileInformation,
} from "WoltLabSuite/Core/Component/File/Helper";
type FileProcessorData = {
@@ -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);
diff --git a/ts/WoltLabSuite/Core/Component/File/Helper.ts b/ts/WoltLabSuite/Core/Component/File/Helper.ts
index 1dddce2c6ac..143b37b39a9 100644
--- a/ts/WoltLabSuite/Core/Component/File/Helper.ts
+++ b/ts/WoltLabSuite/Core/Component/File/Helper.ts
@@ -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!));
+}
diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts
index 093ce10bd7c..fad53ba8821 100644
--- a/ts/WoltLabSuite/Core/Component/File/Upload.ts
+++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts
@@ -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;
@@ -44,6 +45,7 @@ async function upload(
element: WoltlabCoreFileUploadElement,
file: File,
fileHash: string,
+ exifData: Exif | null,
): Promise {
const objectType = element.dataset.objectType!;
@@ -54,7 +56,14 @@ async function upload(
const event = new CustomEvent("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) {
@@ -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);
}
}
@@ -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;
@@ -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,
@@ -290,6 +299,29 @@ function reportError(element: WoltlabCoreFileUploadElement, file: File | null, m
innerError(element, message);
}
+async function getExifBytes(file: File): Promise {
+ 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[] }>) => {
@@ -311,11 +343,15 @@ export function setup(): void {
element.markAsBusy();
+ const exifData = new Map();
+
let processImage: (file: File) => Promise;
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) {
@@ -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
@@ -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);
}
@@ -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;
- }
- });
+ });
});
});
}
diff --git a/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts b/ts/WoltLabSuite/Core/Component/File/woltlab-core-file.ts
index 3870d0d3251..1ed1a99e917 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 {
}
}
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class WoltlabCoreFileElement extends HTMLElement {
#data: Record | undefined = undefined;
#filename: string = "";
@@ -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("file:update-data"));
+ }
+
isFailedUpload(): boolean {
return this.#state === State.Failed;
}
@@ -346,6 +361,21 @@ export class WoltlabCoreFileElement extends HTMLElement {
}
}
+interface WoltlabCoreFileElementEventMap {
+ "file:update-data": CustomEvent;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export interface WoltlabCoreFileElement extends HTMLElement {
+ addEventListener: {
+ (
+ 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);
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts
index fef8f521748..b1a7ccebfee 100644
--- a/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts
+++ b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts
@@ -14,6 +14,7 @@ import {
insertFileInformation,
removeUploadProgress,
trackUploadProgress,
+ updateFileInformation,
} from "WoltLabSuite/Core/Component/File/Helper";
import { clearPreviousErrors } from "WoltLabSuite/Core/Component/File/Upload";
import { innerError } from "WoltLabSuite/Core/Dom/Util";
@@ -300,6 +301,10 @@ export class FileProcessor {
if (!this.#useBigPreview) {
insertFileInformation(container, element);
+
+ element.addEventListener("file:update-data", () => {
+ updateFileInformation(container!, element);
+ });
}
trackUploadProgress(container, element);
diff --git a/ts/WoltLabSuite/Core/Image/ExifUtil.ts b/ts/WoltLabSuite/Core/Image/ExifUtil.ts
index 4a6df6e762e..3eb6cd8f724 100644
--- a/ts/WoltLabSuite/Core/Image/ExifUtil.ts
+++ b/ts/WoltLabSuite/Core/Image/ExifUtil.ts
@@ -7,6 +7,8 @@
* @woltlabExcludeBundle tiny
*/
+import { parseWebPFromBuffer } from "./WebP";
+
const Tag = {
SOI: 0xd8, // Start of image
APP0: 0xe0, // JFIF tag
@@ -113,6 +115,19 @@ export async function getExifBytesFromJpeg(blob: Blob | File): Promise {
return exif;
}
+export async function getExifBytesFromWebP(blob: Blob | File): Promise {
+ if (!((blob as any) instanceof Blob) && !(blob instanceof File)) {
+ throw new TypeError("The argument must be a Blob or a File");
+ }
+
+ const webp = parseWebPFromBuffer(await blob.arrayBuffer());
+ if (webp === undefined) {
+ return null;
+ }
+
+ return webp.getExifData();
+}
+
/**
* Removes all EXIF and XMP sections of a JPEG blob.
*/
diff --git a/ts/WoltLabSuite/Core/Image/WebP.ts b/ts/WoltLabSuite/Core/Image/WebP.ts
new file mode 100644
index 00000000000..7a487b27d2a
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Image/WebP.ts
@@ -0,0 +1,196 @@
+/**
+ * Provides helper functions to decode a WebP image.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2025 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.2
+ * @woltlabExcludeBundle tiny
+ */
+
+import type { Exif } from "./ExifUtil";
+
+const enum ChunkHeader {
+ ALPH = "ALPH",
+ ANIM = "ANIM",
+ ANMF = "ANMF",
+ EXIF = "EXIF",
+ ICCP = "ICCP",
+ RIFF = "RIFF",
+ VP8 = "VP8 ",
+ VP8L = "VP8L",
+ VP8X = "VP8X",
+ WEBP = "WEBP",
+ XMP = "XMP ",
+}
+
+function decodeHeader(uint32BE: number): ChunkHeader | number {
+ switch (uint32BE) {
+ case 0x414c5048:
+ return ChunkHeader.ALPH;
+
+ case 0x414e494d:
+ return ChunkHeader.ANIM;
+
+ case 0x414e4d46:
+ return ChunkHeader.ANMF;
+
+ case 0x45584946:
+ return ChunkHeader.EXIF;
+
+ case 0x49434350:
+ return ChunkHeader.ICCP;
+
+ case 0x52494646:
+ return ChunkHeader.RIFF;
+
+ case 0x56503820:
+ return ChunkHeader.VP8;
+
+ case 0x5650384c:
+ return ChunkHeader.VP8L;
+
+ case 0x56503858:
+ return ChunkHeader.VP8X;
+
+ case 0x57454250:
+ return ChunkHeader.WEBP;
+
+ case 0x584d5020:
+ return ChunkHeader.XMP;
+
+ default:
+ return uint32BE;
+ }
+}
+
+type Offset = number;
+type ChunkSize = number;
+type Chunk = [ChunkHeader | number, Offset, ChunkSize];
+
+class WebP {
+ readonly #buffer: ArrayBuffer;
+ readonly #chunks: Chunk[];
+ readonly #height: number;
+ readonly #width: number;
+
+ constructor(buffer: ArrayBuffer, width: number, height: number, chunks: Chunk[]) {
+ this.#buffer = buffer;
+ this.#chunks = chunks;
+ this.#height = height;
+ this.#width = width;
+ }
+
+ getExifData(): Exif | null {
+ for (const [chunkHeader, offset, chunkSize] of this.#chunks) {
+ if (chunkHeader === ChunkHeader.EXIF) {
+ return new Uint8Array(this.#buffer.slice(offset, offset + chunkSize));
+ }
+ }
+
+ return null;
+ }
+
+ get height(): number {
+ return this.#height;
+ }
+
+ get width(): number {
+ return this.#width;
+ }
+}
+
+function parseVp8x(buffer: ArrayBuffer, dataView: DataView): WebP {
+ if (dataView.byteLength <= 30) {
+ throw new Error("A VP8X encoded WebP must be larger than 30 bytes.");
+ }
+
+ // If we reach this point, then we have already consumed the first 20 bytes of
+ // the buffer. (offset = 20)
+
+ // The next 8 bits contain the flags. (offset + 1 = 21)
+
+ // The next 24 bits are reserved. (offset + 3 = 24)
+
+ // The next 48 bits contain the width and height, represented as uint24LE, but
+ // using the value - 1, thus we need to add 1 to each calculated value.
+ const width = ((dataView.getUint8(26) << 16) | (dataView.getUint8(25) << 8) | dataView.getUint8(24)) + 1;
+ const height = ((dataView.getUint8(29) << 16) | (dataView.getUint8(28) << 8) | dataView.getUint8(27)) + 1;
+
+ const chunks: Chunk[] = [];
+ let offset = 30;
+ while (offset < dataView.byteLength) {
+ const chunkHeader = decodeHeader(dataView.getUint32(offset));
+ const chunkSize = dataView.getUint32(offset + 4, true);
+ offset += 8;
+
+ chunks.push([chunkHeader, offset, chunkSize]);
+
+ offset += chunkSize;
+
+ if (chunkSize % 2 === 1) {
+ // "If Chunk Size is odd, a single padding byte -- which MUST be 0 to
+ // conform with RIFF -- is added."
+ offset += 1;
+ }
+
+ if (offset > dataView.byteLength) {
+ const header = typeof chunkHeader === "number" ? `0x${chunkHeader.toString(16)}` : chunkHeader;
+ throw new Error(`Corrupted image detected, offset ${offset} > ${dataView.byteLength} for chunk ${header}.`);
+ }
+ }
+
+ return new WebP(buffer, width, height, chunks);
+}
+
+function getDimensions(buffer: ArrayBuffer): [number, number] {
+ // This is the lazy version that avoids having to implement an RFC 6386 parser
+ // to extract the dimensions from the VP8/VP8L frames.
+ const blob = new Blob([new Uint8Array(buffer)], { type: "image/webp" });
+ const img = new Image();
+ img.src = window.URL.createObjectURL(blob);
+
+ return [img.naturalWidth, img.naturalHeight];
+}
+
+export function parseWebPFromBuffer(buffer: ArrayBuffer): WebP | undefined {
+ const dataView = new DataView(buffer, 0, buffer.byteLength);
+ if (dataView.byteLength < 20) {
+ // Anything below 20 bytes cannot be an WebP image. The first 12 bytes are
+ // the RIFF header followed by at least 8 bytes for a chunk plus its size.
+ return undefined;
+ }
+
+ if (decodeHeader(dataView.getUint32(0)) !== ChunkHeader.RIFF) {
+ return undefined;
+ }
+
+ // The next 4 bytes represent the total size of the file.
+
+ if (decodeHeader(dataView.getUint32(8)) !== ChunkHeader.WEBP) {
+ return undefined;
+ }
+
+ const firstChunk = decodeHeader(dataView.getUint32(12));
+ if (typeof firstChunk === "number") {
+ // The first chunk must be a known value.
+ throw new Error(`Unrecognized chunk 0x${firstChunk.toString(16)} at the first position`);
+ }
+
+ const chunkSize = dataView.getUint32(16, true);
+
+ switch (firstChunk) {
+ case ChunkHeader.VP8:
+ case ChunkHeader.VP8L: {
+ const [width, height] = getDimensions(buffer);
+
+ return new WebP(buffer, width, height, [[firstChunk, 20, chunkSize]]);
+ }
+
+ case ChunkHeader.VP8X:
+ return parseVp8x(buffer, dataView);
+
+ default:
+ throw new Error(`Unexpected chunk "${firstChunk}" at the first position`);
+ }
+}
diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php
index 8fce51d2094..a7bfc88c4ec 100644
--- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php
+++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php
@@ -62,5 +62,10 @@
PartialDatabaseTable::create('wcf1_file')
->columns([
IntDatabaseTableColumn::create('uploadTime'),
+ MediumtextDatabaseTableColumn::create('exifData'),
+ ]),
+ PartialDatabaseTable::create('wcf1_file_temporary')
+ ->columns([
+ MediumtextDatabaseTableColumn::create('exifData'),
]),
];
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.2_option.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.2_option.php
new file mode 100644
index 00000000000..c747108dfb9
--- /dev/null
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.2_option.php
@@ -0,0 +1,20 @@
+ 'keep',
+ 'image_strip_exif' => 0,
+]);
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 762ec12c869..06894da03a5 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Upload.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Upload.js
@@ -2,14 +2,22 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], fu
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.upload = upload;
- async function upload(filename, fileSize, fileHash, objectType, context) {
+ async function upload(filename, fileSize, fileHash, objectType, context, exifBytes = null) {
const url = new URL(`${window.WSC_RPC_API_URL}core/files/upload`);
+ let exifData = 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;
try {
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/Entry.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/Entry.js
index d447ed84869..936c2a17bca 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/Entry.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/Entry.js
@@ -128,6 +128,9 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ui/Dropdown/Simple", "
const element = document.createElement("li");
element.classList.add("fileList__item", "attachment__item");
(0, Helper_1.insertFileInformation)(element, file);
+ file.addEventListener("file:update-data", () => {
+ (0, Helper_1.updateFileInformation)(element, file);
+ });
void file.ready
.then(() => {
fileInitializationCompleted(element, file, editor);
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Helper.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Helper.js
index bbe9ecaac17..5414f78f37a 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Helper.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Helper.js
@@ -6,6 +6,7 @@ define(["require", "exports", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/F
exports.getErrorMessageFromFile = getErrorMessageFromFile;
exports.fileInitializationFailed = fileInitializationFailed;
exports.insertFileInformation = insertFileInformation;
+ exports.updateFileInformation = updateFileInformation;
function trackUploadProgress(element, file) {
const progress = document.createElement("progress");
progress.classList.add("fileList__item__progress__bar");
@@ -84,4 +85,10 @@ define(["require", "exports", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/F
fileSize.textContent = (0, FileUtil_1.formatFilesize)(file.fileSize || parseInt(file.dataset.fileSize));
container.append(fileWrapper, filename, fileSize);
}
+ function updateFileInformation(container, file) {
+ const filename = container.querySelector(".fileList__item__filename");
+ filename.textContent = file.filename || file.dataset.filename;
+ const fileSize = container.querySelector(".fileList__item__fileSize");
+ fileSize.textContent = (0, FileUtil_1.formatFilesize)(file.fileSize || parseInt(file.dataset.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 e60a620f9cf..ad4155b8dad 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js
@@ -1,18 +1,18 @@
-define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk", "WoltLabSuite/Core/Api/Files/GenerateThumbnails", "WoltLabSuite/Core/Image/Resizer", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Language", "hash-wasm", "WoltLabSuite/Core/Component/Image/Cropper"], function (require, exports, tslib_1, Selector_1, Upload_1, Chunk_1, GenerateThumbnails_1, Resizer_1, Util_1, Language_1, hash_wasm_1, Cropper_1) {
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk", "WoltLabSuite/Core/Api/Files/GenerateThumbnails", "WoltLabSuite/Core/Image/Resizer", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Language", "hash-wasm", "WoltLabSuite/Core/Component/Image/Cropper", "WoltLabSuite/Core/Image/ExifUtil"], function (require, exports, tslib_1, Selector_1, Upload_1, Chunk_1, GenerateThumbnails_1, Resizer_1, Util_1, Language_1, hash_wasm_1, Cropper_1, ExifUtil_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.clearPreviousErrors = clearPreviousErrors;
exports.setup = setup;
Resizer_1 = tslib_1.__importDefault(Resizer_1);
const BUFFER_SIZE = 10 * 1_024 * 1_024;
- async function upload(element, file, fileHash) {
+ async function upload(element, file, fileHash, exifData) {
const objectType = element.dataset.objectType;
const fileElement = document.createElement("woltlab-core-file");
fileElement.dataset.filename = file.name;
fileElement.dataset.fileSize = file.size.toString();
const event = new CustomEvent("uploadStart", { detail: fileElement });
element.dispatchEvent(event);
- const response = await (0, Upload_1.upload)(file.name, file.size, fileHash, objectType, element.dataset.context || "");
+ const response = await (0, Upload_1.upload)(file.name, file.size, fileHash, objectType, element.dataset.context || "", exifData);
if (!response.ok) {
const validationError = response.error.getValidationError();
if (validationError === undefined) {
@@ -59,8 +59,9 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol
}
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());
+ const { filename, fileSize, mimeType, thumbnails } = (await (0, GenerateThumbnails_1.generateThumbnails)(result.fileID)).unwrap();
+ fileElement.setThumbnails(thumbnails);
+ fileElement.updateFileData(filename, fileSize, mimeType);
}
}
async function getSha256Hash(data) {
@@ -94,7 +95,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol
});
const resizeConfiguration = JSON.parse(element.dataset.resizeConfiguration);
const resizer = new Resizer_1.default();
- 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;
if (window.devicePixelRatio >= 2) {
@@ -114,13 +115,12 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol
}
let fileType = 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, fileType, resizeConfiguration.quality);
return resizedFile;
@@ -193,6 +193,29 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol
}
(0, Util_1.innerError)(element, message);
}
+ async function getExifBytes(file) {
+ if (file.type === "image/jpeg") {
+ try {
+ const bytes = await (0, ExifUtil_1.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 (0, ExifUtil_1.getExifBytesFromWebP)(file);
+ }
+ catch {
+ return null;
+ }
+ }
+ return null;
+ }
function setup() {
(0, Selector_1.wheneverFirstSeen)("woltlab-core-file-upload", (element) => {
element.addEventListener("upload:files", (event) => {
@@ -210,10 +233,12 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol
}
}
element.markAsBusy();
+ const exifData = new Map();
let processImage;
if (element.dataset.cropperConfiguration) {
const cropperConfiguration = JSON.parse(element.dataset.cropperConfiguration);
processImage = async (file) => {
+ exifData.set(file, await getExifBytes(file));
try {
return await (0, Cropper_1.cropImage)(element, file, cropperConfiguration);
}
@@ -224,7 +249,10 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol
};
}
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
// that files are uploaded in the same order that they were provided by
@@ -255,7 +283,8 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol
for (let i = 0, length = checksums.length; i < length; i++) {
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);
@@ -283,10 +312,16 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol
promiseReject();
return;
}
- void resizeImage(element, file).then(async (resizeFile) => {
+ let exifData;
+ 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);
+ const data = await upload(element, resizeFile, checksum, exifData);
if (data === undefined || typeof data.data.attachmentID !== "number") {
promiseReject();
}
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 bebeb4aac23..73ad4984be0 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
@@ -17,6 +17,7 @@ define(["require", "exports", "WoltLabSuite/Core/FileUtil"], function (require,
}
}
exports.Thumbnail = Thumbnail;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
class WoltlabCoreFileElement extends HTMLElement {
#data = undefined;
#filename = "";
@@ -246,6 +247,18 @@ define(["require", "exports", "WoltLabSuite/Core/FileUtil"], function (require,
this.#rebuildElement();
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, fileSize, mimeType) {
+ this.#filename = filename;
+ this.#fileSize = fileSize;
+ this.#mimeType = mimeType;
+ this.dispatchEvent(new CustomEvent("file:update-data"));
+ }
isFailedUpload() {
return this.#state === 4 /* State.Failed */;
}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js
index c8d3abd3322..6c64da57602 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js
@@ -234,6 +234,9 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui
}
if (!this.#useBigPreview) {
(0, Helper_1.insertFileInformation)(container, element);
+ element.addEventListener("file:update-data", () => {
+ (0, Helper_1.updateFileInformation)(container, element);
+ });
}
(0, Helper_1.trackUploadProgress)(container, element);
element.ready
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js
index e09ff1091d9..46430011971 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js
@@ -6,10 +6,11 @@
* @license GNU Lesser General Public License
* @woltlabExcludeBundle tiny
*/
-define(["require", "exports"], function (require, exports) {
+define(["require", "exports", "./WebP"], function (require, exports, WebP_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getExifBytesFromJpeg = getExifBytesFromJpeg;
+ exports.getExifBytesFromWebP = getExifBytesFromWebP;
exports.removeExifData = removeExifData;
exports.setExifData = setExifData;
const Tag = {
@@ -100,6 +101,16 @@ define(["require", "exports"], function (require, exports) {
}
return exif;
}
+ async function getExifBytesFromWebP(blob) {
+ if (!(blob instanceof Blob) && !(blob instanceof File)) {
+ throw new TypeError("The argument must be a Blob or a File");
+ }
+ const webp = (0, WebP_1.parseWebPFromBuffer)(await blob.arrayBuffer());
+ if (webp === undefined) {
+ return null;
+ }
+ return webp.getExifData();
+ }
/**
* Removes all EXIF and XMP sections of a JPEG blob.
*/
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js
new file mode 100644
index 00000000000..6ce81ccd563
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js
@@ -0,0 +1,140 @@
+/**
+ * Provides helper functions to decode a WebP image.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2025 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.2
+ * @woltlabExcludeBundle tiny
+ */
+define(["require", "exports"], function (require, exports) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.parseWebPFromBuffer = parseWebPFromBuffer;
+ function decodeHeader(uint32BE) {
+ switch (uint32BE) {
+ case 0x414c5048:
+ return "ALPH" /* ChunkHeader.ALPH */;
+ case 0x414e494d:
+ return "ANIM" /* ChunkHeader.ANIM */;
+ case 0x414e4d46:
+ return "ANMF" /* ChunkHeader.ANMF */;
+ case 0x45584946:
+ return "EXIF" /* ChunkHeader.EXIF */;
+ case 0x49434350:
+ return "ICCP" /* ChunkHeader.ICCP */;
+ case 0x52494646:
+ return "RIFF" /* ChunkHeader.RIFF */;
+ case 0x56503820:
+ return "VP8 " /* ChunkHeader.VP8 */;
+ case 0x5650384c:
+ return "VP8L" /* ChunkHeader.VP8L */;
+ case 0x56503858:
+ return "VP8X" /* ChunkHeader.VP8X */;
+ case 0x57454250:
+ return "WEBP" /* ChunkHeader.WEBP */;
+ case 0x584d5020:
+ return "XMP " /* ChunkHeader.XMP */;
+ default:
+ return uint32BE;
+ }
+ }
+ class WebP {
+ #buffer;
+ #chunks;
+ #height;
+ #width;
+ constructor(buffer, width, height, chunks) {
+ this.#buffer = buffer;
+ this.#chunks = chunks;
+ this.#height = height;
+ this.#width = width;
+ }
+ getExifData() {
+ for (const [chunkHeader, offset, chunkSize] of this.#chunks) {
+ if (chunkHeader === "EXIF" /* ChunkHeader.EXIF */) {
+ return new Uint8Array(this.#buffer.slice(offset, offset + chunkSize));
+ }
+ }
+ return null;
+ }
+ get height() {
+ return this.#height;
+ }
+ get width() {
+ return this.#width;
+ }
+ }
+ function parseVp8x(buffer, dataView) {
+ if (dataView.byteLength <= 30) {
+ throw new Error("A VP8X encoded WebP must be larger than 30 bytes.");
+ }
+ // If we reach this point, then we have already consumed the first 20 bytes of
+ // the buffer. (offset = 20)
+ // The next 8 bits contain the flags. (offset + 1 = 21)
+ // The next 24 bits are reserved. (offset + 3 = 24)
+ // The next 48 bits contain the width and height, represented as uint24LE, but
+ // using the value - 1, thus we need to add 1 to each calculated value.
+ const width = ((dataView.getUint8(26) << 16) | (dataView.getUint8(25) << 8) | dataView.getUint8(24)) + 1;
+ const height = ((dataView.getUint8(29) << 16) | (dataView.getUint8(28) << 8) | dataView.getUint8(27)) + 1;
+ const chunks = [];
+ let offset = 30;
+ while (offset < dataView.byteLength) {
+ const chunkHeader = decodeHeader(dataView.getUint32(offset));
+ const chunkSize = dataView.getUint32(offset + 4, true);
+ offset += 8;
+ chunks.push([chunkHeader, offset, chunkSize]);
+ offset += chunkSize;
+ if (chunkSize % 2 === 1) {
+ // "If Chunk Size is odd, a single padding byte -- which MUST be 0 to
+ // conform with RIFF -- is added."
+ offset += 1;
+ }
+ if (offset > dataView.byteLength) {
+ const header = typeof chunkHeader === "number" ? `0x${chunkHeader.toString(16)}` : chunkHeader;
+ throw new Error(`Corrupted image detected, offset ${offset} > ${dataView.byteLength} for chunk ${header}.`);
+ }
+ }
+ return new WebP(buffer, width, height, chunks);
+ }
+ function getDimensions(buffer) {
+ // This is the lazy version that avoids having to implement an RFC 6386 parser
+ // to extract the dimensions from the VP8/VP8L frames.
+ const blob = new Blob([new Uint8Array(buffer)], { type: "image/webp" });
+ const img = new Image();
+ img.src = window.URL.createObjectURL(blob);
+ return [img.naturalWidth, img.naturalHeight];
+ }
+ function parseWebPFromBuffer(buffer) {
+ const dataView = new DataView(buffer, 0, buffer.byteLength);
+ if (dataView.byteLength < 20) {
+ // Anything below 20 bytes cannot be an WebP image. The first 12 bytes are
+ // the RIFF header followed by at least 8 bytes for a chunk plus its size.
+ return undefined;
+ }
+ if (decodeHeader(dataView.getUint32(0)) !== "RIFF" /* ChunkHeader.RIFF */) {
+ return undefined;
+ }
+ // The next 4 bytes represent the total size of the file.
+ if (decodeHeader(dataView.getUint32(8)) !== "WEBP" /* ChunkHeader.WEBP */) {
+ return undefined;
+ }
+ const firstChunk = decodeHeader(dataView.getUint32(12));
+ if (typeof firstChunk === "number") {
+ // The first chunk must be a known value.
+ throw new Error(`Unrecognized chunk 0x${firstChunk.toString(16)} at the first position`);
+ }
+ const chunkSize = dataView.getUint32(16, true);
+ switch (firstChunk) {
+ case "VP8 " /* ChunkHeader.VP8 */:
+ case "VP8L" /* ChunkHeader.VP8L */: {
+ const [width, height] = getDimensions(buffer);
+ return new WebP(buffer, width, height, [[firstChunk, 20, chunkSize]]);
+ }
+ case "VP8X" /* ChunkHeader.VP8X */:
+ return parseVp8x(buffer, dataView);
+ default:
+ throw new Error(`Unexpected chunk "${firstChunk}" at the first position`);
+ }
+ }
+});
diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php
index 2a80dc1a9ed..9c68846d7cf 100644
--- a/wcfsetup/install/files/lib/data/file/File.class.php
+++ b/wcfsetup/install/files/lib/data/file/File.class.php
@@ -32,6 +32,7 @@
* @property-read int|null $height
* @property-read string|null $fileHashWebp
* @property-read int $uploadTime
+ * @property-read string|null $exifData
*/
class File extends DatabaseObject implements ITitledLinkObject, IImageDataProvider
{
diff --git a/wcfsetup/install/files/lib/data/file/FileEditor.class.php b/wcfsetup/install/files/lib/data/file/FileEditor.class.php
index d93e7055933..11d95583aee 100644
--- a/wcfsetup/install/files/lib/data/file/FileEditor.class.php
+++ b/wcfsetup/install/files/lib/data/file/FileEditor.class.php
@@ -11,6 +11,7 @@
use wcf\system\image\ImageHandler;
use wcf\util\ExifUtil;
use wcf\util\FileUtil;
+use wcf\util\JSON;
/**
* @author Alexander Ebert
@@ -99,6 +100,7 @@ public static function createFromTemporary(FileTemporary $fileTemporary): File
'width' => $width,
'height' => $height,
'uploadTime' => \TIME_NOW,
+ 'exifData' => $fileTemporary->exifData,
]]);
$file = $fileAction->executeAction()['returnValues'];
\assert($file instanceof File);
@@ -119,12 +121,16 @@ public static function createFromTemporary(FileTemporary $fileTemporary): File
return $event->getFile();
}
+ /**
+ * @param null|array> $exifData
+ */
public static function createFromExistingFile(
string $pathname,
string $originalFilename,
string $objectTypeName,
bool $copy = false,
- ?int $uploadTime = null
+ ?int $uploadTime = null,
+ ?array $exifData = null,
): ?File {
if (!\is_readable($pathname)) {
return null;
@@ -144,6 +150,22 @@ public static function createFromExistingFile(
default => false,
};
+ if ($exifData === null) {
+ $exifData = ExifUtil::getExifData($pathname);
+
+ // Remove the `FILE` and `COMPUTED` section because those contain
+ // garbled data anyway and we do not need them in the first place.
+ unset($exifData['FILE'], $exifData['COMPUTED']);
+
+ // We can also discard the `THUMBNAIL` section because it is a
+ // pointless feature and we’re not extracting it either.
+ unset($exifData['THUMBNAIL']);
+
+ if ($exifData === []) {
+ $exifData = null;
+ }
+ }
+
$width = $height = null;
if ($isImage) {
try {
@@ -173,6 +195,7 @@ public static function createFromExistingFile(
'width' => $width,
'height' => $height,
'uploadTime' => $uploadTime,
+ 'exifData' => JSON::encode($exifData),
]]);
$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 35161f33829..98f41099094 100644
--- a/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php
+++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php
@@ -20,6 +20,7 @@
* @property-read int|null $objectTypeID
* @property-read string $context
* @property-read string $chunks
+ * @property-read string|null $exifData
*/
class FileTemporary extends DatabaseObject
{
diff --git a/wcfsetup/install/files/lib/system/WCF.class.php b/wcfsetup/install/files/lib/system/WCF.class.php
index 03006a4fe26..0c9db1db37e 100644
--- a/wcfsetup/install/files/lib/system/WCF.class.php
+++ b/wcfsetup/install/files/lib/system/WCF.class.php
@@ -515,6 +515,9 @@ protected function defineLegacyOptions(): void
// The option to show an article counter in the message sidebar was removed with version 6.2.
\define('MESSAGE_SIDEBAR_ENABLE_ARTICLES', 0);
+
+ // The autoscale quality setting for attachments was removed with version 6.2.
+ \define('ATTACHMENT_IMAGE_AUTOSCALE_QUALITY', 80);
}
/**
diff --git a/wcfsetup/install/files/lib/system/api/composer.json b/wcfsetup/install/files/lib/system/api/composer.json
index 882f3f453a8..e4d1be4d0c9 100644
--- a/wcfsetup/install/files/lib/system/api/composer.json
+++ b/wcfsetup/install/files/lib/system/api/composer.json
@@ -35,7 +35,8 @@
"symfony/polyfill-php83": "^1.32",
"symfony/polyfill-php84": "^1.32",
"symfony/polyfill-php85": "^1.32",
- "willdurand/negotiation": "^3.1"
+ "willdurand/negotiation": "^3.1",
+ "woltlab/webp-exif": "^0.1.1"
},
"replace": {
"paragonie/random_compat": "*",
diff --git a/wcfsetup/install/files/lib/system/api/composer.lock b/wcfsetup/install/files/lib/system/api/composer.lock
index 8b8b60ec67c..315a58c6eda 100644
--- a/wcfsetup/install/files/lib/system/api/composer.lock
+++ b/wcfsetup/install/files/lib/system/api/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "d95d8794edeb0688df52be753a441985",
+ "content-hash": "7bda0b2a726b2865b120f6a816d53491",
"packages": [
{
"name": "brick/math",
@@ -1160,6 +1160,66 @@
},
"time": "2025-01-29T17:44:07+00:00"
},
+ {
+ "name": "nelexa/buffer",
+ "version": "1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Ne-Lexa/php-byte-buffer.git",
+ "reference": "c97bc126d5fbe0c94152fce406a054f681149fac"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Ne-Lexa/php-byte-buffer/zipball/c97bc126d5fbe0c94152fce406a054f681149fac",
+ "reference": "c97bc126d5fbe0c94152fce406a054f681149fac",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Nelexa\\Buffer\\": "src/Nelexa/Buffer"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ne-Lexa",
+ "email": "alexey@nelexa.ru"
+ }
+ ],
+ "description": "Reading And Writing Binary Data (incl. primitive types, ex. byte, ubyte, short, ushort, int, uint, long, float, double). The classes also help with porting the I/O operations of the JAVA code.",
+ "keywords": [
+ "Double",
+ "binary",
+ "byte",
+ "float",
+ "int",
+ "io",
+ "java",
+ "long",
+ "pack",
+ "primitive",
+ "short",
+ "ubyte",
+ "uint",
+ "unpack",
+ "ushort"
+ ],
+ "support": {
+ "issues": "https://github.com/Ne-Lexa/php-byte-buffer/issues",
+ "source": "https://github.com/Ne-Lexa/php-byte-buffer/tree/master"
+ },
+ "time": "2019-05-25T17:47:34+00:00"
+ },
{
"name": "nikic/fast-route",
"version": "2.0.0-beta1",
@@ -3267,6 +3327,56 @@
"source": "https://github.com/willdurand/Negotiation/tree/3.1.0"
},
"time": "2022-01-30T20:08:53+00:00"
+ },
+ {
+ "name": "woltlab/webp-exif",
+ "version": "v0.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/WoltLab/webp-exif.git",
+ "reference": "5056d7114b3622dcd5b655340fdeaac01598997a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/WoltLab/webp-exif/zipball/5056d7114b3622dcd5b655340fdeaac01598997a",
+ "reference": "5056d7114b3622dcd5b655340fdeaac01598997a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-exif": "*",
+ "nelexa/buffer": "^1.3",
+ "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
+ "symfony/polyfill-php84": "^1.31"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-deprecation-rules": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/php-code-coverage": "^12.0",
+ "phpunit/phpunit": "^12.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "WoltLab\\WebpExif\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Extract and embed EXIF metadata from and to WebP images",
+ "keywords": [
+ "Webp",
+ "exif"
+ ],
+ "support": {
+ "issues": "https://github.com/WoltLab/webp-exif/issues",
+ "source": "https://github.com/WoltLab/webp-exif/tree/v0.1.1"
+ },
+ "time": "2025-07-20T11:37:11+00:00"
}
],
"packages-dev": [],
diff --git a/wcfsetup/install/files/lib/system/api/composer/autoload_classmap.php b/wcfsetup/install/files/lib/system/api/composer/autoload_classmap.php
index 88a58ddde97..616e7a7fec1 100644
--- a/wcfsetup/install/files/lib/system/api/composer/autoload_classmap.php
+++ b/wcfsetup/install/files/lib/system/api/composer/autoload_classmap.php
@@ -1224,6 +1224,14 @@
'Negotiation\\Exception\\InvalidMediaType' => $vendorDir . '/willdurand/negotiation/src/Negotiation/Exception/InvalidMediaType.php',
'Negotiation\\LanguageNegotiator' => $vendorDir . '/willdurand/negotiation/src/Negotiation/LanguageNegotiator.php',
'Negotiation\\Negotiator' => $vendorDir . '/willdurand/negotiation/src/Negotiation/Negotiator.php',
+ 'Nelexa\\Buffer\\Buffer' => $vendorDir . '/nelexa/buffer/src/Nelexa/Buffer/Buffer.php',
+ 'Nelexa\\Buffer\\BufferException' => $vendorDir . '/nelexa/buffer/src/Nelexa/Buffer/BufferException.php',
+ 'Nelexa\\Buffer\\Cast' => $vendorDir . '/nelexa/buffer/src/Nelexa/Buffer/Cast.php',
+ 'Nelexa\\Buffer\\FileBuffer' => $vendorDir . '/nelexa/buffer/src/Nelexa/Buffer/FileBuffer.php',
+ 'Nelexa\\Buffer\\MemoryResourceBuffer' => $vendorDir . '/nelexa/buffer/src/Nelexa/Buffer/MemoryResourceBuffer.php',
+ 'Nelexa\\Buffer\\ResourceBuffer' => $vendorDir . '/nelexa/buffer/src/Nelexa/Buffer/ResourceBuffer.php',
+ 'Nelexa\\Buffer\\StringBuffer' => $vendorDir . '/nelexa/buffer/src/Nelexa/Buffer/StringBuffer.php',
+ 'Nelexa\\Buffer\\TempBuffer' => $vendorDir . '/nelexa/buffer/src/Nelexa/Buffer/TempBuffer.php',
'NoDiscard' => $vendorDir . '/symfony/polyfill-php85/Resources/stubs/NoDiscard.php',
'Override' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/Override.php',
'ParagonIE\\ConstantTime\\Base32' => $vendorDir . '/paragonie/constant_time_encoding/src/Base32.php',
@@ -2008,4 +2016,41 @@
'Webmozart\\Assert\\Assert' => $vendorDir . '/webmozart/assert/src/Assert.php',
'Webmozart\\Assert\\InvalidArgumentException' => $vendorDir . '/webmozart/assert/src/InvalidArgumentException.php',
'Webmozart\\Assert\\Mixin' => $vendorDir . '/webmozart/assert/src/Mixin.php',
+ 'WoltLab\\WebpExif\\ChunkType' => $vendorDir . '/woltlab/webp-exif/src/ChunkType.php',
+ 'WoltLab\\WebpExif\\Chunk\\Alph' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Alph.php',
+ 'WoltLab\\WebpExif\\Chunk\\Anim' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Anim.php',
+ 'WoltLab\\WebpExif\\Chunk\\Anmf' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Anmf.php',
+ 'WoltLab\\WebpExif\\Chunk\\Chunk' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Chunk.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\AnimationFrameWithoutBitstream' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Exception/AnimationFrameWithoutBitstream.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\DimensionsExceedInt32' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Exception/DimensionsExceedInt32.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\EmptyAnimationFrame' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Exception/EmptyAnimationFrame.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\ExpectedKeyFrame' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Exception/ExpectedKeyFrame.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\MissingExifExtension' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Exception/MissingExifExtension.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\MissingMagicByte' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Exception/MissingMagicByte.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\UnknownChunkWithKnownFourCC' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Exception/UnknownChunkWithKnownFourCC.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\UnsupportedVersion' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Exception/UnsupportedVersion.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exif' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Exif.php',
+ 'WoltLab\\WebpExif\\Chunk\\Iccp' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Iccp.php',
+ 'WoltLab\\WebpExif\\Chunk\\UnknownChunk' => $vendorDir . '/woltlab/webp-exif/src/Chunk/UnknownChunk.php',
+ 'WoltLab\\WebpExif\\Chunk\\Vp8' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Vp8.php',
+ 'WoltLab\\WebpExif\\Chunk\\Vp8l' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Vp8l.php',
+ 'WoltLab\\WebpExif\\Chunk\\Vp8x' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Vp8x.php',
+ 'WoltLab\\WebpExif\\Chunk\\Xmp' => $vendorDir . '/woltlab/webp-exif/src/Chunk/Xmp.php',
+ 'WoltLab\\WebpExif\\Decoder' => $vendorDir . '/woltlab/webp-exif/src/Decoder.php',
+ 'WoltLab\\WebpExif\\Encoder' => $vendorDir . '/woltlab/webp-exif/src/Encoder.php',
+ 'WoltLab\\WebpExif\\Exception\\ExtraChunksInSimpleFormat' => $vendorDir . '/woltlab/webp-exif/src/Exception/ExtraChunksInSimpleFormat.php',
+ 'WoltLab\\WebpExif\\Exception\\ExtraVp8xChunk' => $vendorDir . '/woltlab/webp-exif/src/Exception/ExtraVp8xChunk.php',
+ 'WoltLab\\WebpExif\\Exception\\FileSizeMismatch' => $vendorDir . '/woltlab/webp-exif/src/Exception/FileSizeMismatch.php',
+ 'WoltLab\\WebpExif\\Exception\\LengthOutOfBounds' => $vendorDir . '/woltlab/webp-exif/src/Exception/LengthOutOfBounds.php',
+ 'WoltLab\\WebpExif\\Exception\\MissingChunks' => $vendorDir . '/woltlab/webp-exif/src/Exception/MissingChunks.php',
+ 'WoltLab\\WebpExif\\Exception\\NotEnoughData' => $vendorDir . '/woltlab/webp-exif/src/Exception/NotEnoughData.php',
+ 'WoltLab\\WebpExif\\Exception\\UnexpectedChunk' => $vendorDir . '/woltlab/webp-exif/src/Exception/UnexpectedChunk.php',
+ 'WoltLab\\WebpExif\\Exception\\UnexpectedEndOfFile' => $vendorDir . '/woltlab/webp-exif/src/Exception/UnexpectedEndOfFile.php',
+ 'WoltLab\\WebpExif\\Exception\\UnrecognizedFileFormat' => $vendorDir . '/woltlab/webp-exif/src/Exception/UnrecognizedFileFormat.php',
+ 'WoltLab\\WebpExif\\Exception\\Vp8xAbsentChunk' => $vendorDir . '/woltlab/webp-exif/src/Exception/Vp8xAbsentChunk.php',
+ 'WoltLab\\WebpExif\\Exception\\Vp8xHeaderLengthMismatch' => $vendorDir . '/woltlab/webp-exif/src/Exception/Vp8xHeaderLengthMismatch.php',
+ 'WoltLab\\WebpExif\\Exception\\Vp8xMissingImageData' => $vendorDir . '/woltlab/webp-exif/src/Exception/Vp8xMissingImageData.php',
+ 'WoltLab\\WebpExif\\Exception\\Vp8xWithoutChunks' => $vendorDir . '/woltlab/webp-exif/src/Exception/Vp8xWithoutChunks.php',
+ 'WoltLab\\WebpExif\\Exception\\WebpExifException' => $vendorDir . '/woltlab/webp-exif/src/Exception/WebpExifException.php',
+ 'WoltLab\\WebpExif\\WebP' => $vendorDir . '/woltlab/webp-exif/src/WebP.php',
);
diff --git a/wcfsetup/install/files/lib/system/api/composer/autoload_files.php b/wcfsetup/install/files/lib/system/api/composer/autoload_files.php
index bc5bdec724d..a4827056e4b 100644
--- a/wcfsetup/install/files/lib/system/api/composer/autoload_files.php
+++ b/wcfsetup/install/files/lib/system/api/composer/autoload_files.php
@@ -11,6 +11,7 @@
'6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php',
'320cde22f66dd4f5d3fd621d3e88b98f' => $vendorDir . '/symfony/polyfill-ctype/bootstrap.php',
'37a3dc5111fe8f707ab4c132ef1dbc62' => $vendorDir . '/guzzlehttp/guzzle/src/functions_include.php',
+ '9d2b9fc6db0f153a0a149fefb182415e' => $vendorDir . '/symfony/polyfill-php84/bootstrap.php',
'2cffec82183ee1cea088009cef9a6fc3' => $vendorDir . '/ezyang/htmlpurifier/library/HTMLPurifier.composer.php',
'07d7f1a47144818725fd8d91a907ac57' => $vendorDir . '/laminas/laminas-diactoros/src/functions/create_uploaded_file.php',
'da94ac5d3ca7d2dbab84ce561ce72bfd' => $vendorDir . '/laminas/laminas-diactoros/src/functions/marshal_headers_from_sapi.php',
@@ -22,6 +23,5 @@
'253c157292f75eb38082b5acb06f3f01' => $vendorDir . '/nikic/fast-route/src/functions.php',
'5897ea0ac4cccf14d323035e65887801' => $vendorDir . '/symfony/polyfill-php82/bootstrap.php',
'662a729f963d39afe703c9d9b7ab4a8c' => $vendorDir . '/symfony/polyfill-php83/bootstrap.php',
- '9d2b9fc6db0f153a0a149fefb182415e' => $vendorDir . '/symfony/polyfill-php84/bootstrap.php',
'606a39d89246991a373564698c2d8383' => $vendorDir . '/symfony/polyfill-php85/bootstrap.php',
);
diff --git a/wcfsetup/install/files/lib/system/api/composer/autoload_psr4.php b/wcfsetup/install/files/lib/system/api/composer/autoload_psr4.php
index ce204dc2b9b..4f1607d1278 100644
--- a/wcfsetup/install/files/lib/system/api/composer/autoload_psr4.php
+++ b/wcfsetup/install/files/lib/system/api/composer/autoload_psr4.php
@@ -6,6 +6,7 @@
$baseDir = $vendorDir;
return array(
+ 'WoltLab\\WebpExif\\' => array($vendorDir . '/woltlab/webp-exif/src'),
'Webmozart\\Assert\\' => array($vendorDir . '/webmozart/assert/src'),
'Symfony\\Polyfill\\Php85\\' => array($vendorDir . '/symfony/polyfill-php85'),
'Symfony\\Polyfill\\Php84\\' => array($vendorDir . '/symfony/polyfill-php84'),
@@ -28,6 +29,7 @@
'Psr\\Cache\\' => array($vendorDir . '/psr/cache/src'),
'Pelago\\Emogrifier\\' => array($vendorDir . '/pelago/emogrifier/src'),
'ParagonIE\\ConstantTime\\' => array($vendorDir . '/paragonie/constant_time_encoding/src'),
+ 'Nelexa\\Buffer\\' => array($vendorDir . '/nelexa/buffer/src/Nelexa/Buffer'),
'Negotiation\\' => array($vendorDir . '/willdurand/negotiation/src/Negotiation'),
'Minishlink\\WebPush\\' => array($vendorDir . '/minishlink/web-push/src'),
'League\\Uri\\' => array($vendorDir . '/league/uri', $vendorDir . '/league/uri-interfaces'),
diff --git a/wcfsetup/install/files/lib/system/api/composer/autoload_static.php b/wcfsetup/install/files/lib/system/api/composer/autoload_static.php
index a6f8b161dcd..84cc2125763 100644
--- a/wcfsetup/install/files/lib/system/api/composer/autoload_static.php
+++ b/wcfsetup/install/files/lib/system/api/composer/autoload_static.php
@@ -12,6 +12,7 @@ class ComposerStaticInita1f5f7c74275d47a45049a2936db1d0d
'6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php',
'320cde22f66dd4f5d3fd621d3e88b98f' => __DIR__ . '/..' . '/symfony/polyfill-ctype/bootstrap.php',
'37a3dc5111fe8f707ab4c132ef1dbc62' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/functions_include.php',
+ '9d2b9fc6db0f153a0a149fefb182415e' => __DIR__ . '/..' . '/symfony/polyfill-php84/bootstrap.php',
'2cffec82183ee1cea088009cef9a6fc3' => __DIR__ . '/..' . '/ezyang/htmlpurifier/library/HTMLPurifier.composer.php',
'07d7f1a47144818725fd8d91a907ac57' => __DIR__ . '/..' . '/laminas/laminas-diactoros/src/functions/create_uploaded_file.php',
'da94ac5d3ca7d2dbab84ce561ce72bfd' => __DIR__ . '/..' . '/laminas/laminas-diactoros/src/functions/marshal_headers_from_sapi.php',
@@ -23,13 +24,13 @@ class ComposerStaticInita1f5f7c74275d47a45049a2936db1d0d
'253c157292f75eb38082b5acb06f3f01' => __DIR__ . '/..' . '/nikic/fast-route/src/functions.php',
'5897ea0ac4cccf14d323035e65887801' => __DIR__ . '/..' . '/symfony/polyfill-php82/bootstrap.php',
'662a729f963d39afe703c9d9b7ab4a8c' => __DIR__ . '/..' . '/symfony/polyfill-php83/bootstrap.php',
- '9d2b9fc6db0f153a0a149fefb182415e' => __DIR__ . '/..' . '/symfony/polyfill-php84/bootstrap.php',
'606a39d89246991a373564698c2d8383' => __DIR__ . '/..' . '/symfony/polyfill-php85/bootstrap.php',
);
public static $prefixLengthsPsr4 = array (
'W' =>
array (
+ 'WoltLab\\WebpExif\\' => 17,
'Webmozart\\Assert\\' => 17,
),
'S' =>
@@ -61,6 +62,7 @@ class ComposerStaticInita1f5f7c74275d47a45049a2936db1d0d
),
'N' =>
array (
+ 'Nelexa\\Buffer\\' => 14,
'Negotiation\\' => 12,
),
'M' =>
@@ -102,6 +104,10 @@ class ComposerStaticInita1f5f7c74275d47a45049a2936db1d0d
);
public static $prefixDirsPsr4 = array (
+ 'WoltLab\\WebpExif\\' =>
+ array (
+ 0 => __DIR__ . '/..' . '/woltlab/webp-exif/src',
+ ),
'Webmozart\\Assert\\' =>
array (
0 => __DIR__ . '/..' . '/webmozart/assert/src',
@@ -192,6 +198,10 @@ class ComposerStaticInita1f5f7c74275d47a45049a2936db1d0d
array (
0 => __DIR__ . '/..' . '/paragonie/constant_time_encoding/src',
),
+ 'Nelexa\\Buffer\\' =>
+ array (
+ 0 => __DIR__ . '/..' . '/nelexa/buffer/src/Nelexa/Buffer',
+ ),
'Negotiation\\' =>
array (
0 => __DIR__ . '/..' . '/willdurand/negotiation/src/Negotiation',
@@ -1495,6 +1505,14 @@ class ComposerStaticInita1f5f7c74275d47a45049a2936db1d0d
'Negotiation\\Exception\\InvalidMediaType' => __DIR__ . '/..' . '/willdurand/negotiation/src/Negotiation/Exception/InvalidMediaType.php',
'Negotiation\\LanguageNegotiator' => __DIR__ . '/..' . '/willdurand/negotiation/src/Negotiation/LanguageNegotiator.php',
'Negotiation\\Negotiator' => __DIR__ . '/..' . '/willdurand/negotiation/src/Negotiation/Negotiator.php',
+ 'Nelexa\\Buffer\\Buffer' => __DIR__ . '/..' . '/nelexa/buffer/src/Nelexa/Buffer/Buffer.php',
+ 'Nelexa\\Buffer\\BufferException' => __DIR__ . '/..' . '/nelexa/buffer/src/Nelexa/Buffer/BufferException.php',
+ 'Nelexa\\Buffer\\Cast' => __DIR__ . '/..' . '/nelexa/buffer/src/Nelexa/Buffer/Cast.php',
+ 'Nelexa\\Buffer\\FileBuffer' => __DIR__ . '/..' . '/nelexa/buffer/src/Nelexa/Buffer/FileBuffer.php',
+ 'Nelexa\\Buffer\\MemoryResourceBuffer' => __DIR__ . '/..' . '/nelexa/buffer/src/Nelexa/Buffer/MemoryResourceBuffer.php',
+ 'Nelexa\\Buffer\\ResourceBuffer' => __DIR__ . '/..' . '/nelexa/buffer/src/Nelexa/Buffer/ResourceBuffer.php',
+ 'Nelexa\\Buffer\\StringBuffer' => __DIR__ . '/..' . '/nelexa/buffer/src/Nelexa/Buffer/StringBuffer.php',
+ 'Nelexa\\Buffer\\TempBuffer' => __DIR__ . '/..' . '/nelexa/buffer/src/Nelexa/Buffer/TempBuffer.php',
'NoDiscard' => __DIR__ . '/..' . '/symfony/polyfill-php85/Resources/stubs/NoDiscard.php',
'Override' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/Override.php',
'ParagonIE\\ConstantTime\\Base32' => __DIR__ . '/..' . '/paragonie/constant_time_encoding/src/Base32.php',
@@ -2279,6 +2297,43 @@ class ComposerStaticInita1f5f7c74275d47a45049a2936db1d0d
'Webmozart\\Assert\\Assert' => __DIR__ . '/..' . '/webmozart/assert/src/Assert.php',
'Webmozart\\Assert\\InvalidArgumentException' => __DIR__ . '/..' . '/webmozart/assert/src/InvalidArgumentException.php',
'Webmozart\\Assert\\Mixin' => __DIR__ . '/..' . '/webmozart/assert/src/Mixin.php',
+ 'WoltLab\\WebpExif\\ChunkType' => __DIR__ . '/..' . '/woltlab/webp-exif/src/ChunkType.php',
+ 'WoltLab\\WebpExif\\Chunk\\Alph' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Alph.php',
+ 'WoltLab\\WebpExif\\Chunk\\Anim' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Anim.php',
+ 'WoltLab\\WebpExif\\Chunk\\Anmf' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Anmf.php',
+ 'WoltLab\\WebpExif\\Chunk\\Chunk' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Chunk.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\AnimationFrameWithoutBitstream' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Exception/AnimationFrameWithoutBitstream.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\DimensionsExceedInt32' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Exception/DimensionsExceedInt32.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\EmptyAnimationFrame' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Exception/EmptyAnimationFrame.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\ExpectedKeyFrame' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Exception/ExpectedKeyFrame.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\MissingExifExtension' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Exception/MissingExifExtension.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\MissingMagicByte' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Exception/MissingMagicByte.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\UnknownChunkWithKnownFourCC' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Exception/UnknownChunkWithKnownFourCC.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exception\\UnsupportedVersion' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Exception/UnsupportedVersion.php',
+ 'WoltLab\\WebpExif\\Chunk\\Exif' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Exif.php',
+ 'WoltLab\\WebpExif\\Chunk\\Iccp' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Iccp.php',
+ 'WoltLab\\WebpExif\\Chunk\\UnknownChunk' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/UnknownChunk.php',
+ 'WoltLab\\WebpExif\\Chunk\\Vp8' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Vp8.php',
+ 'WoltLab\\WebpExif\\Chunk\\Vp8l' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Vp8l.php',
+ 'WoltLab\\WebpExif\\Chunk\\Vp8x' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Vp8x.php',
+ 'WoltLab\\WebpExif\\Chunk\\Xmp' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Chunk/Xmp.php',
+ 'WoltLab\\WebpExif\\Decoder' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Decoder.php',
+ 'WoltLab\\WebpExif\\Encoder' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Encoder.php',
+ 'WoltLab\\WebpExif\\Exception\\ExtraChunksInSimpleFormat' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/ExtraChunksInSimpleFormat.php',
+ 'WoltLab\\WebpExif\\Exception\\ExtraVp8xChunk' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/ExtraVp8xChunk.php',
+ 'WoltLab\\WebpExif\\Exception\\FileSizeMismatch' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/FileSizeMismatch.php',
+ 'WoltLab\\WebpExif\\Exception\\LengthOutOfBounds' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/LengthOutOfBounds.php',
+ 'WoltLab\\WebpExif\\Exception\\MissingChunks' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/MissingChunks.php',
+ 'WoltLab\\WebpExif\\Exception\\NotEnoughData' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/NotEnoughData.php',
+ 'WoltLab\\WebpExif\\Exception\\UnexpectedChunk' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/UnexpectedChunk.php',
+ 'WoltLab\\WebpExif\\Exception\\UnexpectedEndOfFile' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/UnexpectedEndOfFile.php',
+ 'WoltLab\\WebpExif\\Exception\\UnrecognizedFileFormat' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/UnrecognizedFileFormat.php',
+ 'WoltLab\\WebpExif\\Exception\\Vp8xAbsentChunk' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/Vp8xAbsentChunk.php',
+ 'WoltLab\\WebpExif\\Exception\\Vp8xHeaderLengthMismatch' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/Vp8xHeaderLengthMismatch.php',
+ 'WoltLab\\WebpExif\\Exception\\Vp8xMissingImageData' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/Vp8xMissingImageData.php',
+ 'WoltLab\\WebpExif\\Exception\\Vp8xWithoutChunks' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/Vp8xWithoutChunks.php',
+ 'WoltLab\\WebpExif\\Exception\\WebpExifException' => __DIR__ . '/..' . '/woltlab/webp-exif/src/Exception/WebpExifException.php',
+ 'WoltLab\\WebpExif\\WebP' => __DIR__ . '/..' . '/woltlab/webp-exif/src/WebP.php',
);
public static function getInitializer(ClassLoader $loader)
diff --git a/wcfsetup/install/files/lib/system/api/composer/installed.json b/wcfsetup/install/files/lib/system/api/composer/installed.json
index 325226b6c68..51b764f7f0d 100644
--- a/wcfsetup/install/files/lib/system/api/composer/installed.json
+++ b/wcfsetup/install/files/lib/system/api/composer/installed.json
@@ -1199,6 +1199,69 @@
},
"install-path": "../minishlink/web-push"
},
+ {
+ "name": "nelexa/buffer",
+ "version": "1.3.0",
+ "version_normalized": "1.3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Ne-Lexa/php-byte-buffer.git",
+ "reference": "c97bc126d5fbe0c94152fce406a054f681149fac"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Ne-Lexa/php-byte-buffer/zipball/c97bc126d5fbe0c94152fce406a054f681149fac",
+ "reference": "c97bc126d5fbe0c94152fce406a054f681149fac",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8"
+ },
+ "time": "2019-05-25T17:47:34+00:00",
+ "type": "library",
+ "installation-source": "dist",
+ "autoload": {
+ "psr-4": {
+ "Nelexa\\Buffer\\": "src/Nelexa/Buffer"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ne-Lexa",
+ "email": "alexey@nelexa.ru"
+ }
+ ],
+ "description": "Reading And Writing Binary Data (incl. primitive types, ex. byte, ubyte, short, ushort, int, uint, long, float, double). The classes also help with porting the I/O operations of the JAVA code.",
+ "keywords": [
+ "Double",
+ "binary",
+ "byte",
+ "float",
+ "int",
+ "io",
+ "java",
+ "long",
+ "pack",
+ "primitive",
+ "short",
+ "ubyte",
+ "uint",
+ "unpack",
+ "ushort"
+ ],
+ "support": {
+ "issues": "https://github.com/Ne-Lexa/php-byte-buffer/issues",
+ "source": "https://github.com/Ne-Lexa/php-byte-buffer/tree/master"
+ },
+ "install-path": "../nelexa/buffer"
+ },
{
"name": "nikic/fast-route",
"version": "2.0.0-beta1",
@@ -3402,8 +3465,61 @@
"source": "https://github.com/willdurand/Negotiation/tree/3.1.0"
},
"install-path": "../willdurand/negotiation"
+ },
+ {
+ "name": "woltlab/webp-exif",
+ "version": "v0.1.1",
+ "version_normalized": "0.1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/WoltLab/webp-exif.git",
+ "reference": "5056d7114b3622dcd5b655340fdeaac01598997a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/WoltLab/webp-exif/zipball/5056d7114b3622dcd5b655340fdeaac01598997a",
+ "reference": "5056d7114b3622dcd5b655340fdeaac01598997a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-exif": "*",
+ "nelexa/buffer": "^1.3",
+ "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
+ "symfony/polyfill-php84": "^1.31"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-deprecation-rules": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/php-code-coverage": "^12.0",
+ "phpunit/phpunit": "^12.2"
+ },
+ "time": "2025-07-20T11:37:11+00:00",
+ "type": "library",
+ "installation-source": "dist",
+ "autoload": {
+ "psr-4": {
+ "WoltLab\\WebpExif\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Extract and embed EXIF metadata from and to WebP images",
+ "keywords": [
+ "Webp",
+ "exif"
+ ],
+ "support": {
+ "issues": "https://github.com/WoltLab/webp-exif/issues",
+ "source": "https://github.com/WoltLab/webp-exif/tree/v0.1.1"
+ },
+ "install-path": "../woltlab/webp-exif"
}
],
- "dev": true,
+ "dev": false,
"dev-package-names": []
}
diff --git a/wcfsetup/install/files/lib/system/api/composer/installed.php b/wcfsetup/install/files/lib/system/api/composer/installed.php
index d5484f09e21..6ec37435072 100644
--- a/wcfsetup/install/files/lib/system/api/composer/installed.php
+++ b/wcfsetup/install/files/lib/system/api/composer/installed.php
@@ -3,17 +3,17 @@
'name' => '__root__',
'pretty_version' => '6.2.x-dev',
'version' => '6.2.9999999.9999999-dev',
- 'reference' => 'b66a1721eeb9abf3b9b7e52dce7a6ec949c24e6c',
+ 'reference' => 'f6d3061d5091741d703ba7ca3e55e2a1339c1a2d',
'type' => 'project',
'install_path' => __DIR__ . '/../',
'aliases' => array(),
- 'dev' => true,
+ 'dev' => false,
),
'versions' => array(
'__root__' => array(
'pretty_version' => '6.2.x-dev',
'version' => '6.2.9999999.9999999-dev',
- 'reference' => 'b66a1721eeb9abf3b9b7e52dce7a6ec949c24e6c',
+ 'reference' => 'f6d3061d5091741d703ba7ca3e55e2a1339c1a2d',
'type' => 'project',
'install_path' => __DIR__ . '/../',
'aliases' => array(),
@@ -160,6 +160,15 @@
0 => '^1.0',
),
),
+ 'nelexa/buffer' => array(
+ 'pretty_version' => '1.3.0',
+ 'version' => '1.3.0.0',
+ 'reference' => 'c97bc126d5fbe0c94152fce406a054f681149fac',
+ 'type' => 'library',
+ 'install_path' => __DIR__ . '/../nelexa/buffer',
+ 'aliases' => array(),
+ 'dev_requirement' => false,
+ ),
'nikic/fast-route' => array(
'pretty_version' => '2.0.0-beta1',
'version' => '2.0.0.0-beta1',
@@ -522,5 +531,14 @@
'aliases' => array(),
'dev_requirement' => false,
),
+ 'woltlab/webp-exif' => array(
+ 'pretty_version' => 'v0.1.1',
+ 'version' => '0.1.1.0',
+ 'reference' => '5056d7114b3622dcd5b655340fdeaac01598997a',
+ 'type' => 'library',
+ 'install_path' => __DIR__ . '/../woltlab/webp-exif',
+ 'aliases' => array(),
+ 'dev_requirement' => false,
+ ),
),
);
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/.gitignore b/wcfsetup/install/files/lib/system/api/nelexa/buffer/.gitignore
new file mode 100644
index 00000000000..2b080480962
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/.gitignore
@@ -0,0 +1,5 @@
+/vendor
+composer.lock
+.DS_Store
+/.idea
+/.php_cs.cache
\ No newline at end of file
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/.travis.yml b/wcfsetup/install/files/lib/system/api/nelexa/buffer/.travis.yml
new file mode 100644
index 00000000000..d884b7628a9
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/.travis.yml
@@ -0,0 +1,21 @@
+language: php
+php:
+ - '5.4'
+ - '5.5'
+ - '5.6'
+ - '7.1'
+ - '7.2'
+ - '7.3'
+
+cache:
+ directories:
+ - vendor
+ - $HOME/.composer/cache
+
+install:
+ - travis_retry composer self-update && composer --version
+ - travis_retry composer install --no-interaction
+
+script:
+ - composer validate --no-check-lock
+ - vendor/bin/phpunit -c phpunit.xml
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/README.md b/wcfsetup/install/files/lib/system/api/nelexa/buffer/README.md
new file mode 100644
index 00000000000..256d861c5cb
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/README.md
@@ -0,0 +1,456 @@
+# `nelexa/buffer` -> Read And Write Binary Data
+
+[](https://packagist.org/packages/nelexa/buffer)
+[](https://packagist.org/packages/nelexa/buffer)
+[](https://travis-ci.org/Ne-Lexa/php-byte-buffer
+)
+[](https://packagist.org/packages/nelexa/buffer)
+
+This is classes defines methods for **reading and writing** values of all primitive types. Primitive values are translated to (or from) sequences of bytes according to the buffer's current byte order, which may be retrieved and modified via the order methods. The initial order of a byte buffer is always Buffer::BIG_ENDIAN.
+
+### Requirements
+* PHP >= 5.4 (64 bit)
+
+### Installation
+```bash
+composer require nelexa/buffer
+```
+
+### Documentation
+
+Class `\Nelexa\Buffer` is abstract and base methods for all other buffers.
+
+Initialize buffer as string.
+```php
+$buffer = new \Nelexa\StringBuffer();
+// or
+$buffer = new \Nelexa\StringBuffer($text);
+```
+
+Initialize buffer as file.
+```php
+$buffer = new \Nelexa\FileBuffer($filename);
+```
+
+Initialize buffer as memory resource (php://memory).
+```php
+$buffer = new \Nelexa\MemoryReourceBuffer();
+// or
+$buffer = new \Nelexa\MemoryReourceBuffer($text);
+```
+
+Initialize buffer as stream resource.
+```php
+$fp = fopen('php://temp', 'w+b');
+// or
+$buffer = new \Nelexa\ResourceBuffer($fp);
+```
+
+Set read only buffer
+```php
+$buffer->setReadOnly(true);
+```
+
+Checking the possibility of recording in the buffer
+```php
+$boolValue = $buffer->isReadOnly();
+```
+
+Modifies this buffer's byte order, either `Buffer::BIG_ENDIAN` or `Buffer::LITTLE_ENDIAN`
+```php
+$buffer->setOrder(\Nelexa\Buffer::LITTLE_ENDIAN);
+```
+
+Get buffer's byte order
+```php
+$byteOrder = $buffer->order();
+```
+
+Get buffer size.
+```php
+$size = $buffer->size();
+```
+
+Set buffer position.
+```php
+$buffer->setPosition($position);
+```
+
+Get buffer position.
+```php
+$position = $buffer->position();
+```
+
+Skip bytes.
+```php
+$buffer->skip($count);
+
+// example
+$buffer->insertString('Test value');
+assert($buffer->position() === 10);
+$buffer->skip(-7);
+assert($buffer->position() === 3);
+$buffer->skip(2);
+assert($buffer->position() === 5);
+```
+
+Skip primitive type size.
+```php
+$buffer->skipByte(); // skip 1 byte
+$buffer->skipShort(); // skip 2 bytes
+$buffer->skipInt(); // skip 4 bytes
+$buffer->skipLong(); // skip 8 bytes
+$buffer->skipFloat(); // skip 4 bytes
+$buffer->skipDouble(); // skip 8 bytes
+```
+
+Rewinds this buffer. The position is set to zero.
+```php
+$buffer->rewind();
+
+// example
+$buffer->insertString('Test value');
+assert($buffer->position() === 10);
+$buffer->rewind();
+assert($buffer->position() === 0);
+assert($buffer->size() === 10);
+```
+
+Flips this buffer. The limit is set to the current position and then the position is set to zero.
+```php
+$buffer->flip();
+
+// example
+$buffer->insertString('Test value');
+assert($buffer->position() === 10);
+$buffer->setPosition(5);
+$buffer->flip();
+assert($buffer->position() === 0);
+assert($buffer->size() === 5);
+```
+
+Returns the number of elements between the current position and the limit.
+```php
+$remaining = $buffer->remaining();
+
+// example
+$buffer->insertString('Test value');
+assert($buffer->position() === 10);
+$buffer->setPosition(7);
+assert($buffer->remaining() === 10 - 7);
+```
+
+Tells whether there are any elements between the current position and the limit. True if, and only if, there is at least one element remaining in this buffer
+```php
+$boolValue = $buffer->hasRemaining();
+
+// example
+$buffer->insertString('Test value');
+assert($buffer->position() === 10);
+assert($buffer->hasRemaining() === false);
+$buffer->setPosition(9);
+assert($buffer->hasRemaining() === true);
+```
+
+Close buffer and release resources
+```php
+$buffer->close();
+```
+
+### Read buffer
+
+Read the entire contents of the buffer into a string without changing the position of the buffer.
+```php
+$allBufferContent = $buffer->toString();
+
+// example
+$buffer->insertString('Test value');
+assert($buffer->position() === 10);
+$buffer->setPosition(4);
+$allBufferContent = $buffer->toString();
+assert($buffer->position() === 4);
+assert($allBufferContent === 'Test value');
+```
+
+Reads the string at this buffer's current position, and then increments the position.
+```php
+$content = $buffer->get($length);
+
+// example
+$buffer->insertString('Test value');
+assert($buffer->position() === 10);
+$buffer->setPosition(3);
+$content = $buffer->get(5);
+assert($buffer->position() === 8);
+assert($content === 't val');
+```
+##### Read literal types
+Method | Type | Values
+--------------------------------- | ----------------------- | -----------------
+`$buffer->getBoolean` | boolean | `true` or `false`
+`$buffer->getByte()` | byte | -128 ... 127
+`$buffer->getUnsignedByte()` | unsigned byte (ubyte) | 0 ... 255
+`$buffer->getShort()` | short (2 bytes) | -32768 ... 32767
+`$buffer->getUnsignedShort()` | unsigned short (ushort) | 0 ... 65535
+`$buffer->getInt()` | int (4 bytes) | -2147483648 ... 2147483647
+`$buffer->getUnsignedInt()` | unsigned int (uint) | 0 ... 4294967296
+`$buffer->getLong()` | long (8 bytes) | -9223372036854775808 ... 9223372036854775807
+`$buffer->getFloat()` | float (4 bytes) | single-precision 32-bit IEEE 754 floating point number
+`$buffer->getDouble()` | double (5 bytes) | double-precision 64-bit IEEE 754 floating point number
+`$buffer->getArrayBytes($length)` | byte[] | `array`
+`$buffer->getString($length)` | string (length bytes) | `string`
+`$buffer->getUTF()` | string | `string`
+`$buffer->getUTF16($length)` | string (length * 2) | `string`
+
+
+### Write to buffer
+
+#### Insert bytes to buffer
+Insert string (byte[]) or Buffer to buffer.
+```php
+$buffer->insert('content');
+// or
+$buffer->insert(new StringBuffer('Other buffer'));
+
+// example
+assert($buffer->position() === 0);
+assert($buffer->size() === 0);
+$buffer->insert('Test value');
+assert($buffer->position() === 10);
+assert($buffer->size() === 10);
+$buffer->setPosition(4);
+$buffer->insert('ed');
+assert($buffer->position() === 6);
+assert($buffer->size() === 12);
+assert($buffer->toString() === 'Tested value');
+```
+##### Insert primitive types
+Insert boolean value `false` or `true`. Change size and position by +1.
+```php
+$buffer->insertBoolean($boolValue);
+```
+Insert byte (-128 >= byte <= 127). Change size and position by +1.
+```php
+$buffer->insertByte($byteValue);
+```
+Insert short value (-32768 >= short <= 32767). Change size and position by +2.
+```php
+$buffer->insertShort($shortValue);
+```
+Insert integer value (-2147483648 >= int <= 2147483647). Change size and position by +4.
+```php
+$buffer->insertInt($intValue);
+```
+Insert long value (-9223372036854775808 >= long <= 9223372036854775807). Change position +8.
+```php
+$buffer->insertLong($longValue);
+```
+Insert float value (single-precision 32-bit IEEE 754 floating point number). Change position +4.
+```php
+$buffer->insertFloat($floatValue);
+```
+Insert double value (double-precision 64-bit IEEE 754 floating point number). Change position +8.
+```php
+$buffer->insertDouble($doubleValue);
+```
+Insert array bytes. Change size and position by +(size array).
+```php
+$buffer->insertArrayBytes($bytes);
+```
+Insert string value. Change size and position by +(length string).
+```php
+$buffer->insertString($string);
+```
+Insert UTF-8 string with encoding first two bytes as length string. Change size and position by +(2 + length string).
+
+Analog java [java.io.DataOutputStream#writeUTF(String str)](https://docs.oracle.com/javase/8/docs/api/java/io/DataOutputStream.html#writeUTF-java.lang.String-)
+```php
+$buffer->insertUTF($string);
+```
+Insert string with UTF-16 encoding. Change size and position by +(2 * length string).
+```php
+$buffer->insertUTF16($string);
+```
+#### Put bytes to buffer
+Put string (byte[]) or Buffer to buffer and overwrite old value.
+```php
+$buffer->put('content');
+// or
+$buffer->put(new StringBuffer('Other buffer'));
+
+// example
+assert($buffer->position() === 0);
+assert($buffer->size() === 0);
+$buffer->insert('Test value');
+assert($buffer->position() === 10);
+assert($buffer->size() === 10);
+$buffer->setPosition(4);
+$buffer->put('ed');
+assert($buffer->position() === 6);
+assert($buffer->size() === 10);
+assert($buffer->toString() === 'Testedalue');
+```
+##### Put primitive types
+Put boolean value `false` or `true`. Change position by +1.
+```php
+$buffer->putBoolean($boolValue);
+```
+Put byte (-128 >= byte <= 127). Change position by +1.
+```php
+$buffer->putByte($byteValue);
+```
+Put short value (-32768 >= short <= 32767). Change position by +2.
+```php
+$buffer->putShort($shortValue);
+```
+Put integer value (-2147483648 >= int <= 2147483647). Change position by +4.
+```php
+$buffer->putInt($intValue);
+```
+Put long value (-9223372036854775808 >= long <= 9223372036854775807). Change position by +8.
+```php
+$buffer->putLong($longValue);
+```
+Put float value (single-precision 32-bit IEEE 754 floating point number). Change position +4.
+```php
+$buffer->putFloat($floatValue);
+```
+Put double value (double-precision 64-bit IEEE 754 floating point number). Change position +8.
+```php
+$buffer->putDouble($doubleValue);
+```
+Put array bytes. Change position by +(size array).
+```php
+$buffer->putArrayBytes($bytes);
+```
+Insert string value. Change position by +(length string).
+```php
+$buffer->putString($string);
+```
+Put UTF-8 string with encoding first two bytes as length string. Change position by +(2 + length string).
+
+Analog java [java.io.DataOutputStream#writeUTF(String str)](https://docs.oracle.com/javase/8/docs/api/java/io/DataOutputStream.html#writeUTF-java.lang.String-)
+```php
+$buffer->puttUTF($string);
+```
+Put string with UTF-16 encoding. Change position by +(2 * length string).
+```php
+$buffer->putUTF16($string);
+```
+#### Replace bytes by buffer
+Replace following a certain number of bytes by string or another Buffer.
+```php
+$buffer->replace('content', $length);
+// or
+$buffer->insert(new StringBuffer('Other buffer'), $length);
+
+// example
+assert($buffer->position() === 0);
+assert($buffer->size() === 0);
+$buffer->insert('Test value');
+assert($buffer->position() === 10);
+assert($buffer->size() === 10);
+$buffer->setPosition(4);
+$buffer->replace('ed', 4); // remove 4 next bytes and insert 2 bytes
+assert($buffer->position() === 6);
+assert($buffer->size() === 8);
+assert($buffer->toString() === 'Testedlue');
+```
+##### Replace by primitive types
+Replace by boolean value `false` or `true`. Change size by (-$length + 1) and position +1.
+```php
+$buffer->replaceBoolean($boolValue, $length);
+```
+Replace by byte (-128 >= byte <= 127). Change size by (-$length + 1) and position +1.
+```php
+$buffer->replaceByte($byteValue, $length);
+```
+Replace by short value (-32768 >= short <= 32767). Change size by (-$length + 2) and position +2.
+```php
+$buffer->replaceShort($shortValue, $length);
+```
+Replace by integer value (-2147483648 >= int <= 2147483647). Change size by (-$length + 4) and position +4.
+```php
+$buffer->replaceInt($intValue, $length);
+```
+Replace by long value (-9223372036854775808 >= long <= 9223372036854775807). Change size by (-$length + 8) and position +8.
+```php
+$buffer->replaceLong($longValue, $length);
+```
+Replace by float value (single-precision 32-bit IEEE 754 floating point number). Change size by (-$length + 4) and position +4.
+```php
+$buffer->replaceFloat($floatValue, $length);
+```
+Replace by double value (double-precision 64-bit IEEE 754 floating point number). Change size by (-$length + 8) and position +8.
+```php
+$buffer->replaceDouble($doubleValue, $length);
+```
+Replace by array bytes. Change size by (-$length + size array) and position +(size array).
+```php
+$buffer->replaceArrayBytes($bytes, $length);
+```
+Replace by string value. Change size by (-$length + length string) and position +(length string).
+```php
+$buffer->replaceString($string, $length);
+```
+Replace by UTF-8 string with encoding first two bytes as length string. Change size by (-$length + 2 + length string) and position +(2 + length string).
+
+Analog java [java.io.DataOutputStream#writeUTF(String str)](https://docs.oracle.com/javase/8/docs/api/java/io/DataOutputStream.html#writeUTF-java.lang.String-)
+```php
+$buffer->replaceUTF($string, $length);
+```
+Replace by string with UTF-16 encoding. Change size by (-$length + 2 * length string) and position +(2 * length string).
+```php
+$buffer->replaceUTF16($string, $length);
+```
+
+### Remove bytes by buffer
+Remove a certain number of bytes. Change size by -$length.
+```php
+$buffer->remove($length);
+
+// example
+assert($buffer->position() === 0);
+assert($buffer->size() === 0);
+$buffer->insert('Test value');
+assert($buffer->position() === 10);
+assert($buffer->size() === 10);
+$buffer->setPosition(4);
+$buffer->remove(3); // remove 3 next bytes
+assert($buffer->position() === 4);
+assert($buffer->size() === 7);
+assert($buffer->toString() === 'Testlue');
+```
+Remove all bytes. Truncate buffer.
+```php
+$buffer->truncate($size = 0);
+
+// example
+assert($buffer->position() === 0);
+assert($buffer->size() === 0);
+$buffer->insert('Test value');
+assert($buffer->position() === 10);
+assert($buffer->size() === 10);
+$buffer->truncate(0);
+assert($buffer->position() === 0);
+assert($buffer->size() === 0);
+```
+
+### Fluent interface
+```php
+// example
+($buffer = new StringBuffer())
+ ->insertByte(1)
+ ->insertBoolean(true)
+ ->insertShort(5551)
+ ->skip(-2)
+ ->insertUTF("Hello, World")
+ ->truncate()
+ ->insertString(str_rot13('Hello World'))
+ ->setPosition(7)
+ ->flip();
+
+assert($this->buffer->size() === 7);
+assert($this->buffer->position() === 0);
+assert($this->buffer->toString() === str_rot13('Hello W'));
+```
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/composer.json b/wcfsetup/install/files/lib/system/api/nelexa/buffer/composer.json
new file mode 100644
index 00000000000..037da700654
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/composer.json
@@ -0,0 +1,45 @@
+{
+ "name": "nelexa/buffer",
+ "description": "Reading And Writing Binary Data (incl. primitive types, ex. byte, ubyte, short, ushort, int, uint, long, float, double). The classes also help with porting the I/O operations of the JAVA code.",
+ "type": "library",
+ "keywords": [
+ "binary",
+ "primitive",
+ "byte",
+ "ubyte",
+ "short",
+ "ushort",
+ "int",
+ "uint",
+ "long",
+ "float",
+ "double",
+ "io",
+ "java",
+ "pack",
+ "unpack"
+ ],
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Ne-Lexa",
+ "email": "alexey@nelexa.ru"
+ }
+ ],
+ "require": {
+ "php": ">=5.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8"
+ },
+ "autoload": {
+ "psr-4": {
+ "Nelexa\\Buffer\\": "src/Nelexa/Buffer"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Nelexa\\Buffer\\": "tests/Nelexa/Buffer"
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/phpunit.xml b/wcfsetup/install/files/lib/system/api/nelexa/buffer/phpunit.xml
new file mode 100644
index 00000000000..65634f9e237
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/phpunit.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+ tests/
+
+
+
+
+ src/
+
+
+
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/Buffer.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/Buffer.php
new file mode 100644
index 00000000000..ec92b2a4b2f
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/Buffer.php
@@ -0,0 +1,1170 @@
+position;
+ }
+
+ /**
+ * Rewinds this buffer. The position is set to zero.
+ *
+ * Invoke this method before a sequence of channel-write or get
+ * operations, assuming that the limit has already been set
+ * appropriately.
+ *
+ * For example:
+ *
+ * $buf->writeString("Hello"); // Write remaining data
+ * $buf->rewind(); // Rewind buffer
+ * $buf->get(5); // get 5 bytes (Hello)
+ *
+ * @return Buffer
+ * @throws BufferException
+ */
+ final public function rewind()
+ {
+ return $this->setPosition(0);
+ }
+
+ /**
+ * Set buffer position.
+ *
+ * @param int $position
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function setPosition($position)
+ {
+ $position = (int)$position;
+ if ($position > $this->limit) {
+ throw new BufferException('Set position ' . $position . ' invalid. Exceeded limit ' . $this->limit);
+ }
+ $this->position = $position;
+ return $this;
+ }
+
+ /**
+ * Flips this buffer. The limit is set to the current position and then
+ * the position is set to zero.
+ *
+ * After a sequence of channel-read or put operations, invoke
+ * this method to prepare for a sequence of channel-write or relative
+ * get operations.
+ *
+ * @return Buffer
+ */
+ abstract public function flip();
+
+ /**
+ * Returns the number of elements between the current position and the
+ * limit.
+ *
+ * @return int The number of elements remaining in this buffer
+ */
+ public function remaining()
+ {
+ return $this->limit - $this->position;
+ }
+
+ /**
+ * Tells whether there are any elements between the current position and
+ * the limit.
+ *
+ * @return boolean true if, and only if, there is at least one element remaining in this buffer
+ */
+ public function hasRemaining()
+ {
+ return $this->position < $this->limit;
+ }
+
+ /**
+ * Modifies this buffer's byte order.
+ *
+ * @param string $order The new byte order, either Buffer::BIG_ENDIAN or Buffer::LITTLE_ENDIAN
+ * @return Buffer
+ * @see Buffer::BIG_ENDIAN
+ * @see Buffer::LITTLE_ENDIAN
+ *
+ */
+ final public function setOrder($order)
+ {
+ $this->orderLittleEndian = $order === self::LITTLE_ENDIAN;
+ return $this;
+ }
+
+ /**
+ * Set read only buffer.
+ *
+ * @param boolean $isReadOnly
+ * @return Buffer
+ */
+ public function setReadOnly($isReadOnly)
+ {
+ $this->isReadOnly = $isReadOnly;
+ return $this;
+ }
+
+ /**
+ * Skip 1 byte
+ *
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function skipByte()
+ {
+ return $this->skip(1);
+ }
+
+ /**
+ * Skip number bytes.
+ *
+ * @param int $n The number of bytes to be skipped. The value may be negative.
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function skip($n)
+ {
+ return $this->setPosition($this->position + $n);
+ }
+
+ /**
+ * Skip short (2 bytes)
+ *
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function skipShort()
+ {
+ return $this->skip(2);
+ }
+
+ /**
+ * Skip int (4 bytes)
+ *
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function skipInt()
+ {
+ return $this->skip(4);
+ }
+
+ /**
+ * Skip long (8 bytes)
+ *
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function skipLong()
+ {
+ return $this->skip(8);
+ }
+
+ /**
+ * Skip float (4 bytes)
+ *
+ * @return $this
+ * @throws BufferException
+ */
+ public function skipFloat()
+ {
+ return $this->skip(4);
+ }
+
+ /**
+ * Skip double (8 bytes)
+ *
+ * @return $this
+ * @throws BufferException
+ */
+ public function skipDouble()
+ {
+ return $this->skip(8);
+ }
+
+ /**
+ * Reads one input byte and returns true if that byte is nonzero,
+ * false if that byte is zero.
+ *
+ * @return bool the boolean value read.
+ * @throws BufferException
+ */
+ public function getBoolean()
+ {
+ return (bool)$this->getUnsignedByte();
+ }
+
+ /**
+ * Reads one input byte, zero-extends
+ * it to type int, and returns
+ * the result, which is therefore in the range
+ * 0 through 255.
+ *
+ * @return int the unsigned 8-bit value read.
+ * @throws BufferException
+ */
+ public function getUnsignedByte()
+ {
+ return unpack('C', $this->get(1))[1];
+ }
+
+ /**
+ * Relative get method.
+ * Reads the string at this buffer's current position, and then increments the position.
+ *
+ * @param int $length
+ * @return string The strings at the buffer's current position
+ * @throws BufferException
+ */
+ abstract protected function get($length);
+
+ /**
+ * Reads and returns one input byte.
+ * The byte is treated as a signed value in
+ * the range -128 through 127, inclusive.
+ *
+ * @return int the 8-bit value read.
+ * @throws BufferException
+ */
+ public function getByte()
+ {
+ return Cast::toByte($this->getUnsignedByte());
+ }
+
+ /**
+ * Reads two input bytes and returns
+ * a short value in the range -32768 through 32767.
+ *
+ * @return int the 16-bit value read.
+ * @throws BufferException
+ */
+ public function getShort()
+ {
+ return Cast::toShort($this->getUnsignedShort());
+ }
+
+ /**
+ * Reads two input bytes and returns
+ * an int value in the range 0 through 65535.
+ *
+ * @return int the unsigned 16-bit value read.
+ * @throws BufferException
+ */
+ public function getUnsignedShort()
+ {
+ return unpack($this->orderLittleEndian ? 'v' : 'n', $this->get(2))[1];
+ }
+
+ /**
+ * Reads four input bytes and returns an unsigned short value
+ * in the range -2147483648 through 2147483647.
+ *
+ * @return int the int value read.
+ * @throws BufferException
+ */
+ public function getInt()
+ {
+ return Cast::toInt($this->getUnsignedInt());
+ }
+
+ /**
+ * Reads four input bytes and returns an unsigned int value
+ * in the range 0 through 4294967296.
+ *
+ * @return int the unsigned int value read.
+ * @throws BufferException
+ */
+ public function getUnsignedInt()
+ {
+ return unpack($this->orderLittleEndian ? 'V' : 'N', $this->get(4))[1];
+ }
+
+ /**
+ * Reads eight input bytes and returns a long value
+ * in the range -9223372036854775808 through 9223372036854775807.
+ *
+ * @return string|int the long value read.
+ * @throws BufferException
+ */
+ public function getLong()
+ {
+ $data = $this->get(8);
+ if (PHP_VERSION_ID >= 50603) {
+ return unpack($this->orderLittleEndian ? 'P' : 'J', $data)[1];
+ }
+
+ if ($this->orderLittleEndian) {
+ $unpack = unpack('Va/Vb', $data);
+ return $unpack['a'] + ($unpack['b'] << 32);
+ }
+
+ $unpack = unpack('Na/Nb', $data);
+ return ($unpack['a'] << 32) | $unpack['b'];
+ }
+
+ /**
+ * Reads four input bytes and returns a float value
+ *
+ * @return float the float value read.
+ * @throws BufferException
+ */
+ public function getFloat()
+ {
+ self::checkPhpSupport();
+ return unpack($this->orderLittleEndian ? 'g' : 'G', $this->get(4))[1];
+ }
+
+ /**
+ * Reads four input bytes and returns a double value
+ *
+ * @return double the double value read.
+ * @throws BufferException
+ */
+ public function getDouble()
+ {
+ self::checkPhpSupport();
+ return unpack($this->orderLittleEndian ? 'e' : 'E', $this->get(8))[1];
+ }
+
+ /**
+ * Reads $length bytes from an input stream.
+ *
+ * @param $length int
+ * @return int[]
+ * @throws BufferException
+ */
+ public function getArrayBytes($length)
+ {
+ if ($length > 0) {
+ return array_values(
+ unpack('c*', $this->get($length))
+ );
+ }
+ return [];
+ }
+
+ /**
+ * Reads in a string that has been encoded using
+ * a modified UTF-8 format.
+ *
+ * First, two bytes are read and used to
+ * construct an unsigned 16-bit integer in
+ * exactly the manner of the Buffer::readUnsignedShort()
+ * method. This integer value is called the UTF length
+ * and specifies the number of additional bytes to be read.
+ *
+ * Analog java @see java.io.DataOutputStream#readUTF()
+ *
+ * @return string
+ * @throws BufferException
+ */
+ public function getUTF()
+ {
+ $size = $this->getUnsignedShort();
+ if ($size > 0) {
+ return $this->getString($size);
+ }
+ return '';
+ }
+
+ /**
+ * Reads $length input bytes and returns a string value.
+ *
+ * @param $length int
+ * @return string
+ * @throws BufferException
+ */
+ public function getString($length)
+ {
+ if ($length > 0) {
+ return $this->get($length);
+ }
+ return '';
+ }
+
+ /**
+ * Reads $length * 2 input bytes and returns a string value.
+ *
+ * @param $length int
+ * @return string
+ * @throws BufferException
+ * @deprecated
+ */
+ public function getUTF16($length)
+ {
+ if ($length > 0) {
+ return implode('', array_map('chr', array_values(unpack('S*', $this->get($length << 1)))));
+ }
+ return '';
+ }
+
+ /**
+ * Insert boolean value
+ *
+ * @param $bool
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function insertBoolean($bool)
+ {
+ return $this->insert($this->writeBoolean($bool));
+ }
+
+ /**
+ * Insert Buffer or string.
+ *
+ * @param Buffer|string $buffer
+ * @return Buffer
+ * @throws BufferException
+ */
+ abstract public function insert($buffer);
+
+ /**
+ * @param bool $bool
+ * @return string
+ * @throws BufferException
+ */
+ protected function writeBoolean($bool)
+ {
+ if ($bool === null) {
+ throw new BufferException('null boolean');
+ }
+ return pack('c', $bool ? 1 : 0);
+ }
+
+ /**
+ * Insert byte (-128 >= byte <= 127)
+ *
+ * @param int|string $byte
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function insertByte($byte)
+ {
+ return $this->insert($this->writeByte($byte));
+ }
+
+ /**
+ * @param int|string $byte
+ * @return string
+ * @throws BufferException
+ */
+ protected function writeByte($byte)
+ {
+ if ($byte === null) {
+ throw new BufferException('null byte');
+ }
+ return pack('c', $byte);
+ }
+
+ /**
+ * Insert short value (-32768 >= short <= 32767)
+ *
+ * @param int|string $v
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function insertShort($v)
+ {
+ return $this->insert($this->writeShort($v));
+ }
+
+ /**
+ * @param int|string $v
+ * @return string
+ * @throws BufferException
+ */
+ protected function writeShort($v)
+ {
+ if ($v === null) {
+ throw new BufferException('null short');
+ }
+ return pack($this->orderLittleEndian ? 'v' : 'n', $v);
+ }
+
+ /**
+ * Insert integer value (-2147483648 >= int <= 2147483647)
+ *
+ * @param int|string $v
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function insertInt($v)
+ {
+ return $this->insert($this->writeInt($v));
+ }
+
+ /**
+ * @param int|string $v
+ * @return string
+ * @throws BufferException
+ */
+ protected function writeInt($v)
+ {
+ if ($v === null) {
+ throw new BufferException('null int');
+ }
+ return pack($this->orderLittleEndian ? 'V' : 'N', $v);
+ }
+
+ /**
+ * Insert long value (-9223372036854775808 >= long <= 9223372036854775807)
+ *
+ * @param int|string $v
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function insertLong($v)
+ {
+ return $this->insert($this->writeLong($v));
+ }
+
+ /**
+ * @param int|string $v
+ * @return string
+ * @throws BufferException
+ */
+ protected function writeLong($v)
+ {
+ if ($v === null) {
+ throw new BufferException('null long');
+ }
+ if (PHP_VERSION_ID >= 50603) {
+ return pack($this->orderLittleEndian ? 'P' : 'J', $v);
+ }
+
+ $left = 0xffffffff00000000;
+ $right = 0x00000000ffffffff;
+ if ($this->orderLittleEndian) {
+ $r = ($v & $left) >> 32;
+ $l = $v & $right;
+ return pack('VV', $l, $r);
+ }
+
+ $l = ($v & $left) >> 32;
+ $r = $v & $right;
+ return pack('NN', $l, $r);
+ }
+
+ /**
+ * Insert float value
+ *
+ * @param float $v
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function insertFloat($v)
+ {
+ return $this->insert($this->writeFloat($v));
+ }
+
+ /**
+ * @param float $v
+ * @return string
+ * @throws BufferException
+ */
+ protected function writeFloat($v)
+ {
+ self::checkPhpSupport();
+ if ($v === null) {
+ throw new BufferException('null float');
+ }
+ return pack($this->orderLittleEndian ? 'g' : 'G', $v);
+ }
+
+ /**
+ * Insert double value
+ *
+ * @param double $v
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function insertDouble($v)
+ {
+ return $this->insert($this->writeDouble($v));
+ }
+
+ /**
+ * @param double $v
+ * @return string
+ * @throws BufferException
+ */
+ protected function writeDouble($v)
+ {
+ self::checkPhpSupport();
+ if ($v === null) {
+ throw new BufferException('null double');
+ }
+ return pack($this->orderLittleEndian ? 'e' : 'E', $v);
+ }
+
+ /**
+ * Insert string
+ *
+ * @param string $string
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function insertString($string)
+ {
+ return $this->insert($this->writeString($string));
+ }
+
+ /**
+ * @param string $string
+ * @return string
+ */
+ protected function writeString($string)
+ {
+ return $string;
+ }
+
+ /**
+ * Insert array bytes
+ *
+ * @param array $bytes
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function insertArrayBytes(array $bytes)
+ {
+ return $this->insert($this->writeArrayBytes($bytes));
+ }
+
+ /**
+ * @param array $bytes
+ * @return string
+ */
+ protected function writeArrayBytes(array $bytes)
+ {
+ return call_user_func_array('pack', array_merge(['c*'], $bytes));
+ }
+
+ /**
+ * Writes a string to the underlying output stream using
+ * modified UTF-8 encoding in a machine-independent manner.
+ *
+ * @param string $string
+ * @return Buffer
+ * @throws BufferException
+ * @see Buffer::writeUTF()
+ *
+ */
+ public function insertUTF($string)
+ {
+ return $this->insert($this->writeUTF($string));
+ }
+
+ /**
+ * Writes a string to the underlying output stream using
+ * modified UTF-8 encoding in a machine-independent manner.
+ *
+ * First, two bytes are written to the output stream as if by the
+ * Buffer::writeShort() method giving the number of bytes to
+ * follow. This value is the number of bytes actually written out,
+ * not the length of the string.
+ *
+ * Analog java @see java.io.DataOutputStream#writeUTF()
+ *
+ * @param string $str
+ * @return string
+ * @throws BufferException
+ */
+ protected function writeUTF($str)
+ {
+ if ($str === null) {
+ throw new BufferException('$str is null');
+ }
+ $bytes = unpack('c*', $str);
+ $length = count($bytes);
+ if ($length > 65535) {
+ throw new BufferException('Encoded string too long: ' . $length . ' bytes');
+ }
+ array_unshift($bytes, 'c*');
+ return $this->writeShort($length) . call_user_func_array('pack', $bytes);
+ }
+
+ /**
+ * Insert UTF16 string
+ *
+ * @param string $string
+ * @return Buffer
+ * @throws BufferException
+ * @deprecated
+ */
+ public function insertUTF16($string)
+ {
+ return $this->insert($this->writeUTF16($string));
+ }
+
+ /**
+ * @param string $string
+ * @return string
+ * @throws BufferException
+ * @deprecated
+ */
+ protected function writeUTF16($string)
+ {
+ if ($string === null) {
+ throw new BufferException('$string is null');
+ }
+ $args = array_map('ord', str_split($string));
+ array_unshift($args, 'S*');
+ return call_user_func_array('pack', $args);
+ }
+
+ /**
+ * Put boolean value
+ *
+ * @param $bool
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function putBoolean($bool)
+ {
+ return $this->put($this->writeBoolean($bool));
+ }
+
+ /**
+ * Relative put method (optional operation).
+ *
+ * Writes the given string into this buffer at the current
+ * position, and then increments the position.
+ *
+ * @param Buffer|string $buffer
+ * @return Buffer
+ * @throws BufferException
+ */
+ abstract public function put($buffer);
+
+ /**
+ * Put byte (-128 >= byte <= 127)
+ *
+ * @param int|string $byte
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function putByte($byte)
+ {
+ return $this->put($this->writeByte($byte));
+ }
+
+ /**
+ * Put short value (-32768 >= short <= 32767)
+ *
+ * @param int|string $v
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function putShort($v)
+ {
+ return $this->put($this->writeShort($v));
+ }
+
+ /**
+ * Put integer value (-2147483648 >= int <= 2147483647)
+ *
+ * @param int|string $v
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function putInt($v)
+ {
+ return $this->put($this->writeInt($v));
+ }
+
+ /**
+ * Put long value (-9223372036854775808 >= long <= 9223372036854775807)
+ *
+ * @param int|string $v
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function putLong($v)
+ {
+ return $this->put($this->writeLong($v));
+ }
+
+ /**
+ * Put float value
+ *
+ * @param float $v
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function putFloat($v)
+ {
+ return $this->put($this->writeFloat($v));
+ }
+
+ /**
+ * Put double value
+ *
+ * @param double $v
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function putDouble($v)
+ {
+ return $this->put($this->writeDouble($v));
+ }
+
+ /**
+ * Put string
+ *
+ * @param string $string
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function putString($string)
+ {
+ return $this->put($this->writeString($string));
+ }
+
+ /**
+ * Put array bytes
+ *
+ * @param array $bytes
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function putArrayBytes(array $bytes)
+ {
+ return $this->put($this->writeArrayBytes($bytes));
+ }
+
+ /**
+ * Put UTF string (Format - java DataOutputStream.writeUTF)
+ *
+ * @param string $str
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function putUTF($str)
+ {
+ return $this->put($this->writeUTF($str));
+ }
+
+ /**
+ * Put UTF16 string
+ *
+ * @param string $str
+ * @return Buffer
+ * @throws BufferException
+ * @deprecated
+ */
+ public function putUTF16($str)
+ {
+ return $this->put($this->writeUTF16($str));
+ }
+
+ /**
+ * Replace by boolean value
+ *
+ * @param bool $bool
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replaceBoolean($bool, $length)
+ {
+ return $this->replace($this->writeBoolean($bool), $length);
+ }
+
+ /**
+ * Replace $length bytes in a string or Buffer.
+ *
+ * @param Buffer|string $buffer
+ * @param int $length remove length bytes
+ * @return Buffer
+ * @throws BufferException
+ */
+ abstract public function replace($buffer, $length);
+
+ /**
+ * Replace by byte (-128 >= byte <= 127)
+ *
+ * @param int|string $byte
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replaceByte($byte, $length)
+ {
+ return $this->replace($this->writeByte($byte), $length);
+ }
+
+ /**
+ * Replace short value (-32768 >= short <= 32767)
+ *
+ * @param int|string $v
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replaceShort($v, $length)
+ {
+ return $this->replace($this->writeShort($v), $length);
+ }
+
+ /**
+ * Replace integer value (-2147483648 >= int <= 2147483647)
+ *
+ * @param int|string $v
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replaceInt($v, $length)
+ {
+ return $this->replace($this->writeInt($v), $length);
+ }
+
+ /**
+ * Replace long value (-9223372036854775808 >= long <= 9223372036854775807)
+ *
+ * @param int|string $v
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replaceLong($v, $length)
+ {
+ return $this->replace($this->writeLong($v), $length);
+ }
+
+ /**
+ * Replace float value
+ *
+ * @param float $v
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replaceFloat($v, $length)
+ {
+ return $this->replace($this->writeFloat($v), $length);
+ }
+
+ /**
+ * Replace double value
+ *
+ * @param double $v
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replaceDouble($v, $length)
+ {
+ return $this->replace($this->writeDouble($v), $length);
+ }
+
+ /**
+ * Replace string
+ *
+ * @param string $string
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replaceString($string, $length)
+ {
+ return $this->replace($this->writeString($string), $length);
+ }
+
+ /**
+ * Insert array bytes
+ *
+ * @param array $bytes
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replaceArrayBytes(array $bytes, $length)
+ {
+ return $this->replace($this->writeArrayBytes($bytes), $length);
+ }
+
+ /**
+ * Replace UTF string (Format - java DataOutStream.writeUTF)
+ *
+ * @param string $str
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replaceUTF($str, $length)
+ {
+ return $this->replace($this->writeUTF($str), $length);
+ }
+
+ /**
+ * Replace UTF16 string
+ *
+ * @param string $str
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ * @deprecated
+ */
+ public function replaceUTF16($str, $length)
+ {
+ return $this->replace($this->writeUTF16($str), $length);
+ }
+
+ /**
+ * Remove a certain number of bytes.
+ *
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ abstract public function remove($length);
+
+ /**
+ * Truncate data
+ *
+ * @param int $size
+ * @return Buffer
+ */
+ abstract public function truncate($size = 0);
+
+ /**
+ * Close buffer. If this buffer resource that closes the stream.
+ */
+ abstract public function close();
+
+ /**
+ * @return string
+ */
+ abstract public function toString();
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return get_called_class() . '{' .
+ 'position=' . $this->position .
+ ', limit=' . $this->size() .
+ ', order=' . $this->order() .
+ ', readOnly=' . ($this->isReadOnly() ? 'true' : 'false') .
+ '}';
+ }
+
+ /**
+ * Returns this buffer's limit.
+ *
+ * @return int The limit of this buffer
+ */
+ final public function size()
+ {
+ return $this->limit;
+ }
+
+ /**
+ * Retrieves this buffer's byte order.
+ *
+ * The byte order is used when reading or writing multibyte values, and
+ * when creating buffers that are views of this byte buffer. The order of
+ * a newly-created byte buffer is always Buffer::BIG_ENDIAN
+ *
+ * @return string This buffer's byte order
+ * @see Buffer::LITTLE_ENDIAN
+ *
+ * @see Buffer::BIG_ENDIAN
+ */
+ final public function order()
+ {
+ return $this->orderLittleEndian ? self::LITTLE_ENDIAN : self::BIG_ENDIAN;
+ }
+
+ /**
+ * Is read only buffer.
+ *
+ * @return boolean
+ */
+ final public function isReadOnly()
+ {
+ return $this->isReadOnly;
+ }
+
+ /**
+ * Sets this buffer's limit. If the position is larger than the new limit
+ * then it is set to the new limit.
+ *
+ * @param $newLimit int
+ * @return Buffer
+ * @throws BufferException
+ */
+ protected function newLimit($newLimit)
+ {
+ if ($newLimit < 0) {
+ throw new BufferException('New Limit < 0');
+ }
+ $this->limit = $newLimit;
+ if ($this->position > $this->limit) {
+ $this->position = $this->limit;
+ }
+ return $this;
+ }
+
+ /**
+ * Buffer's byte order is Buffer::LITTLE_ENDIAN
+ *
+ * @return bool
+ * @see Buffer::LITTLE_ENDIAN
+ *
+ * @see Buffer::BIG_ENDIAN
+ */
+ final protected function isOrderLE()
+ {
+ return $this->orderLittleEndian;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/BufferException.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/BufferException.php
new file mode 100644
index 00000000000..1c4cfd683e7
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/BufferException.php
@@ -0,0 +1,13 @@
+= byte <= 127)
+ *
+ * @param int $i
+ * @return int
+ */
+ public static function toByte($i)
+ {
+ $i &= 0xff;
+ if ($i < 128) {
+ return $i;
+ }
+ return $i - 256;
+ }
+
+ /**
+ * Cast to unsigned byte (0 >= short <= 255)
+ *
+ * @param int $i
+ * @return int
+ */
+ public static function toUnsignedByte($i)
+ {
+ return $i & 0xff;
+ }
+
+ /**
+ * Cast to short (-32768 >= short <= 32767)
+ *
+ * @param int $i
+ * @return int
+ */
+ public static function toShort($i)
+ {
+ $i &= 0xffff;
+ if ($i < 32768) {
+ return $i;
+ }
+ return $i - 65536;
+ }
+
+ /**
+ * Cast to unsigned short (0 >= int <= 65535)
+ *
+ * @param int $i
+ * @return int
+ */
+ public static function toUnsignedShort($i)
+ {
+ return $i & 0xffff;
+ }
+
+ /**
+ * Cast to int (-2147483648 >= int <= 2147483647)
+ *
+ * @param int $i
+ * @return int
+ */
+ public static function toInt($i)
+ {
+ if (PHP_INT_SIZE === 8) {
+ $i &= 0xffffffff;
+ if ($i < 2147483648) {
+ return $i;
+ }
+ return $i - 4294967296;
+ }
+ return $i;
+ }
+
+ /**
+ * Cast to unsigned int (0 >= long <= 4294967296)
+ *
+ * @param int $i
+ * @return int
+ */
+ public static function toUnsignedInt($i)
+ {
+ return $i & 0xffffffff;
+ }
+
+ /**
+ * Cast to long (-9223372036854775808 >= long <= 9223372036854775807)
+ *
+ * @param int $i
+ * @return int
+ */
+ public static function toLong($i)
+ {
+ $i = (int)$i;
+ if ($i > static::LONG_MAX_VALUE) {
+ throw new \RuntimeException('Invalid long value');
+ }
+
+ if ($i < static::LONG_MIN_VALUE) {
+ throw new \RuntimeException('Invalid long value');
+ }
+ return $i;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/FileBuffer.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/FileBuffer.php
new file mode 100644
index 00000000000..c62e46d8549
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/FileBuffer.php
@@ -0,0 +1,58 @@
+writable = is_writable(dirname($file));
+ parent::setReadOnly(!$this->writable);
+
+ $mode = !$this->writable ? 'rb' : (file_exists($file) ? 'r+' : 'w+') . 'b';
+
+ if (($fp = fopen($file, $mode)) === false) {
+ throw new BufferException("file '$file' can not open.");
+ }
+ parent::__construct($fp);
+ }
+
+ /**
+ * @param bool $isReadOnly
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function setReadOnly($isReadOnly)
+ {
+ if (!$this->writable && !$isReadOnly) {
+ throw new BufferException('You can not set the recording flag.' .
+ 'The directory containing the file is not available for recording.');
+ }
+ return parent::setReadOnly($isReadOnly);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/MemoryResourceBuffer.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/MemoryResourceBuffer.php
new file mode 100644
index 00000000000..ebea717f43d
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/MemoryResourceBuffer.php
@@ -0,0 +1,34 @@
+setResource($resource);
+ }
+
+ /**
+ * @param resource $resource
+ * @throws BufferException
+ */
+ protected function setResource($resource)
+ {
+ if ($resource === null) {
+ throw new BufferException('Resource null');
+ }
+ if (!is_resource($resource)) {
+ throw new BufferException('invalid type $resource - is not resource');
+ }
+ if (!stream_is_local($resource)) {
+ throw new BufferException('invalid argument $resource - read only resource is not local');
+ }
+ $meta = stream_get_meta_data($resource);
+ if (!$meta['seekable']) {
+ throw new BufferException('$resource cannot seekable stream.');
+ }
+ $stats = fstat($resource);
+ if (isset($stats['size'])) {
+ $this->newLimit($stats['size']);
+ }
+ $this->resource = $resource;
+ $this->setPosition(0);
+ }
+
+ /**
+ * @param int $position
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function setPosition($position)
+ {
+ if (!is_numeric($position)) {
+ throw new BufferException('position ' . $position . ' is not numeric');
+ }
+ if (fseek($this->resource, $position) === 0) {
+ return parent::setPosition($position);
+ }
+
+ throw new BufferException('set position ' . $position . ' failure');
+ }
+
+ /**
+ * @return string
+ * @throws BufferException
+ */
+ final public function toString()
+ {
+ $position = $this->position;
+ $this->rewind();
+ $content = stream_get_contents($this->resource);
+ $this->setPosition($position);
+ return $content;
+ }
+
+ /**
+ * Flips this buffer. The limit is set to the current position and then
+ * the position is set to zero.
+ *
+ * After a sequence of channel-read or put operations, invoke
+ * this method to prepare for a sequence of channel-write or relative
+ * get operations.
+ *
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function flip()
+ {
+ $this->newLimit($this->position);
+ ftruncate($this->resource, $this->size());
+ $this->setPosition(0);
+ return $this;
+ }
+
+ /**
+ * @param Buffer|string $buffer
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function insert($buffer)
+ {
+ if ($this->isReadOnly()) {
+ throw new BufferException('Read Only');
+ }
+ if ($buffer === null) {
+ throw new BufferException('null buffer');
+ }
+ if ($buffer instanceof Buffer) {
+ $buffer = $buffer->toString();
+ }
+ $lengthBuffer = strlen($buffer);
+ if ($this->hasRemaining()) {
+ $buffer .= stream_get_contents($this->resource);
+ $this->setPosition($this->position);
+ }
+ $length = strlen($buffer);
+
+ $lengthWrite = fwrite($this->resource, $buffer, $length);
+ if ($lengthWrite === false || $lengthWrite !== $length) {
+ throw new BufferException('Not write all bytes. Length: ' . $length . ', write length: ' . $lengthWrite);
+ }
+ $this->newLimit($this->size() + $lengthBuffer);
+ $this->position += $lengthBuffer;
+ return $this;
+ }
+
+ /**
+ * Relative put method (optional operation).
+ *
+ * Writes the given string into this buffer at the current
+ * position, and then increments the position.
+ *
+ * @param Buffer|string $buffer
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function put($buffer)
+ {
+ if ($this->isReadOnly()) {
+ throw new BufferException('Read Only');
+ }
+ if ($buffer === null) {
+ throw new BufferException('null buffer');
+ }
+ $length = null;
+ if ($buffer instanceof Buffer) {
+ $length = $buffer->size();
+ $buffer = $buffer->toString();
+ } else {
+ $length = strlen($buffer);
+ }
+ if ($length > $this->remaining()) {
+ throw new BufferException('put length > remaining');
+ }
+ $lengthWrite = fwrite($this->resource, $buffer, $length);
+ if ($lengthWrite === false || $lengthWrite !== $length) {
+ throw new BufferException('Not write all bytes. Length: ' . $length . ', write length: ' . $lengthWrite);
+ }
+ $this->position += $length;
+ return $this;
+ }
+
+ /**
+ * @param Buffer|string $buffer
+ * @param int $length remove length bytes
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replace($buffer, $length)
+ {
+ $length = (int)$length;
+ if ($this->isReadOnly()) {
+ throw new BufferException('Read Only');
+ }
+ if ($length < 0) {
+ throw new BufferException('length < 0');
+ }
+ if ($length > $this->remaining()) {
+ throw new BufferException('replace length > remaining');
+ }
+ if ($buffer === null) {
+ throw new BufferException('null buffer');
+ }
+ if ($buffer instanceof Buffer) {
+ $buffer = $buffer->toString();
+ }
+ $lengthBuffer = strlen($buffer);
+
+ $position = $this->position;
+ $this->setPosition($position + $length);
+ $buffer .= stream_get_contents($this->resource);
+ $this->setPosition($position);
+ ftruncate($this->resource, $position);
+ $lengthNewBuffer = strlen($buffer);
+
+ $lengthWrite = fwrite($this->resource, $buffer, $lengthNewBuffer);
+ if ($lengthWrite === false || $lengthWrite !== $lengthNewBuffer) {
+ throw new BufferException('Not write all bytes. Length: ' . $lengthNewBuffer . ', write length: ' . $lengthWrite);
+ }
+ $this->newLimit($this->size() + $lengthBuffer - $length);
+ $this->position += $lengthBuffer;
+ return $this;
+ }
+
+ /**
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function remove($length)
+ {
+ if ($this->isReadOnly()) {
+ throw new BufferException('Read Only');
+ }
+ if ($length < 0) {
+ throw new BufferException('length < 0');
+ }
+ if ($length > $this->remaining()) {
+ throw new BufferException('remove length > remaining');
+ }
+ $position = $this->position;
+ $this->setPosition($position + $length);
+ $buffer = stream_get_contents($this->resource);
+ $this->setPosition($position);
+ ftruncate($this->resource, $position);
+ $lengthNewBuffer = strlen($buffer);
+
+ $lengthWrite = fwrite($this->resource, $buffer, $lengthNewBuffer);
+ if ($lengthWrite === false || $lengthWrite !== $lengthNewBuffer) {
+ throw new BufferException('Not write all bytes. Length: ' . $lengthNewBuffer . ', write length: ' . $lengthWrite);
+ }
+ $this->newLimit($this->size() - $length);
+ $this->position += $position;
+ return $this;
+ }
+
+ /**
+ * Truncate file
+ *
+ * @param int $size
+ * @return Buffer
+ * @throws BufferException
+ */
+ final public function truncate($size = 0)
+ {
+ if ($this->isReadOnly()) {
+ throw new BufferException('Read Only');
+ }
+ ftruncate($this->resource, $size);
+ $this->rewind();
+ $this->newLimit($size);
+ return $this;
+ }
+
+ /**
+ * Destruct object, close file description.
+ */
+ public function __destruct()
+ {
+ $this->close();
+ }
+
+ /**
+ * Close buffer. If this buffer resource that closes the stream.
+ */
+ public function close()
+ {
+ if ($this->resource !== null && is_resource($this->resource)) {
+ fclose($this->resource);
+ $this->resource = null;
+ }
+ }
+
+ /**
+ * Relative get method.
+ * Reads the string at this buffer's current position, and then increments the position.
+ *
+ * @param int $length
+ * @return string The strings at the buffer's current position
+ * @throws BufferException
+ */
+ protected function get($length)
+ {
+ if (!$this->hasRemaining()) {
+ throw new BufferException('get length > remaining');
+ }
+ $str = fread($this->resource, $length);
+ if ($str === false) {
+ throw new BufferException('error read resource. position - ' . $this->position . ', limit: ' . $this->size());
+ }
+ $this->position += $length;
+ return $str;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/StringBuffer.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/StringBuffer.php
new file mode 100644
index 00000000000..c9a7bd7796e
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/StringBuffer.php
@@ -0,0 +1,230 @@
+setString($string);
+ }
+
+ /**
+ * @param string $string
+ * @throws BufferException
+ */
+ final public function setString($string)
+ {
+ $this->string = $string;
+ $this->rewind();
+ $this->newLimit(strlen($this->string));
+ }
+
+ /**
+ * @return string
+ */
+ final public function toString()
+ {
+ return $this->string;
+ }
+
+ /**
+ * Flips this buffer. The limit is set to the current position and then
+ * the position is set to zero.
+ *
+ * After a sequence of channel-read or put operations, invoke
+ * this method to prepare for a sequence of channel-write or relative
+ * get operations.
+ *
+ * @return Buffer
+ * @throws BufferException
+ */
+ final public function flip()
+ {
+ $this->setString(substr($this->string, 0, $this->position));
+ $this->setPosition(0);
+ return $this;
+ }
+
+ /**
+ * @param Buffer|string $buffer
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function insert($buffer)
+ {
+ if ($this->isReadOnly()) {
+ throw new BufferException('Read Only');
+ }
+ if ($buffer === null) {
+ throw new BufferException('null buffer');
+ }
+ if ($buffer instanceof Buffer) {
+ $buffer = $buffer->toString();
+ }
+ $length = strlen($buffer);
+ $this->string = substr_replace($this->string, $buffer, $this->position, 0);
+ $this->newLimit($this->size() + $length);
+ $this->skip($length);
+ return $this;
+ }
+
+ /**
+ * Relative put method (optional operation).
+ *
+ * Writes the given string into this buffer at the current
+ * position, and then increments the position.
+ *
+ * @param Buffer|string $buffer
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function put($buffer)
+ {
+ if ($this->isReadOnly()) {
+ throw new BufferException('Read Only');
+ }
+ if ($buffer === null) {
+ throw new BufferException('null buffer');
+ }
+ if ($buffer instanceof Buffer) {
+ $length = $buffer->size();
+ $buffer = $buffer->toString();
+ } else {
+ $length = strlen($buffer);
+ }
+ if ($length > $this->remaining()) {
+ throw new BufferException('put length > remaining');
+ }
+ $this->string = substr_replace($this->string, $buffer, $this->position, $length);
+ $this->skip($length);
+ return $this;
+ }
+
+ /**
+ * @param Buffer|string $buffer
+ * @param int $length remove length bytes
+ * @return Buffer
+ * @throws BufferException
+ */
+ public function replace($buffer, $length)
+ {
+ $length = (int)$length;
+ if ($this->isReadOnly()) {
+ throw new BufferException('Read Only');
+ }
+ if ($length < 0) {
+ throw new BufferException('length < 0');
+ }
+ if ($length > $this->remaining()) {
+ throw new BufferException('replace length > remaining');
+ }
+ if ($buffer === null) {
+ throw new BufferException('null buffer');
+ }
+ if ($buffer instanceof Buffer) {
+ $buffer = $buffer->toString();
+ }
+ $bufferLength = strlen($buffer);
+ $this->string = substr_replace($this->string, $buffer, $this->position, $length);
+ $this->newLimit($this->size() + $bufferLength - $length);
+ $this->skip($bufferLength);
+ return $this;
+ }
+
+ /**
+ * @param int $length
+ * @return Buffer
+ * @throws BufferException
+ */
+ final public function remove($length)
+ {
+ if ($this->isReadOnly()) {
+ throw new BufferException('Read Only');
+ }
+ if ($length < 0) {
+ throw new BufferException('length < 0');
+ }
+ if ($length > $this->remaining()) {
+ throw new BufferException('remove length > remaining');
+ }
+ $this->string = substr_replace($this->string, '', $this->position, $length);
+ $this->newLimit($this->size() - $length);
+ return $this;
+ }
+
+ /**
+ * Truncate buffer
+ *
+ * @param int $size
+ * @return Buffer
+ * @throws BufferException
+ */
+ final public function truncate($size = 0)
+ {
+ if ($size < $this->size()) {
+ $this->setString(substr($this->string, 0, $size));
+ }
+ return $this;
+ }
+
+ /**
+ * Destruct object, close file description.
+ */
+ public function __destruct()
+ {
+ $this->close();
+ }
+
+ /**
+ * Close buffer. If this buffer resource that closes the stream.
+ */
+ public function close()
+ {
+ if ($this->string !== null) {
+ $this->string = null;
+ }
+ }
+
+ /**
+ * Relative get method.
+ * Reads the string at this buffer's current position, and then increments the position.
+ *
+ * @param $length
+ * @return string The strings at the buffer's current position
+ * @throws BufferException
+ */
+ protected function get($length)
+ {
+ if ($length > $this->remaining()) {
+ throw new BufferException('get length > remaining');
+ }
+ $str = substr($this->string, $this->position, $length);
+ $this->skip($length);
+ return $str;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/TempBuffer.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/TempBuffer.php
new file mode 100644
index 00000000000..bfc5ced2a7d
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/src/Nelexa/Buffer/TempBuffer.php
@@ -0,0 +1,36 @@
+setTimeMillis($timeMillis);
+ $instance->setCategories($categories);
+ return $instance;
+ }
+
+ /**
+ * @return int
+ */
+ public function getTimeMillis()
+ {
+ return $this->timeMillis;
+ }
+
+ /**
+ * @param int $timeMillis
+ */
+ public function setTimeMillis($timeMillis)
+ {
+ $this->timeMillis = $timeMillis;
+ }
+
+ /**
+ * @return array
+ */
+ public function getCategories()
+ {
+ return $this->categories;
+ }
+
+ /**
+ * @param array $categories
+ */
+ public function setCategories($categories)
+ {
+ $this->categories = $categories;
+ }
+
+ /**
+ * @param Buffer $buffer
+ * @throws \Nelexa\Buffer\BufferException
+ */
+ public function readObject(Buffer $buffer)
+ {
+ $this->timeMillis = $buffer->getLong();
+ $length = $buffer->getInt();
+ $this->categories = [];
+ for ($i = 0; $i < $length; $i++) {
+ $this->categories[] = $buffer->getUTF();
+ }
+ }
+
+ /**
+ * @param Buffer $buffer
+ * @throws \Nelexa\Buffer\BufferException
+ */
+ public function writeObject(Buffer $buffer)
+ {
+ $buffer->insertLong($this->timeMillis);
+ $length = count($this->categories);
+ $buffer->insertInt($length);
+ foreach ($this->categories as $i => $iValue) {
+ $buffer->insertUTF($this->categories[$i]);
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/BinaryFormat/BinaryFileTestFormat.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/BinaryFormat/BinaryFileTestFormat.php
new file mode 100644
index 00000000000..e063ca445c6
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/BinaryFormat/BinaryFileTestFormat.php
@@ -0,0 +1,100 @@
+setName($name);
+ $instance->setItems($items);
+ return $instance;
+ }
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * @param string $name
+ */
+ public function setName($name)
+ {
+ $this->name = $name;
+ }
+
+ /**
+ * @return BinaryFileItem[]
+ */
+ public function getItems()
+ {
+ return $this->items;
+ }
+
+ /**
+ * @param BinaryFileItem[] $items
+ */
+ public function setItems($items)
+ {
+ $this->items = $items;
+ }
+
+ /**
+ * @param Buffer $buffer
+ * @throws \Nelexa\Buffer\BufferException
+ */
+ public function readObject(Buffer $buffer)
+ {
+ $this->name = $buffer->getUTF();
+ $length = $buffer->getInt();
+ $this->items = [];
+ for ($i = 0; $i < $length; $i++) {
+ $item = new BinaryFileItem();
+ $item->readObject($buffer);
+ $this->items[] = $item;
+ }
+ }
+
+ /**
+ * @param Buffer $buffer
+ * @throws \Nelexa\Buffer\BufferException
+ */
+ public function writeObject(Buffer $buffer)
+ {
+ $buffer->insertUTF($this->name);
+ $length = count($this->items);
+ $buffer->insertInt($length);
+ foreach ($this->items as $item) {
+ $item->writeObject($buffer);
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/BufferTestCase.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/BufferTestCase.php
new file mode 100644
index 00000000000..c0434020ee7
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/BufferTestCase.php
@@ -0,0 +1,583 @@
+buffer = $this->createBuffer();
+ if (!($this->buffer instanceof Buffer)) {
+ throw new \AssertionError('$buffer can\'t implements Buffer');
+ }
+ }
+
+ protected function tearDown()
+ {
+ parent::tearDown();
+ $this->buffer->close();
+ }
+
+ /**
+ * @return Buffer
+ */
+ abstract protected function createBuffer();
+
+ /**
+ * @throws BufferException
+ */
+ public function testBaseFunctional()
+ {
+ $this->buffer->insertString('Telephone');
+ $this->buffer->rewind();
+ $this->buffer->putString('My I');
+ $this->assertEquals($this->buffer->toString(), 'My Iphone');
+
+ $this->buffer->rewind();
+ $this->buffer->replaceString('P', 5);
+ $this->assertEquals($this->buffer->toString(), 'Phone');
+
+ $this->buffer->rewind();
+ $this->buffer->insertString('Tele');
+ $this->assertEquals($this->buffer->toString(), 'TelePhone');
+
+ $this->buffer->skip(2);
+ $this->buffer->flip();
+ $this->assertEquals($this->buffer->position(), 0);
+ $this->assertEquals($this->buffer->toString(), 'TelePh');
+
+ $this->buffer->truncate();
+ $this->assertEquals($this->buffer->position(), 0);
+ $this->assertEquals($this->buffer->size(), 0);
+ }
+
+ /**
+ * @throws BufferException
+ */
+ public function testFluent()
+ {
+ $this->buffer->insertByte(1)
+ ->insertBoolean(true)
+ ->insertShort(5551)
+ ->skip(-2)
+ ->insertUTF('Hello, World')
+ ->truncate()
+ ->insertString(str_rot13('Hello World'))
+ ->setPosition(7)
+ ->flip();
+ $this->assertEquals($this->buffer->size(), 7);
+ $this->assertEquals($this->buffer->position(), 0);
+ $this->assertEquals($this->buffer->toString(), str_rot13('Hello W'));
+ }
+
+ /**
+ * @throws BufferException
+ */
+ public function testInsertFunctional()
+ {
+ $orders = [Buffer::BIG_ENDIAN, Buffer::LITTLE_ENDIAN];
+
+ foreach ($orders as $order) {
+ $this->buffer->truncate();
+ $this->buffer->setOrder($order);
+
+ $byte1 = 34;
+ $byte2 = 3432424;
+ $byte3 = -100;
+
+ $this->buffer->insertByte($byte1);
+ $this->buffer->insertByte($byte2);
+ $this->buffer->insertByte($byte3);
+
+ $short1 = 31111;
+ $short2 = -12444;
+ $short3 = 243253233;
+
+ $this->buffer->insertShort($short1);
+ $this->buffer->insertShort($short2);
+ $this->buffer->insertShort($short3);
+
+ $int1 = Cast::INTEGER_MIN_VALUE;
+ $int2 = Cast::INTEGER_MIN_VALUE - 1;
+ $int3 = Cast::INTEGER_MAX_VALUE;
+ $int4 = Cast::INTEGER_MAX_VALUE + 1;
+ $int5 = 24234333;
+
+ $this->buffer->insertInt($int1);
+ $this->buffer->insertInt($int2);
+ $this->buffer->insertInt($int3);
+ $this->buffer->insertInt($int4);
+ $this->buffer->insertInt($int5);
+
+ $long1 = Cast::LONG_MIN_VALUE;
+ $long2 = Cast::LONG_MAX_VALUE;
+ $long3 = Cast::BYTE_MIN_VALUE;
+ $long4 = 0;
+ $long5 = 243535423222;
+
+ $this->buffer->insertLong($long1);
+ $this->buffer->insertLong($long2);
+ $this->buffer->insertLong($long3);
+ $this->buffer->insertLong($long4);
+ $this->buffer->insertLong($long5);
+
+ $bool1 = true;
+ $bool2 = false;
+
+ $this->buffer->insertBoolean($bool1);
+ $this->buffer->insertBoolean($bool2);
+
+ $arrayBytes = [0x01, 0x02, 0x03, 0x4, Cast::toByte(Cast::INTEGER_MAX_VALUE)];
+ $this->buffer->insertArrayBytes($arrayBytes);
+
+ $string = 'String... Строка... 串...
+ 😀 😬 😁 😂 😃 😄 😅 😆 😇 😉 😊 😊 🙂 🙃 ☺️ 😋 😌 😍 😘
+ 🇦🇫 🇦🇽 🇦🇱 🇩🇿 🇦🇸 🇦🇩 🇦🇴 🇦🇮 🇦🇶 🇦🇬 🇦🇷 🇦🇲 🇦🇼 🇦🇺 🇦🇹
+ 🇦🇿 🇧🇸 🇧🇭 🇧🇩 🇧🇧 🇧🇾 🇧🇪 🇧🇿 🇧🇯 🇧🇲 🇧🇹 🇧🇴 🇧🇶 🇧🇦 🇧🇼
+ 🇧🇷 🇮🇴 🇻🇬 🇧🇳 🇧🇬 🇧🇫 🇧🇮 🇨🇻 🇰🇭 🇨🇲 🇨🇦 🇮🇨 🇰🇾 🇨🇫 🇹🇩
+ 🇨🇱 🇨🇳 🇨🇽 🇨🇨 🇨🇴 🇰🇲 🇨🇬 🇨🇩 🇨🇰 🇨🇷 🇭🇷 🇨🇺 🇨🇼 🇨🇾
+ 🇨🇿 🇩🇰 🇩🇯 🇩🇲 🇩🇴 🇪🇨 🇪🇬 🇸🇻 🇬🇶 🇪🇷 🇪🇪 🇪🇹 🇪🇺 🇫🇰
+ 🇫🇴 🇫🇯 🇫🇮 🇫🇷 🇬🇫 🇵🇫 🇹🇫 🇬🇦 🇬🇲 🇬🇪 🇩🇪 🇬🇭 🇬🇮 🇬🇷
+ 🇬🇱 🇬🇩 🇬🇵 🇬🇺 🇬🇹 🇬🇬 🇬🇳 🇬🇼 🇬🇾 🇭🇹 🇭🇳 🇭🇰 🇭🇺 🇮🇸
+ 🇮🇳 🇮🇩 🇮🇷 🇮🇶 🇮🇪 🇮🇲 🇮🇱 🇮🇹 🇨🇮 🇯🇲 🇯🇵 🇯🇪 🇯🇴 🇰🇿
+ 🇰🇪 🇰🇮 🇽🇰 🇰🇼 🇰🇬 🇱🇦 🇱🇻 🇱🇧 🇱🇸 🇱🇷 🇱🇾 🇱🇮 🇱🇹 🇱🇺
+ 🇲🇴 🇲🇰 🇲🇬 🇲🇼 🇲🇾 🇲🇻 🇲🇱 🇲🇹 🇲🇭 🇲🇶 🇲🇷 🇲🇺 🇾🇹 🇲🇽
+ 🇫🇲 🇲🇩 🇲🇨 🇲🇳 🇲🇪 🇲🇸 🇲🇦 🇲🇿 🇲🇲 🇳🇦 🇳🇷 🇳🇵 🇳🇱 🇳🇨
+ 🇳🇿 🇳🇮 🇳🇪 🇳🇬 🇳🇺 🇳🇫 🇲🇵 🇰🇵 🇳🇴 🇴🇲 🇵🇰 🇵🇼 🇵🇸 🇵🇦
+ 🇵🇬 🇵🇾 🇵🇪 🇵🇭 🇵🇳 🇵🇱 🇵🇹 🇵🇷 🇶🇦 🇷🇪 🇷🇴 🇷🇺 🇷🇼 🇧🇱
+ 🇸🇭 🇰🇳 🇱🇨 🇵🇲 🇻🇨 🇼🇸 🇸🇲 🇸🇹 🇸🇦 🇸🇳 🇷🇸 🇸🇨 🇸🇱 🇸🇬
+ 🇸🇽 🇸🇰 🇸🇮 🇸🇧 🇸🇴 🇿🇦 🇬🇸 🇰🇷 🇸🇸 🇪🇸 🇱🇰 🇸🇩 🇸🇷 🇸🇿
+ 🇸🇪 🇨🇭 🇸🇾 🇹🇼 🇹🇯 🇹🇿 🇹🇭 🇹🇱 🇹🇬 🇹🇰 🇹🇴 🇹🇹 🇹🇳 🇹🇷
+ 🇹🇲 🇹🇨 🇹🇻 🇺🇬 🇺🇦 🇦🇪 🇬🇧 🇺🇸 🇻🇮 🇺🇾 🇺🇿 🇻🇺 🇻🇦 🇻🇪
+ 🇻🇳 🇼🇫 🇪🇭 🇾🇪 🇿🇲 🇿🇼 ';
+ $lengthString = strlen($string);
+
+ $this->buffer->insertString($string);
+ $this->buffer->insertUTF($string);
+ $this->buffer->insertUTF16($string);
+
+ $otherBuffer = new MemoryResourceBuffer(str_rot13($string));
+ $this->buffer->insert($otherBuffer);
+
+ $this->buffer->rewind();
+
+ $this->assertEquals($this->buffer->position(), 0);
+ $this->assertEquals($this->buffer->getByte(), Cast::toByte($byte1));
+ $this->assertEquals($this->buffer->position(), 1);
+ $this->assertEquals($this->buffer->getByte(), Cast::toByte($byte2));
+ $this->assertEquals($this->buffer->position(), 2);
+ $this->assertEquals($this->buffer->getByte(), Cast::toByte($byte3));
+ $this->assertEquals($this->buffer->position(), 3);
+
+ $this->buffer->setPosition(0);
+
+ $this->assertEquals($this->buffer->position(), 0);
+ $this->assertEquals($this->buffer->getUnsignedByte(), Cast::toUnsignedByte($byte1));
+ $this->assertEquals($this->buffer->position(), 1);
+ $this->assertEquals($this->buffer->getUnsignedByte(), Cast::toUnsignedByte($byte2));
+ $this->assertEquals($this->buffer->position(), 2);
+ $this->assertEquals($this->buffer->getUnsignedByte(), Cast::toUnsignedByte($byte3));
+ $this->assertEquals($this->buffer->position(), 3);
+
+ $this->assertEquals($this->buffer->getShort(), Cast::toShort($short1));
+ $this->assertEquals($this->buffer->position(), 5);
+ $this->assertEquals($this->buffer->getShort(), Cast::toShort($short2));
+ $this->assertEquals($this->buffer->position(), 7);
+ $this->assertEquals($this->buffer->getShort(), Cast::toShort($short3));
+ $this->assertEquals($this->buffer->position(), 9);
+
+ $this->buffer->skip(-6);
+
+ $this->assertEquals($this->buffer->position(), 3);
+ $this->assertEquals($this->buffer->getUnsignedShort(), Cast::toUnsignedShort($short1));
+ $this->assertEquals($this->buffer->position(), 5);
+ $this->assertEquals($this->buffer->getUnsignedShort(), Cast::toUnsignedShort($short2));
+ $this->assertEquals($this->buffer->position(), 7);
+ $this->assertEquals($this->buffer->getUnsignedShort(), Cast::toUnsignedShort($short3));
+ $this->assertEquals($this->buffer->position(), 9);
+
+ $this->assertEquals($this->buffer->getInt(), Cast::toInt($int1));
+ $this->assertEquals($this->buffer->position(), 13);
+ $this->assertEquals($this->buffer->getInt(), Cast::toInt($int2));
+ $this->assertEquals($this->buffer->position(), 17);
+ $this->assertEquals($this->buffer->getInt(), Cast::toInt($int3));
+ $this->assertEquals($this->buffer->position(), 21);
+ $this->assertEquals($this->buffer->getInt(), Cast::toInt($int4));
+ $this->assertEquals($this->buffer->position(), 25);
+ $this->assertEquals($this->buffer->getInt(), Cast::toInt($int5));
+ $this->assertEquals($this->buffer->position(), 29);
+
+ $this->buffer->skip(-20);
+
+ $this->assertEquals($this->buffer->getUnsignedInt(), Cast::toUnsignedInt($int1));
+ $this->assertEquals($this->buffer->position(), 13);
+ $this->assertEquals($this->buffer->getUnsignedInt(), Cast::toUnsignedInt($int2));
+ $this->assertEquals($this->buffer->position(), 17);
+ $this->assertEquals($this->buffer->getUnsignedInt(), Cast::toUnsignedInt($int3));
+ $this->assertEquals($this->buffer->position(), 21);
+ $this->assertEquals($this->buffer->getUnsignedInt(), Cast::toUnsignedInt($int4));
+ $this->assertEquals($this->buffer->position(), 25);
+ $this->assertEquals($this->buffer->getUnsignedInt(), Cast::toUnsignedInt($int5));
+ $this->assertEquals($this->buffer->position(), 29);
+
+ $this->assertEquals($this->buffer->getLong(), Cast::toLong($long1));
+ $this->assertEquals($this->buffer->position(), 37);
+ $this->assertEquals($this->buffer->getLong(), Cast::toLong($long2));
+ $this->assertEquals($this->buffer->position(), 45);
+ $this->assertEquals($this->buffer->getLong(), Cast::toLong($long3));
+ $this->assertEquals($this->buffer->position(), 53);
+ $this->assertEquals($this->buffer->getLong(), Cast::toLong($long4));
+ $this->assertEquals($this->buffer->position(), 61);
+ $this->assertEquals($this->buffer->getLong(), Cast::toLong($long5));
+ $this->assertEquals($this->buffer->position(), 69);
+
+ $this->assertEquals($this->buffer->getBoolean(), $bool1);
+ $this->assertEquals($this->buffer->position(), 70);
+ $this->assertEquals($this->buffer->getBoolean(), $bool2);
+ $this->assertEquals($this->buffer->position(), 71);
+
+ $this->assertEquals($this->buffer->getArrayBytes(5), $arrayBytes);
+ $this->assertEquals($this->buffer->position(), 76);
+
+ $this->assertEquals($this->buffer->getString($lengthString), $string);
+ $this->assertEquals($this->buffer->position(), 76 + $lengthString);
+
+ $this->assertEquals($this->buffer->getUTF(), $string);
+ $this->assertEquals($this->buffer->position(), 78 + $lengthString * 2);
+
+ $this->assertEquals($this->buffer->getUTF16($lengthString), $string);
+ $this->assertEquals($this->buffer->position(), 78 + $lengthString * 4);
+
+ $this->assertEquals($this->buffer->getString($lengthString), $otherBuffer->toString());
+ $this->assertEquals($this->buffer->position(), 78 + $lengthString * 5);
+ }
+ }
+
+ /**
+ * @throws BufferException
+ */
+ public function testPutFunctional()
+ {
+ $this->buffer->setOrder(Buffer::BIG_ENDIAN);
+ $this->buffer->insertLong(12345);
+ $this->buffer->setPosition(4);
+ $this->buffer->putInt(98765);
+ $this->buffer->rewind();
+ $this->assertEquals($this->buffer->getLong(), 98765);
+
+ $this->buffer->rewind();
+ $this->buffer->setOrder(Buffer::LITTLE_ENDIAN);
+ $this->buffer->putLong(12345);
+ $this->buffer->rewind();
+ $this->assertEquals($this->buffer->getLong(), 12345);
+ $this->buffer->setPosition(0);
+ $this->buffer->putInt(98765);
+ $this->buffer->rewind();
+ $this->assertEquals($this->buffer->getLong(), 98765);
+ }
+
+ /**
+ * @throws BufferException
+ */
+ public function testReplaceFunctional()
+ {
+ $this->buffer->insertString('123456789');
+ $this->buffer->setPosition(3);
+ $this->buffer->replaceBoolean(true, 3);
+ $this->assertEquals('123789', $this->buffer->toString());
+ $this->buffer->skip(-1);
+ $this->buffer->replaceString('', 1);
+ $this->assertEquals('123789', $this->buffer->toString());
+ $this->buffer->replaceString('456', 0);
+ $this->assertEquals('123456789', $this->buffer->toString());
+ }
+
+ /**
+ * @throws BufferException
+ */
+ public function testRemoveFunctional()
+ {
+ $this->buffer->insertString('123456789');
+ $this->buffer->setPosition(3);
+ $this->buffer->remove(3);
+ $this->assertEquals('123789', $this->buffer->toString());
+ }
+
+ /**
+ * @expectedException \Nelexa\Buffer\BufferException
+ * @expectedExceptionMessage put length > remaining
+ */
+ public function testPutException()
+ {
+ $this->assertEquals($this->buffer->size(), 0);
+ $this->buffer->putString('Test');
+ }
+
+ /**
+ * @expectedException \Nelexa\Buffer\BufferException
+ * @expectedExceptionMessage put length > remaining
+ */
+ public function testPutException2()
+ {
+ $this->buffer
+ ->insertString('Test')
+ ->rewind()
+ ->putString('My Test');
+ }
+
+ /**
+ * @expectedException \Nelexa\Buffer\BufferException
+ * @expectedExceptionMessage replace length > remaining
+ */
+ public function testReplaceException()
+ {
+ $this->assertEquals($this->buffer->size(), 0);
+ $this->buffer->replaceString('Test', 5);
+ }
+
+ /**
+ * @expectedException \Nelexa\Buffer\BufferException
+ * @expectedExceptionMessage remove length > remaining
+ */
+ public function testRemoveException()
+ {
+ $this->assertEquals($this->buffer->size(), 0);
+ $this->buffer->remove(1);
+ }
+
+ /**
+ * @expectedException \Nelexa\Buffer\BufferException
+ * @expectedExceptionMessage Read Only
+ */
+ public function testReadOnly()
+ {
+ $this->assertEquals($this->buffer->isReadOnly(), false);
+ $this->buffer->setReadOnly(true);
+ $this->assertEquals($this->buffer->isReadOnly(), true);
+ $this->buffer->insertBoolean(true);
+ }
+
+ /**
+ * @throws BufferException
+ */
+ public function testOrder()
+ {
+ $this->assertEquals($this->buffer->order(), Buffer::BIG_ENDIAN);
+
+ $this->buffer->insertByte(50)
+ ->insertShort(5000)
+ ->insertInt(50000000)
+ ->insertLong(5000000000);
+
+ $this->buffer->setOrder(Buffer::LITTLE_ENDIAN)->rewind();
+ $this->assertEquals($this->buffer->order(), Buffer::LITTLE_ENDIAN);
+
+ $this->assertEquals($this->buffer->getByte(), 50);
+ $this->assertEquals($this->buffer->getShort(), -30701);
+ $this->assertEquals($this->buffer->getInt(), -2131691006);
+ $this->assertEquals($this->buffer->getLong(), 68122622327521280);
+
+ $this->buffer->setOrder(Buffer::BIG_ENDIAN)->rewind();
+ $this->assertEquals($this->buffer->order(), Buffer::BIG_ENDIAN);
+
+ $this->assertEquals($this->buffer->getByte(), 50);
+ $this->assertEquals($this->buffer->getShort(), 5000);
+ $this->assertEquals($this->buffer->getInt(), 50000000);
+ $this->assertEquals($this->buffer->getLong(), 5000000000);
+ }
+
+ /**
+ * @throws BufferException
+ */
+ public function testPositions()
+ {
+ $this->buffer->insertString('Test value');
+ $this->assertEquals($this->buffer->size(), 10);
+ $this->assertEquals($this->buffer->position(), 10);
+
+ $this->buffer->setPosition(3);
+ $this->assertEquals($this->buffer->position(), 3);
+
+ $this->buffer->skip(2);
+ $this->assertEquals($this->buffer->position(), 5);
+
+ $this->buffer->skip(-4);
+ $this->assertEquals($this->buffer->position(), 1);
+
+ $this->assertEquals($this->buffer->remaining(), 9);
+ $this->assertEquals($this->buffer->hasRemaining(), true);
+
+ $this->buffer->setPosition($this->buffer->size());
+ $this->assertEquals($this->buffer->position(), 10);
+ $this->assertEquals($this->buffer->remaining(), 0);
+ $this->assertEquals($this->buffer->hasRemaining(), false);
+
+ $this->buffer->rewind();
+ $this->assertEquals($this->buffer->position(), 0);
+
+ $this->buffer->insertString(str_repeat('*', 100));
+ $this->assertEquals($this->buffer->position(), 100);
+ $this->assertEquals($this->buffer->size(), 110);
+
+ $this->buffer->setPosition(0);
+ $this->assertEquals($this->buffer->position(), 0);
+
+ $this->buffer->skipByte();
+ $this->assertEquals($this->buffer->position(), 1);
+
+ $this->buffer->skipShort();
+ $this->assertEquals($this->buffer->position(), 3);
+
+ $this->buffer->skipInt();
+ $this->assertEquals($this->buffer->position(), 7);
+
+ $this->buffer->skipLong();
+ $this->assertEquals($this->buffer->position(), 15);
+
+ $this->buffer->toString();
+ $this->assertEquals($this->buffer->position(), 15);
+
+ $this->buffer->flip();
+ $this->assertEquals($this->buffer->position(), 0);
+ $this->assertEquals($this->buffer->size(), 15);
+
+ $this->buffer->setPosition(5)->truncate();
+ $this->assertEquals($this->buffer->position(), 0);
+ $this->assertEquals($this->buffer->size(), 0);
+
+ $this->buffer->insertBoolean(true);
+ $this->assertEquals($this->buffer->position(), 1);
+ $this->assertEquals($this->buffer->size(), 1);
+ $this->buffer->truncate();
+
+ $this->buffer->insertByte(0);
+ $this->assertEquals($this->buffer->position(), 1);
+ $this->assertEquals($this->buffer->size(), 1);
+ $this->buffer->truncate();
+
+ $this->buffer->insertShort(0);
+ $this->assertEquals($this->buffer->position(), 2);
+ $this->assertEquals($this->buffer->size(), 2);
+ $this->buffer->truncate();
+
+ $this->buffer->insertInt(0);
+ $this->assertEquals($this->buffer->position(), 4);
+ $this->assertEquals($this->buffer->size(), 4);
+ $this->buffer->truncate();
+
+ $this->buffer->insertLong(0);
+ $this->assertEquals($this->buffer->position(), 8);
+ $this->assertEquals($this->buffer->size(), 8);
+ $this->buffer->truncate();
+
+ $this->buffer->insertArrayBytes([5, 5, 6, 5, 7, 8, 9]);
+ $this->assertEquals($this->buffer->position(), 7);
+ $this->assertEquals($this->buffer->size(), 7);
+ $this->buffer->truncate();
+
+ $this->buffer->insertUTF('Test');
+ $this->assertEquals($this->buffer->position(), 6);
+ $this->assertEquals($this->buffer->size(), 6);
+ $this->buffer->truncate();
+
+ $this->buffer->insertUTF16('Test');
+ $this->assertEquals($this->buffer->position(), 8);
+ $this->assertEquals($this->buffer->size(), 8);
+ $this->buffer->truncate();
+ }
+
+ /**
+ * @throws BufferException
+ */
+ public function testBinaryFile()
+ {
+ $name = 'General Name';
+ $items = [
+ BinaryFileItem::create(time() * 1000, ['Category 1', 'Category 2']),
+ BinaryFileItem::create((time() - 3600) * 1000, ['Category 2', 'Category 3']),
+ BinaryFileItem::create((time() - 52222) * 1000, ['Category 4', 'Category 2', 'Category 7']),
+ ];
+
+ $binaryFileActual = BinaryFileTestFormat::create($name, $items);
+ $binaryFileActual->writeObject($this->buffer);
+ $output = $this->buffer->toString();
+
+ $buffer = new StringBuffer($output);
+ $binaryFileExpected = new BinaryFileTestFormat();
+ $binaryFileExpected->readObject($buffer);
+
+ $this->assertEquals($binaryFileExpected, $binaryFileActual);
+ }
+
+ /**
+ * @throws BufferException
+ * @requires PHP 7.0.15
+ */
+ public function testDouble()
+ {
+ $double = 12.6664287277627762; // 64 bit
+
+ $buffer = $this->createBuffer();
+
+ $buffer->insertDouble($double);
+ $this->assertEquals($buffer->size(), 8);
+
+ $buffer->rewind();
+ $this->assertEquals($buffer->getDouble(), $double);
+ $this->assertEquals($buffer->position(), 8);
+
+ $buffer->rewind();
+ $buffer->skipDouble();
+ $this->assertEquals($buffer->position(), 8);
+
+ $buffer->rewind();
+ $this->assertEquals($buffer->getArrayBytes(8), [64, 41, 85, 54, 37, 109, -74, 71]);
+ }
+
+ /**
+ * @throws BufferException
+ * @requires PHP 7.0.15
+ */
+ public function testFloat()
+ {
+ $float = 12.666428565979; // 32 bit
+
+ $buffer = $this->createBuffer();
+
+ $buffer->insertFloat($float);
+ $this->assertEquals($buffer->size(), 4);
+
+ $buffer->rewind();
+ $this->assertEquals($buffer->getFloat(), $float);
+ $this->assertEquals($buffer->position(), 4);
+
+ $buffer->rewind();
+ $buffer->skipFloat();
+ $this->assertEquals($buffer->position(), 4);
+
+ $buffer->rewind();
+ $this->assertEquals($buffer->getArrayBytes(4), [65, 74, -87, -79]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/CastTest.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/CastTest.php
new file mode 100644
index 00000000000..9a614eb27c2
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/CastTest.php
@@ -0,0 +1,440 @@
+ 86, -1960415292 => -60, -1799385197 => -109, -1721937620 => 44,
+ -1530997534 => -30, -1526855311 => 113, -1298630511 => -111, -1259382890 => -106,
+ -1074950937 => -25, -892932280 => 72, -737085923 => 29, -698175103 => -127,
+ -616855785 => 23, -486032759 => -119, -447665782 => -118, -421148997 => -69,
+ -412081654 => 10, -268644677 => -69, -244595046 => -102, -105212087 => 73,
+ -132 => 124, -131 => 125, -130 => 126, -129 => 127, -128 => -128, -127 => -127,
+ -126 => -126, -125 => -125, -124 => -124, -123 => -123, -120 => -120, -119 => -119,
+ -116 => -116, -115 => -115, -113 => -113, -107 => -107, -106 => -106, -105 => -105,
+ -104 => -104, -103 => -103, -100 => -100, -99 => -99, -91 => -91, -82 => -82,
+ -80 => -80, -75 => -75, -74 => -74, -73 => -73, -72 => -72, -70 => -70, -67 => -67,
+ -58 => -58, -57 => -57, -56 => -56, -55 => -55, -54 => -54, -53 => -53, -49 => -49,
+ -47 => -47, -46 => -46, -44 => -44, -38 => -38, -27 => -27, -23 => -23, -14 => -14,
+ -9 => -9, -8 => -8, -7 => -7, -6 => -6, -2 => -2, -1 => -1, 2 => 2, 3 => 3, 4 => 4,
+ 9 => 9, 11 => 11, 12 => 12, 14 => 14, 20 => 20, 23 => 23, 24 => 24, 27 => 27, 30 => 30,
+ 37 => 37, 41 => 41, 47 => 47, 48 => 48, 50 => 50, 51 => 51, 54 => 54, 55 => 55,
+ 57 => 57, 59 => 59, 68 => 68, 69 => 69, 70 => 70, 71 => 71, 74 => 74, 83 => 83,
+ 85 => 85, 88 => 88, 89 => 89, 93 => 93, 96 => 96, 103 => 103, 104 => 104, 107 => 107,
+ 111 => 111, 116 => 116, 120 => 120, 123 => 123, 124 => 124, 125 => 125, 126 => 126,
+ 127 => 127, 128 => -128, 129 => -127, 130 => -126, 131 => -125, 132 => -124,
+ 7207204 => 36, 233845854 => 94, 334702437 => 101, 340410750 => 126, 430326926 => -114,
+ 471183925 => 53, 499194653 => 29, 682760454 => 6, 691691143 => -121, 972475720 => 72,
+ 1130159086 => -18, 1238801648 => -16, 1273111800 => -8, 1326838210 => -62,
+ 1462828643 => 99, 1495706013 => -99, 1664325026 => -94, 1752068802 => -62,
+ 1798269036 => 108, 1883221567 => 63,
+ ];
+
+ $expectedCast = [];
+ foreach ($actualCast as $i => $value) {
+ $expectedCast[$i] = Cast::toByte($i);
+ }
+ $this->assertEquals($expectedCast, $actualCast);
+ }
+
+ public function testCastToUnsignedByte()
+ {
+ $actualCast = [
+ -2136595330 => 126, -2128000797 => 227, -2112934983 => 185, -2024602240 => 128,
+ -1983244330 => 214, -1588151360 => 192, -1536373296 => 208, -1527028339 => 141,
+ -1497595627 => 21, -1388221194 => 246, -1346350122 => 214, -1323025019 => 133,
+ -951240289 => 159, -868324821 => 43, -817456424 => 216, -807634866 => 78,
+ -557532140 => 20, -258867856 => 112, -247321292 => 52, -46127326 => 34,
+ -132 => 124, -131 => 125, -130 => 126, -129 => 127, -128 => 128, -127 => 129,
+ -126 => 130, -125 => 131, -124 => 132, -123 => 133, -122 => 134, -120 => 136,
+ -118 => 138, -114 => 142, -109 => 147, -106 => 150, -105 => 151, -102 => 154,
+ -99 => 157, -97 => 159, -87 => 169, -86 => 170, -82 => 174, -78 => 178,
+ -75 => 181, -74 => 182, -69 => 187, -66 => 190, -58 => 198, -57 => 199, -54 => 202,
+ -50 => 206, -34 => 222, -32 => 224, -25 => 231, -24 => 232, -23 => 233, -18 => 238,
+ -17 => 239, -13 => 243, -11 => 245, -9 => 247, -7 => 249, -6 => 250, -5 => 251,
+ -4 => 252, -3 => 253, 5 => 5, 11 => 11, 13 => 13, 14 => 14, 15 => 15, 17 => 17,
+ 18 => 18, 19 => 19, 23 => 23, 27 => 27, 29 => 29, 31 => 31, 38 => 38, 40 => 40,
+ 41 => 41, 46 => 46, 48 => 48, 54 => 54, 57 => 57, 59 => 59, 61 => 61, 62 => 62,
+ 65 => 65, 70 => 70, 71 => 71, 75 => 75, 76 => 76, 77 => 77, 79 => 79, 81 => 81,
+ 82 => 82, 95 => 95, 101 => 101, 104 => 104, 112 => 112, 114 => 114, 116 => 116,
+ 117 => 117, 119 => 119, 120 => 120, 122 => 122, 123 => 123, 124 => 124, 125 => 125,
+ 126 => 126, 127 => 127, 128 => 128, 129 => 129, 130 => 130, 131 => 131, 132 => 132,
+ 70749593 => 153, 373009742 => 78, 393363356 => 156, 403215862 => 246,
+ 526361226 => 138, 740206296 => 216, 744006616 => 216, 823793575 => 167,
+ 887569610 => 202, 889805411 => 99, 920302796 => 204, 973062939 => 27,
+ 1150941609 => 169, 1261437697 => 1, 1322397075 => 147, 1363958510 => 238,
+ 1656026962 => 82, 1721052657 => 241, 1945030068 => 180, 1986358021 => 5,
+ ];
+
+ $expectedCast = [];
+ foreach ($actualCast as $i => $value) {
+ $expectedCast[$i] = Cast::toUnsignedByte($i);
+ }
+ $this->assertEquals($expectedCast, $actualCast);
+ }
+
+ public function testCastToShort()
+ {
+ $actualCast = [
+ -1971175757 => 16051, -1968146222 => 30930, -1925425704 => 21976,
+ -1868190929 => -21713, -1821565081 => 8039, -1685254381 => 3859,
+ -1436069992 => 20376, -1185520696 => 25544, -1062425742 => -21646,
+ -999882771 => -19, -816367898 => 14054, -725302878 => -15966,
+ -490449662 => 21762, -378319475 => 19853, -304165789 => -13213,
+ -275551518 => 27362, -262172158 => -28158, -254535372 => 6452, -
+ 72772953 => -27993, -41911111 => 31929, -32772 => 32764, -32771 => 32765,
+ -32770 => 32766, -32769 => 32767, -32768 => -32768, -32767 => -32767,
+ -32766 => -32766, -32765 => -32765, -32764 => -32764, -32763 => -32763,
+ -31605 => -31605, -31279 => -31279, -31152 => -31152, -28716 => -28716,
+ -27155 => -27155, -26048 => -26048, -24067 => -24067, -23840 => -23840,
+ -23723 => -23723, -22833 => -22833, -22344 => -22344, -22189 => -22189,
+ -22046 => -22046, -21882 => -21882, -21787 => -21787, -21011 => -21011,
+ -20809 => -20809, -20377 => -20377, -20197 => -20197, -20025 => -20025,
+ -18803 => -18803, -18692 => -18692, -18235 => -18235, -17069 => -17069,
+ -16937 => -16937, -16602 => -16602, -15059 => -15059, -14614 => -14614,
+ -14380 => -14380, -14106 => -14106, -12603 => -12603, -11774 => -11774,
+ -9780 => -9780, -9540 => -9540, -8646 => -8646, -8387 => -8387, -8063 => -8063,
+ -6846 => -6846, -4381 => -4381, -3648 => -3648, -2900 => -2900, -2408 => -2408,
+ -4 => -4, 1168 => 1168, 1329 => 1329, 1834 => 1834, 2081 => 2081, 2710 => 2710,
+ 3355 => 3355, 4376 => 4376, 4868 => 4868, 5156 => 5156, 5287 => 5287,
+ 6027 => 6027, 6167 => 6167, 9684 => 9684, 10350 => 10350, 11797 => 11797,
+ 11982 => 11982, 12126 => 12126, 12444 => 12444, 12512 => 12512, 12674 => 12674,
+ 13176 => 13176, 13714 => 13714, 14760 => 14760, 16165 => 16165, 16665 => 16665,
+ 16862 => 16862, 17506 => 17506, 18983 => 18983, 19099 => 19099, 19513 => 19513,
+ 19574 => 19574, 19811 => 19811, 20268 => 20268, 20349 => 20349, 20794 => 20794,
+ 21199 => 21199, 21334 => 21334, 21891 => 21891, 21965 => 21965, 24248 => 24248,
+ 24844 => 24844, 25011 => 25011, 25254 => 25254, 26311 => 26311, 26521 => 26521,
+ 26586 => 26586, 26815 => 26815, 27370 => 27370, 27426 => 27426, 27756 => 27756,
+ 28437 => 28437, 29696 => 29696, 29953 => 29953, 30160 => 30160, 30393 => 30393,
+ 30968 => 30968, 31071 => 31071, 32763 => 32763, 32764 => 32764, 32765 => 32765,
+ 32766 => 32766, 32767 => 32767, 32768 => -32768, 32769 => -32767,
+ 32770 => -32766, 32771 => -32765, 32772 => -32764, 67183888 => 9488,
+ 351130529 => -11359, 416036100 => 13572, 419148444 => -19812,
+ 514228657 => -32335, 654021230 => -28050, 743113735 => 1031,
+ 805407091 => -30349, 875355480 => -8872, 878683109 => -23579,
+ 941712008 => 25224, 1505475186 => -17806, 1509506213 => 15525,
+ 1524854795 => 28683, 1581123294 => 1758, 1665603962 => 6522,
+ 1826409361 => -13423, 1979551636 => -28780, 2054791296 => -24448, 2143956813 => 12109,
+ ];
+ $expectedCast = [];
+ foreach ($actualCast as $i => $value) {
+ $expectedCast[$i] = Cast::toShort($i);
+ }
+ $this->assertEquals($expectedCast, $actualCast);
+ }
+
+ public function testCastToUnsignedShort()
+ {
+ $actualCast = [
+ -1545551207 => 49817, -1416288743 => 9753, -1374427376 => 59152,
+ -1358269249 => 29887, -1356152893 => 49091, -1127384673 => 31135,
+ -1122383938 => 51134, -1093018380 => 56564, -988578364 => 32196,
+ -932564899 => 12381, -723356577 => 29791, -719126083 => 445,
+ -513574700 => 30932, -480460457 => 49495, -379567831 => 16681,
+ -277828067 => 44573, -238559590 => 56986, -236028290 => 32382,
+ -220004359 => 65529, -46021472 => 50336, -32772 => 32764,
+ -32771 => 32765, -32770 => 32766, -32769 => 32767, -32768 => 32768,
+ -32767 => 32769, -32766 => 32770, -32765 => 32771, -32764 => 32772,
+ -32763 => 32773, -32318 => 33218, -31670 => 33866, -31598 => 33938,
+ -31443 => 34093, -31142 => 34394, -30229 => 35307, -29387 => 36149,
+ -29306 => 36230, -29234 => 36302, -28815 => 36721, -28334 => 37202,
+ -25406 => 40130, -25135 => 40401, -24032 => 41504, -22198 => 43338,
+ -20584 => 44952, -19906 => 45630, -19621 => 45915, -19173 => 46363,
+ -18876 => 46660, -18427 => 47109, -17124 => 48412, -16953 => 48583,
+ -15452 => 50084, -15074 => 50462, -14621 => 50915, -12600 => 52936,
+ -12373 => 53163, -12241 => 53295, -12204 => 53332, -11883 => 53653,
+ -11614 => 53922, -11393 => 54143, -11251 => 54285, -11063 => 54473,
+ -10834 => 54702, -8947 => 56589, -8940 => 56596, -8696 => 56840,
+ -8592 => 56944, -8095 => 57441, -7949 => 57587, -7761 => 57775,
+ -7415 => 58121, -7255 => 58281, -4564 => 60972, -1841 => 63695,
+ -1537 => 63999, -1463 => 64073, -851 => 64685, -698 => 64838,
+ 1951 => 1951, 2211 => 2211, 2452 => 2452, 2540 => 2540, 3429 => 3429,
+ 3592 => 3592, 5413 => 5413, 5919 => 5919, 6061 => 6061, 6268 => 6268,
+ 6917 => 6917, 7153 => 7153, 7168 => 7168, 7833 => 7833, 10152 => 10152,
+ 10234 => 10234, 10604 => 10604, 10739 => 10739, 12036 => 12036,
+ 12427 => 12427, 14441 => 14441, 15158 => 15158, 15258 => 15258,
+ 15768 => 15768, 17450 => 17450, 17805 => 17805, 17865 => 17865,
+ 18795 => 18795, 18907 => 18907, 19026 => 19026, 20432 => 20432,
+ 20939 => 20939, 21141 => 21141, 21362 => 21362, 22254 => 22254,
+ 22941 => 22941, 24201 => 24201, 24874 => 24874, 25556 => 25556,
+ 25982 => 25982, 27238 => 27238, 28042 => 28042, 29510 => 29510,
+ 30145 => 30145, 30703 => 30703, 31015 => 31015, 31311 => 31311,
+ 32269 => 32269, 32397 => 32397, 32763 => 32763, 32764 => 32764,
+ 32765 => 32765, 32766 => 32766, 32767 => 32767, 32768 => 32768,
+ 32769 => 32769, 32770 => 32770, 32771 => 32771, 32772 => 32772,
+ 65185292 => 42508, 235338675 => 64435, 578753291 => 4875,
+ 725428702 => 10718, 831988380 => 8860, 880887738 => 18362,
+ 994444768 => 1504, 1024813905 => 27473, 1155681093 => 19269,
+ 1206707727 => 58895, 1237051794 => 59794, 1370943847 => 61799,
+ 1383859255 => 1079, 1392915340 => 13196, 1476657022 => 65406,
+ 1496526464 => 11904, 1545061262 => 50062, 1552719781 => 40869,
+ 1773429172 => 25012, 1899457496 => 27608,
+ ];
+ $expectedCast = [];
+ foreach ($actualCast as $i => $value) {
+ $expectedCast[$i] = Cast::toUnsignedShort($i);
+ }
+ $this->assertEquals($expectedCast, $actualCast);
+ }
+
+ public function testCastToInteger()
+ {
+ $actualCast = [
+ -8452929933163922722 => -427853090, -8227809746396717005 => -494477261,
+ -8069447908768999435 => 2018383861, -7322911264700188104 => -774456776,
+ -7157486745453003186 => 481623630, -6294471842345940868 => 2084931708,
+ -5404213518254922788 => 437068764, -5121227846828842541 => 535667155,
+ -5031464466134410432 => -737223872, -4427976296617921775 => -969067759,
+ -4142492729853974452 => 1617586252, -3857943065800693273 => -817583641,
+ -3850022288003099130 => -1707272698, -3545206896667219024 => 1558358960,
+ -3304792279149730522 => -2133613274, -2947055917115005649 => -282745553,
+ -2434852234131164058 => 537637990, -585511715851594644 => 1423316076,
+ -536617668063548519 => -220222567, -461261921154970582 => 19188778,
+ -2147483652 => 2147483644, -2147483651 => 2147483645, -2147483650 => 2147483646,
+ -2147483649 => 2147483647, -2147483648 => -2147483648, -2147483647 => -2147483647,
+ -2147483646 => -2147483646, -2147483645 => -2147483645, -2147483644 => -2147483644,
+ -2147483643 => -2147483643, -2142666998 => -2142666998, -2111112991 => -2111112991,
+ -1997217588 => -1997217588, -1966955733 => -1966955733, -1945305989 => -1945305989,
+ -1935727095 => -1935727095, -1913777766 => -1913777766, -1907399486 => -1907399486,
+ -1872185297 => -1872185297, -1817768170 => -1817768170, -1809184885 => -1809184885,
+ -1715578898 => -1715578898, -1577437854 => -1577437854, -1539215067 => -1539215067,
+ -1508269993 => -1508269993, -1474712926 => -1474712926, -1444810077 => -1444810077,
+ -1416724699 => -1416724699, -1230624732 => -1230624732, -1066332637 => -1066332637,
+ -1025824621 => -1025824621, -992283120 => -992283120, -987038548 => -987038548,
+ -984948950 => -984948950, -954412209 => -954412209, -939075790 => -939075790,
+ -925408230 => -925408230, -793882323 => -793882323, -699831952 => -699831952,
+ -698682415 => -698682415, -613971751 => -613971751, -602932883 => -602932883,
+ -594971095 => -594971095, -580811529 => -580811529, -496216881 => -496216881,
+ -469326095 => -469326095, -425917711 => -425917711, -359291923 => -359291923,
+ -323749607 => -323749607, -228392199 => -228392199, -177008858 => -177008858,
+ -166848142 => -166848142, -124158916 => -124158916, -102650436 => -102650436,
+ -37289172 => -37289172, -19690778 => -19690778, 4395181 => 4395181,
+ 54754652 => 54754652, 75855103 => 75855103, 161761412 => 161761412,
+ 189289804 => 189289804, 233340012 => 233340012, 244137255 => 244137255,
+ 259625493 => 259625493, 275176129 => 275176129, 313881666 => 313881666,
+ 318619740 => 318619740, 361580175 => 361580175, 409226251 => 409226251,
+ 595656064 => 595656064, 699826088 => 699826088, 743084268 => 743084268,
+ 797188569 => 797188569, 911067568 => 911067568, 919736047 => 919736047,
+ 933800054 => 933800054, 981043666 => 981043666, 1011235379 => 1011235379,
+ 1054793626 => 1054793626, 1066243960 => 1066243960, 1111879441 => 1111879441,
+ 1137927280 => 1137927280, 1161141390 => 1161141390, 1207494809 => 1207494809,
+ 1224323748 => 1224323748, 1449283101 => 1449283101, 1476184529 => 1476184529,
+ 1484497025 => 1484497025, 1502384742 => 1502384742, 1560706915 => 1560706915,
+ 1643117236 => 1643117236, 1698234817 => 1698234817, 1700075744 => 1700075744,
+ 1704418040 => 1704418040, 1797315426 => 1797315426, 1827706831 => 1827706831,
+ 1873827816 => 1873827816, 1880985817 => 1880985817, 1885321445 => 1885321445,
+ 1887945117 => 1887945117, 1910918422 => 1910918422, 1913396612 => 1913396612,
+ 1917787717 => 1917787717, 2017893546 => 2017893546, 2023721819 => 2023721819,
+ 2039230599 => 2039230599, 2047689033 => 2047689033, 2051371828 => 2051371828,
+ 2113394667 => 2113394667, 2115087454 => 2115087454, 2147483643 => 2147483643,
+ 2147483644 => 2147483644, 2147483645 => 2147483645, 2147483646 => 2147483646,
+ 2147483647 => 2147483647, 2147483648 => -2147483648, 2147483649 => -2147483647,
+ 2147483650 => -2147483646, 2147483651 => -2147483645, 2147483652 => -2147483644,
+ 327455818446817879 => -98022825, 672496567785689947 => -1148644517,
+ 792152072093005609 => -785287383, 1158754392966664154 => 649301978,
+ 1278651530880918028 => -240475636, 3110847094773196354 => 1652762178,
+ 3413345115981031970 => 1574775330, 3796731187096572787 => 311022451,
+ 4820534321106058373 => -1122304891, 4896272896071787352 => -440704168,
+ 5416563045183045367 => 866434807, 5691445208075489224 => 502288328,
+ 6819073379251083503 => 60119279, 7303323242445241002 => 1506638506,
+ 7332172857428419953 => -959961743, 7646687339917640626 => 742687666,
+ 8920184389710306811 => -139796997, 9024321225994333040 => -1771508880,
+ 9145625486009181568 => -2041174656, 9158246028883747635 => -1522572493,
+ ];
+ $expectedCast = [];
+ foreach ($actualCast as $i => $value) {
+ $expectedCast[$i] = Cast::toInt($i);
+ }
+ $this->assertEquals($expectedCast, $actualCast);
+ }
+
+ public function testCastToUnsignedInteger()
+ {
+ $actualCast = [
+ -8828932113531775224 => 988174088, -7828090724457198174 => 187878818,
+ -7545005068607356200 => 2166555352, -6180013005281428198 => 820509978,
+ -6072007252811478348 => 244550324, -5660547352329380296 => 2250317368,
+ -5565331019887832226 => 2115998558, -4759428743990999011 => 480380957,
+ -3954477486133169125 => 2529211419, -3655807572739950250 => 3282499926,
+ -3372368140996826985 => 403318935, -2535801509511858890 => 1554347318,
+ -2465348119161971711 => 2511100929, -2236391832617430983 => 641781817,
+ -2216259712569952100 => 2655139996, -1985261835992256688 => 3884625744,
+ -1403646043161133542 => 1325366810, -1117145770488479569 => 867451055,
+ -418467636862935386 => 3267993254, -56945642043723349 => 2189046187,
+ -2147483652 => 2147483644, -2147483651 => 2147483645, -2147483650 => 2147483646,
+ -2147483649 => 2147483647, -2147483648 => 2147483648, -2147483647 => 2147483649,
+ -2147483646 => 2147483650, -2147483645 => 2147483651, -2147483644 => 2147483652,
+ -2147483643 => 2147483653, -2117373025 => 2177594271, -2096041860 => 2198925436,
+ -2085299236 => 2209668060, -2075027272 => 2219940024, -1840198102 => 2454769194,
+ -1838509590 => 2456457706, -1761991609 => 2532975687, -1673101190 => 2621866106,
+ -1639045374 => 2655921922, -1635955752 => 2659011544, -1603451580 => 2691515716,
+ -1412048907 => 2882918389, -1371267853 => 2923699443, -1356726173 => 2938241123,
+ -1339647661 => 2955319635, -1238648054 => 3056319242, -1235985228 => 3058982068,
+ -1053158605 => 3241808691, -1001847353 => 3293119943, -947719476 => 3347247820,
+ -933098500 => 3361868796, -931941220 => 3363026076, -909764689 => 3385202607,
+ -903828185 => 3391139111, -902419021 => 3392548275, -889390863 => 3405576433,
+ -800969358 => 3493997938, -754863798 => 3540103498, -733394875 => 3561572421,
+ -732264085 => 3562703211, -723061508 => 3571905788, -687630440 => 3607336856,
+ -656521337 => 3638445959, -641159514 => 3653807782, -574042748 => 3720924548,
+ -484453139 => 3810514157, -451429548 => 3843537748, -384042562 => 3910924734,
+ -369278089 => 3925689207, -355941740 => 3939025556, -347870529 => 3947096767,
+ -337105223 => 3957862073, -324979224 => 3969988072, -186559730 => 4108407566,
+ -177881990 => 4117085306, -174455223 => 4120512073, -113198032 => 4181769264,
+ -101455194 => 4193512102, 3247566 => 3247566, 7056894 => 7056894, 37982905 => 37982905,
+ 102721942 => 102721942, 120367902 => 120367902, 131098802 => 131098802,
+ 149697898 => 149697898, 175671830 => 175671830, 205852524 => 205852524,
+ 208289875 => 208289875, 217767742 => 217767742, 459120502 => 459120502,
+ 574068094 => 574068094, 641095145 => 641095145, 658830419 => 658830419,
+ 690676300 => 690676300, 701773339 => 701773339, 709110931 => 709110931,
+ 731722628 => 731722628, 798837210 => 798837210, 819990933 => 819990933,
+ 912629027 => 912629027, 990890039 => 990890039, 1018131378 => 1018131378,
+ 1073281498 => 1073281498, 1193197075 => 1193197075, 1209553263 => 1209553263,
+ 1244583127 => 1244583127, 1262106470 => 1262106470, 1298309267 => 1298309267,
+ 1302783738 => 1302783738, 1312432823 => 1312432823, 1460631232 => 1460631232,
+ 1464332686 => 1464332686, 1485792110 => 1485792110, 1497462232 => 1497462232,
+ 1512577020 => 1512577020, 1602608160 => 1602608160, 1716466972 => 1716466972,
+ 1739230715 => 1739230715, 1777289703 => 1777289703, 1866315228 => 1866315228,
+ 1877556734 => 1877556734, 1929669130 => 1929669130, 1959469805 => 1959469805,
+ 1961146571 => 1961146571, 1966221636 => 1966221636, 1968675697 => 1968675697,
+ 1970375630 => 1970375630, 1979086003 => 1979086003, 2069784384 => 2069784384,
+ 2123695038 => 2123695038, 2147483643 => 2147483643, 2147483644 => 2147483644,
+ 2147483645 => 2147483645, 2147483646 => 2147483646, 2147483647 => 2147483647,
+ 2147483648 => 2147483648, 2147483649 => 2147483649, 2147483650 => 2147483650,
+ 2147483651 => 2147483651, 2147483652 => 2147483652, 373886987528618573 => 4123574861,
+ 771009823256948802 => 1771122754, 1004387722978796655 => 82453615,
+ 1243209402936707322 => 2071447802, 1655422001120174598 => 170025478,
+ 1772149229859146775 => 3602680855, 2507262907742073252 => 2658840996,
+ 2813363649054078496 => 1037923872, 2876534798038013405 => 2233144797,
+ 3297680158231392904 => 3460067976, 3328118166197267052 => 3400952428,
+ 3572321990398647028 => 1560746740, 4149946872460889004 => 1908820908,
+ 5788410328610570922 => 2332623530, 5825079135471215377 => 1917657873,
+ 6555518071408694953 => 217585321, 6739408486548733086 => 3304946846,
+ 7079040899421182461 => 276175357, 8145391911457264336 => 2394952400,
+ 9147936191948800226 => 2903261410,
+ ];
+ $expectedCast = [];
+ foreach ($actualCast as $i => $value) {
+ $expectedCast[$i] = Cast::toUnsignedInt($i);
+ }
+ $this->assertEquals($expectedCast, $actualCast);
+ }
+
+ public function testCastToLong()
+ {
+ $actualCast = [
+ -9170085652559072218 => -9170085652559072218,
+ -8762875851716154430 => -8762875851716154430,
+ -8412777414798920406 => -8412777414798920406,
+ -8411232067770162241 => -8411232067770162241,
+ -7956962034042337916 => -7956962034042337916,
+ -7741733997731069659 => -7741733997731069659,
+ -7562413780647168309 => -7562413780647168309,
+ -7279532752286714404 => -7279532752286714404,
+ -6875053898264821215 => -6875053898264821215,
+ -6546188122974899677 => -6546188122974899677,
+ -6539319718043370422 => -6539319718043370422,
+ -6269360680735574015 => -6269360680735574015,
+ -6245212573735285524 => -6245212573735285524,
+ -6008164560998165202 => -6008164560998165202,
+ -5673130211365790481 => -5673130211365790481,
+ -5550558879395416461 => -5550558879395416461,
+ -5315545680575876829 => -5315545680575876829,
+ -5270629292889039812 => -5270629292889039812,
+ -5257244755715615860 => -5257244755715615860,
+ -4697072760722166619 => -4697072760722166619,
+ -4378266633934162190 => -4378266633934162190,
+ -4351003675881447778 => -4351003675881447778,
+ -4344868801464432933 => -4344868801464432933,
+ -4043702893244004445 => -4043702893244004445,
+ -3481799324128556516 => -3481799324128556516,
+ -3401006296811244597 => -3401006296811244597,
+ -3310806867794682093 => -3310806867794682093,
+ -3057425529762577563 => -3057425529762577563,
+ -3040853836146852724 => -3040853836146852724,
+ -2817295230844194271 => -2817295230844194271,
+ -2568471746216522125 => -2568471746216522125,
+ -2519307567020828553 => -2519307567020828553,
+ -1975798901698214275 => -1975798901698214275,
+ -1859083390560038307 => -1859083390560038307,
+ -1774553370580724902 => -1774553370580724902,
+ -1634855409015608429 => -1634855409015608429,
+ -1381951605941422786 => -1381951605941422786,
+ -1196959685645247832 => -1196959685645247832,
+ -1036253099502184090 => -1036253099502184090,
+ -914626182071417150 => -914626182071417150,
+ -912583761313393119 => -912583761313393119,
+ -756029432352135202 => -756029432352135202,
+ -669177640404047297 => -669177640404047297,
+ -279752108241877035 => -279752108241877035,
+ -14037444279643280 => -14037444279643280,
+ 89235183761627361 => 89235183761627361,
+ 98272798287346379 => 98272798287346379,
+ 341611274837543729 => 341611274837543729,
+ 617025623621229439 => 617025623621229439,
+ 688607913671564955 => 688607913671564955,
+ 700601410549691241 => 700601410549691241,
+ 700727038457061037 => 700727038457061037,
+ 816476720065344870 => 816476720065344870,
+ 955192676500220111 => 955192676500220111,
+ 1214033201966406170 => 1214033201966406170,
+ 1414590019617251171 => 1414590019617251171,
+ 1528545757917024358 => 1528545757917024358,
+ 1614326684909925062 => 1614326684909925062,
+ 1944672876122505798 => 1944672876122505798,
+ 2207144148508699333 => 2207144148508699333,
+ 2278302445836788036 => 2278302445836788036,
+ 2384226760042317776 => 2384226760042317776,
+ 2573344486686176471 => 2573344486686176471,
+ 2616173938058851058 => 2616173938058851058,
+ 2805044067171596076 => 2805044067171596076,
+ 2894299281119165483 => 2894299281119165483,
+ 3063062202059229841 => 3063062202059229841,
+ 3466271178310710564 => 3466271178310710564,
+ 3632373078879533041 => 3632373078879533041,
+ 3642290790028469551 => 3642290790028469551,
+ 3662343508920772932 => 3662343508920772932,
+ 3769065765930590922 => 3769065765930590922,
+ 4256964567272000930 => 4256964567272000930,
+ 4263913004739735112 => 4263913004739735112,
+ 4526517888199137475 => 4526517888199137475,
+ 4649412930539981605 => 4649412930539981605,
+ 4733367622092357447 => 4733367622092357447,
+ 4907150005690517596 => 4907150005690517596,
+ 5037687856571754869 => 5037687856571754869,
+ 5204427615109238084 => 5204427615109238084,
+ 5480343987422738941 => 5480343987422738941,
+ 5501650918319918819 => 5501650918319918819,
+ 5664389307290364572 => 5664389307290364572,
+ 5680136290368167730 => 5680136290368167730,
+ 5741424946854193175 => 5741424946854193175,
+ 6043965460032981040 => 6043965460032981040,
+ 6203696150695101213 => 6203696150695101213,
+ 6350747862140861316 => 6350747862140861316,
+ 6974846481983904529 => 6974846481983904529,
+ 7028264173177402809 => 7028264173177402809,
+ 7311629905623817713 => 7311629905623817713,
+ 7595125476648712557 => 7595125476648712557,
+ 7638473695518713044 => 7638473695518713044,
+ 7720855249472985748 => 7720855249472985748,
+ 7966241693142082724 => 7966241693142082724,
+ 8110180085178212388 => 8110180085178212388,
+ 8684182726111037256 => 8684182726111037256,
+ 8842898155976061943 => 8842898155976061943,
+ 8882296136384501261 => 8882296136384501261,
+ 8989594683105135694 => 8989594683105135694,
+ 9223372036854775803 => 9223372036854775803,
+ 9223372036854775804 => 9223372036854775804,
+ 9223372036854775805 => 9223372036854775805,
+ 9223372036854775806 => 9223372036854775806,
+ 9223372036854775807 => 9223372036854775807,
+ ];
+ $expectedCast = [];
+ foreach ($actualCast as $i => $value) {
+ $expectedCast[$i] = Cast::toLong($i);
+ }
+ $this->assertEquals($expectedCast, $actualCast);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/FileBufferTest.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/FileBufferTest.php
new file mode 100644
index 00000000000..c10d300701a
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/FileBufferTest.php
@@ -0,0 +1,42 @@
+outputFilename = tempnam(sys_get_temp_dir(), 'temp');
+ parent::setUp();
+ }
+
+ /**
+ * After test
+ */
+ protected function tearDown()
+ {
+ parent::tearDown();
+
+ if ($this->outputFilename !== null && file_exists($this->outputFilename)) {
+ unlink($this->outputFilename);
+ }
+ }
+
+ /**
+ * @return Buffer
+ * @throws BufferException
+ */
+ protected function createBuffer()
+ {
+ return new FileBuffer($this->outputFilename);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/GetInfoIcoFileTest.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/GetInfoIcoFileTest.php
new file mode 100644
index 00000000000..f944ae94c09
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/GetInfoIcoFileTest.php
@@ -0,0 +1,51 @@
+setReadOnly(true);
+ $buffer->setOrder(Buffer::LITTLE_ENDIAN);
+
+ // ico header
+ $this->assertEquals($buffer->getShort(), 0); // reserved
+ $type = $buffer->getShort();
+ $this->assertTrue($type === 1 || $type === 2); // type icon
+ $count = $buffer->getShort();
+ $this->assertTrue($count > 0); // count images
+
+ // image directory
+ for ($i = 0; $i < $count; $i++) {
+ $width = $buffer->getByte();
+ $height = $buffer->getByte();
+ $colors = $buffer->getByte();
+ $this->assertEquals($buffer->getByte(), 0); // reserved
+ $planes = $buffer->getShort();
+ $bpp = $buffer->getShort();
+ $size = $buffer->getInt();
+ $offset = $buffer->getInt();
+
+ $buffer->setPosition($offset + $size);
+ $this->assertFalse($buffer->hasRemaining());
+
+ $this->assertEquals($width, 16);
+ $this->assertEquals($height, 16);
+ $this->assertEquals($colors, 0);
+ $this->assertEquals($planes, 1);
+ $this->assertEquals($bpp, 32);
+ }
+ $buffer->close();
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/MemoryResourceBufferTest.php b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/MemoryResourceBufferTest.php
new file mode 100644
index 00000000000..8f77c117849
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/nelexa/buffer/tests/Nelexa/Buffer/MemoryResourceBufferTest.php
@@ -0,0 +1,16 @@
+fromBinary($binary);
+if ($webp->getExif() !== null) {
+ // Extract the EXIF data to store it separate from the file.
+ $exifData = $webp->getExif()->getRawBytes();
+ // … or just retrieve the parsed EXIF data.
+ \var_dump($webp->getExif()->getParsedExif());
+
+ // Strip the EXIF data.
+ $webp->withExif(null);
+}
+
+$encoder = new Encoder();
+$binary = $encoder->fromWebP($webp);
+```
+
+The decoder performs a strict validation of the input data and will throw an
+exception when it encounters any violations. If you do not care about the
+actual problem, you can catch the generic interface `WebpExifException` that
+is implemented by all exceptions.
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/composer.json b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/composer.json
new file mode 100644
index 00000000000..236331ad907
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/composer.json
@@ -0,0 +1,44 @@
+{
+ "name": "woltlab/webp-exif",
+ "description": "Extract and embed EXIF metadata from and to WebP images",
+ "type": "library",
+ "license": "MIT",
+ "keywords": [
+ "exif",
+ "webp"
+ ],
+ "require": {
+ "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
+ "ext-exif": "*",
+ "nelexa/buffer": "^1.3",
+ "symfony/polyfill-php84": "^1.31"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.2",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpstan/phpstan-deprecation-rules": "^2.0",
+ "phpstan/extension-installer": "^1.4",
+ "phpunit/php-code-coverage": "^12.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "WoltLab\\WebpExif\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "WoltLabTest\\WebpExif\\": "test/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit --colors=always test",
+ "phpstan": "phpstan"
+ },
+ "config": {
+ "allow-plugins": {
+ "phpstan/extension-installer": true
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Alph.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Alph.php
new file mode 100644
index 00000000000..49f8727e8ac
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Alph.php
@@ -0,0 +1,23 @@
+
+ */
+final class Alph extends Chunk
+{
+ private function __construct(int $offset, string $data)
+ {
+ parent::__construct("ALPH", $offset, $data);
+ }
+
+ public static function forBytes(int $offset, string $bytes): self
+ {
+ return new Alph($offset, $bytes);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Anim.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Anim.php
new file mode 100644
index 00000000000..0470d137941
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Anim.php
@@ -0,0 +1,23 @@
+
+ */
+final class Anim extends Chunk
+{
+ private function __construct(int $offset, string $data)
+ {
+ parent::__construct("ANIM", $offset, $data);
+ }
+
+ public static function forBytes(int $offset, string $bytes): self
+ {
+ return new Anim($offset, $bytes);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Anmf.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Anmf.php
new file mode 100644
index 00000000000..41ef78371b0
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Anmf.php
@@ -0,0 +1,115 @@
+
+ */
+final class Anmf extends Chunk
+{
+ /** @param list $chunks */
+ private function __construct(
+ int $offset,
+ string $data,
+ private readonly array $chunks
+ ) {
+ parent::__construct("ANMF", $offset, $data);
+ }
+
+ #[Override]
+ public function getLength(): int
+ {
+ return \array_reduce(
+ $this->chunks,
+ static function ($acc, $chunk) {
+ $paddingByte = $chunk->getLength() % 2;
+ return $acc + $chunk->getLength() + $paddingByte + 8;
+ },
+ parent::getLength(),
+ );
+ }
+
+ /**
+ * @return list
+ */
+ public function getDataChunks(): array
+ {
+ return $this->chunks;
+ }
+
+ public static function fromBuffer(Buffer $buffer): self
+ {
+ $offset = $buffer->position();
+ $length = $buffer->getUnsignedInt();
+ if ($length > $buffer->remaining()) {
+ throw new LengthOutOfBounds($length, $offset, $buffer->remaining());
+ }
+
+ // An animation frame contains at least 16 bytes for the header.
+ if ($buffer->remaining() < 16) {
+ throw new UnexpectedEndOfFile($buffer->position(), $buffer->remaining());
+ }
+
+ // The next 8 bytes contain the X and Y coordinates, as well as the
+ // frame witdth and height. Afterwards there are 3 bytes for the frame
+ // duration followed by 1 byte representing a bit field. (= 16 bytes)
+ $frameHeader = $buffer->getString(16);
+
+ $chunks = [];
+ $decoder = new Decoder();
+ while ($buffer->position() < $offset + 4 + $length) {
+ $chunks[] = $decoder->decodeChunk($buffer);
+ }
+
+ self::validateChunks($offset, $chunks);
+
+ return new Anmf($offset, $frameHeader, $chunks);
+ }
+
+ /**
+ * @param list $chunks
+ */
+ private static function validateChunks(int $offset, array $chunks): void
+ {
+ if ($chunks === []) {
+ throw new EmptyAnimationFrame($offset);
+ }
+
+ // An ALPH chunk can only appear at the start of the frame data.
+ if ($chunks[0] instanceof Alph) {
+ \array_shift($chunks);
+
+ if ($chunks === []) {
+ throw new AnimationFrameWithoutBitstream($offset);
+ }
+ }
+
+ // A bitstream chunk can only appear at the first position or after an
+ // ALPH chunk.
+ if ($chunks[0] instanceof Vp8 || $chunks[0] instanceof Vp8l) {
+ \array_shift($chunks);
+ } else {
+ throw new AnimationFrameWithoutBitstream($offset);
+ }
+
+ // After the bitstream chunk there may be an infinite number of unknown
+ // chunks, but no known ones.
+ $disallowedChunk = \array_find($chunks, static fn($chunk) => !($chunk instanceof UnknownChunk));
+ if ($disallowedChunk instanceof Chunk) {
+ throw new UnexpectedChunk($disallowedChunk->getFourCC(), $disallowedChunk->getOffset());
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Chunk.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Chunk.php
new file mode 100644
index 00000000000..03e4915f3f0
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Chunk.php
@@ -0,0 +1,39 @@
+
+ */
+abstract class Chunk
+{
+ protected function __construct(
+ private readonly string $fourCC,
+ private readonly int $offset,
+ private readonly string $data,
+ ) {}
+
+ public function getFourCC(): string
+ {
+ return $this->fourCC;
+ }
+
+ public function getLength(): int
+ {
+ return \strlen($this->data);
+ }
+
+ public function getOffset(): int
+ {
+ return $this->offset;
+ }
+
+ public function getRawBytes(): string
+ {
+ return $this->data;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/AnimationFrameWithoutBitstream.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/AnimationFrameWithoutBitstream.php
new file mode 100644
index 00000000000..b0ca6705d4b
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/AnimationFrameWithoutBitstream.php
@@ -0,0 +1,24 @@
+
+ *
+ * @internal
+ */
+final class AnimationFrameWithoutBitstream extends RuntimeException implements WebpExifException
+{
+ public function __construct(int $offset)
+ {
+ $offset = \dechex($offset);
+ parent::__construct("The ANMF frame at offset 0x{$offset} does not contain a bitstream chunk");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/DimensionsExceedInt32.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/DimensionsExceedInt32.php
new file mode 100644
index 00000000000..71ce47ed503
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/DimensionsExceedInt32.php
@@ -0,0 +1,23 @@
+
+ *
+ * @internal
+ */
+final class DimensionsExceedInt32 extends OutOfRangeException implements WebpExifException
+{
+ public function __construct(int $width, int $height)
+ {
+ parent::__construct("The product of {$width} and {$height} exceeds the boundary of 2^31 - 1");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/EmptyAnimationFrame.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/EmptyAnimationFrame.php
new file mode 100644
index 00000000000..90a19d349a4
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/EmptyAnimationFrame.php
@@ -0,0 +1,24 @@
+
+ *
+ * @internal
+ */
+final class EmptyAnimationFrame extends RuntimeException implements WebpExifException
+{
+ public function __construct(int $offset)
+ {
+ $offset = \dechex($offset);
+ parent::__construct("The ANMF frame at offset 0x{$offset} contains no chunks");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/ExpectedKeyFrame.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/ExpectedKeyFrame.php
new file mode 100644
index 00000000000..d145ed4d73f
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/ExpectedKeyFrame.php
@@ -0,0 +1,23 @@
+
+ *
+ * @internal
+ */
+final class ExpectedKeyFrame extends RuntimeException implements WebpExifException
+{
+ public function __construct()
+ {
+ parent::__construct("Expected a keyframe to be the first frame");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/MissingExifExtension.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/MissingExifExtension.php
new file mode 100644
index 00000000000..f675160691e
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/MissingExifExtension.php
@@ -0,0 +1,26 @@
+
+ *
+ * @internal
+ */
+final class MissingExifExtension extends RuntimeException implements WebpExifException
+{
+ /**
+ * @codeCoverageIgnore
+ */
+ public function __construct()
+ {
+ parent::__construct("The `php_exif` extension is required to parse EXIF data");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/MissingMagicByte.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/MissingMagicByte.php
new file mode 100644
index 00000000000..f03a7fca553
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/MissingMagicByte.php
@@ -0,0 +1,23 @@
+
+ *
+ * @internal
+ */
+final class MissingMagicByte extends RuntimeException implements WebpExifException
+{
+ public function __construct(string $fourCC)
+ {
+ parent::__construct("The data for `{$fourCC}` is missing the magic byte");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/UnknownChunkWithKnownFourCC.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/UnknownChunkWithKnownFourCC.php
new file mode 100644
index 00000000000..3a2189cd802
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/UnknownChunkWithKnownFourCC.php
@@ -0,0 +1,24 @@
+
+ *
+ * @internal
+ */
+final class UnknownChunkWithKnownFourCC extends RuntimeException implements WebpExifException
+{
+ public function __construct(string $fourCC)
+ {
+ parent::__construct("The FourCC code `{$fourCC}` is well-known and must not be used with " . UnknownChunk::class);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/UnsupportedVersion.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/UnsupportedVersion.php
new file mode 100644
index 00000000000..cccf00e6a2d
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exception/UnsupportedVersion.php
@@ -0,0 +1,23 @@
+
+ *
+ * @internal
+ */
+final class UnsupportedVersion extends RuntimeException implements WebpExifException
+{
+ public function __construct(string $fourCC, int $found, int $expected)
+ {
+ parent::__construct("Expected version `{$expected}` for `{$fourCC}` but found `{$found}`");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exif.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exif.php
new file mode 100644
index 00000000000..da34eedb631
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Exif.php
@@ -0,0 +1,87 @@
+
+ */
+final class Exif extends Chunk
+{
+ private function __construct(int $offset, string $data)
+ {
+ parent::__construct("EXIF", $offset, $data);
+ }
+
+ /**
+ * @param bool $sectionsAsArrays Creates separate arrays for each contained section
+ * @return ($sectionsAsArrays is true ? null|array> : null|array)
+ * @throws MissingExifExtension
+ */
+ public function getParsedExif(bool $sectionsAsArrays = true): ?array
+ {
+ $bytes = $this->getRawBytes();
+ if (\strlen($bytes) === 0) {
+ return null;
+ }
+
+ // We're offloading the EXIF decoding task for `exif_read_data()` which
+ // cannot process WebP.
+ if (!\function_exists('exif_read_data')) {
+ // @codeCoverageIgnoreStart
+ throw new MissingExifExtension();
+ // @codeCoverageIgnoreEnd
+ }
+
+ // A tiny JPEG is used as the host for the EXIF data.
+ // See https://github.com/mathiasbynens/small/blob/267b39f682598eebb0dafe7590b1504be79b5cad/jpeg.jpg
+ // This is a modified version without the leading 0xFF 0xD8 (SOI)!
+ $jpegBody = "\xFF\xDB\x00\x43\x00\x03\x02\x02\x02\x02\x02\x03\x02\x02\x02\x03\x03\x03\x03\x04\x06\x04\x04\x04\x04\x04\x08\x06\x06\x05\x06\x09\x08\x0A\x0A\x09\x08\x09\x09\x0A\x0C\x0F\x0C\x0A\x0B\x0E\x0B\x09\x09\x0D\x11\x0D\x0E\x0F\x10\x10\x11\x10\x0A\x0C\x12\x13\x12\x10\x13\x0F\x10\x10\x10\xFF\xC9\x00\x0B\x08\x00\x01\x00\x01\x01\x01\x11\x00\xFF\xCC\x00\x06\x00\x10\x10\x05\xFF\xDA\x00\x08\x01\x01\x00\x00\x3F\x00\xD2\xCF\x20\xFF\xD9";
+
+ // The image does not have a JFIF tag and instead directly starts with
+ // the quantization table (DQT, 0xFF xDB). The SOI (start of image, 0xFF
+ // 0xD8) is prepended below to simpify the construction of the image.
+ $soiTag = "\xFF\xD8";
+
+ $app1Tag = "\xFF\xE1";
+ $exifHeader = "\x45\x78\x69\x66\x00\x00";
+
+ // The byte length is the length of the EXIF header (6), the payload and
+ // the two bytes for the length itself.
+ $byteLength = \pack("n", 2 + 6 + \strlen($bytes));
+
+ // We must suppress warnings here to gracefully recover from bad EXIF data.
+ $exif = @\exif_read_data(
+ \sprintf(
+ "data://image/jpeg;base64,%s",
+ \base64_encode(
+ $soiTag .
+ $app1Tag .
+ $byteLength .
+ $exifHeader .
+ $bytes .
+ $jpegBody
+ ),
+ ),
+ as_arrays: $sectionsAsArrays,
+ );
+
+ // There is no known case where the call to `\exif_read_data()` can fail
+ // hard, there may be warnings about garbage data but since the
+ // constructed host image is guaranteed to be valid, it is infallible.
+ \assert($exif !== false);
+
+ /** @var array $exif */
+ return $exif;
+ }
+
+ public static function forBytes(int $offset, string $bytes): self
+ {
+ return new Exif($offset, $bytes);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Iccp.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Iccp.php
new file mode 100644
index 00000000000..37d2e91b201
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Iccp.php
@@ -0,0 +1,23 @@
+
+ */
+final class Iccp extends Chunk
+{
+ private function __construct(int $offset, string $data)
+ {
+ parent::__construct("ICCP", $offset, $data);
+ }
+
+ public static function forBytes(int $offset, string $bytes): self
+ {
+ return new Iccp($offset, $bytes);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/UnknownChunk.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/UnknownChunk.php
new file mode 100644
index 00000000000..419ffdc3697
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/UnknownChunk.php
@@ -0,0 +1,25 @@
+
+ */
+final class UnknownChunk extends Chunk
+{
+ public static function forBytes(string $fourCC, int $offset, string $bytes): self
+ {
+ if (ChunkType::fromFourCC($fourCC) !== ChunkType::UnknownChunk) {
+ throw new UnknownChunkWithKnownFourCC($fourCC);
+ }
+
+ return new UnknownChunk($fourCC, $offset, $bytes);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Vp8.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Vp8.php
new file mode 100644
index 00000000000..c88c50c3b0b
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Vp8.php
@@ -0,0 +1,66 @@
+
+ */
+final class Vp8 extends Chunk
+{
+ private function __construct(
+ public readonly int $width,
+ public readonly int $height,
+ int $offset,
+ string $data,
+ ) {
+ parent::__construct("VP8 ", $offset, $data);
+ }
+
+ public static function fromBuffer(Buffer $buffer): self
+ {
+ $length = $buffer->getUnsignedInt();
+ $startOfData = $buffer->position();
+ if ($length > $buffer->remaining()) {
+ throw new LengthOutOfBounds($length, $buffer->position(), $buffer->remaining());
+ }
+
+ $tag = $buffer->getUnsignedByte();
+
+ // We expect the first frame to be a keyframe.
+ $frameType = $tag & 1;
+ if ($frameType !== 0) {
+ throw new ExpectedKeyFrame();
+ }
+
+ // Skip the next two bytes, they are part of the header but do not
+ // contain any information that is relevant to us.
+ $buffer->skip(2);
+
+ // Keyframes must start with 3 magic bytes.
+ $marker = $buffer->getString(3);
+ if ($marker !== "\x9D\x01\x2A") {
+ throw new MissingMagicByte("VP8");
+ }
+
+ // The width and height are encoded using 2 bytes each. However, the
+ // first two bits are the scale followed by 14 bits for the dimension.
+ $width = $buffer->getUnsignedShort() & 0x3FFF;
+ $height = $buffer->getUnsignedShort() & 0x3FFF;
+
+ return new Vp8(
+ $width,
+ $height,
+ $startOfData - 8,
+ $buffer->setPosition($startOfData)->getString($length)
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Vp8l.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Vp8l.php
new file mode 100644
index 00000000000..7d2171f866f
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Vp8l.php
@@ -0,0 +1,63 @@
+
+ */
+final class Vp8l extends Chunk
+{
+ private function __construct(
+ public readonly int $width,
+ public readonly int $height,
+ int $offset,
+ string $data,
+ ) {
+ parent::__construct("VP8L", $offset, $data);
+ }
+
+ public static function fromBuffer(Buffer $buffer): self
+ {
+ $length = $buffer->getUnsignedInt();
+ $startOfData = $buffer->position();
+ if ($length > $buffer->remaining()) {
+ throw new LengthOutOfBounds($length, $buffer->position(), $buffer->remaining());
+ }
+
+ $signature = $buffer->getUnsignedByte();
+ if ($signature !== 0x2F) {
+ throw new MissingMagicByte("VP8L");
+ }
+
+ $header = $buffer->getUnsignedInt();
+
+ // The header contains the following data:
+ // 0-13: width - 1
+ // 14-27: height - 1
+ // 28: alpha_is_used
+ // 29-31: version (must be 0)
+ $version = $header >> 29;
+ if ($version !== 0) {
+ throw new UnsupportedVersion("VP8L", $version, 0);
+ }
+
+ $width = ($header & 0x3FFF) + 1;
+ $height = (($header >> 14) & 0x3FFF) + 1;
+
+ return new Vp8l(
+ $width,
+ $height,
+ $startOfData - 8,
+ $buffer->setPosition($startOfData)->getString($length)
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Vp8x.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Vp8x.php
new file mode 100644
index 00000000000..5e46318d402
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Vp8x.php
@@ -0,0 +1,227 @@
+
+ */
+final class Vp8x extends Chunk
+{
+ private function __construct(
+ public readonly int $width,
+ public readonly int $height,
+ int $offset,
+ public readonly bool $iccProfile,
+ public readonly bool $alpha,
+ public readonly bool $exif,
+ public readonly bool $xmp,
+ public readonly bool $animation,
+ ) {
+ parent::__construct(
+ "VP8X",
+ $offset,
+ // VP8X only contains the header because the actual payload are the
+ // chunks that follow afterwards.
+ ""
+ );
+ }
+
+ /**
+ * Filters the list of chunks and validates them against the specificiations
+ * for VP8X images.
+ *
+ * @param list $chunks
+ * @return list
+ */
+ public function filterChunks(array $chunks): array
+ {
+ if ($chunks === []) {
+ throw new Vp8xWithoutChunks();
+ }
+
+ $nestedVp8x = \array_find($chunks, static fn($chunk) => $chunk instanceof Vp8x);
+ if ($nestedVp8x !== null) {
+ throw new ExtraVp8xChunk();
+ }
+
+ if ($this->iccProfile) {
+ $hasIccProfile = self::removeExtraChunks(Iccp::class, $chunks);
+ if (!$hasIccProfile) {
+ throw new Vp8xAbsentChunk("ICCP");
+ }
+ }
+
+ if ($this->alpha) {
+ $hasAlpha = self::removeExtraChunks(Alph::class, $chunks);
+ if (!$hasAlpha) {
+ throw new Vp8xAbsentChunk("ALPH");
+ }
+ }
+
+ if ($this->exif) {
+ $hasExif = self::removeExtraChunks(Exif::class, $chunks);
+ if (!$hasExif) {
+ throw new Vp8xAbsentChunk("EXIF");
+ }
+ }
+
+ if ($this->xmp) {
+ $hasXmp = self::removeExtraChunks(Xmp::class, $chunks);
+ if (!$hasXmp) {
+ throw new Vp8xAbsentChunk("XMP ");
+ }
+ }
+
+ $frames = \array_filter(
+ $chunks,
+ static fn($chunk) => $chunk instanceof Anmf
+ );
+ $bitstreams = \array_filter(
+ $chunks,
+ static fn($chunk) => ($chunk instanceof Vp8) || ($chunk instanceof Vp8l)
+ );
+
+ // The VP8X chunk must contain image data that can come in two flavors:
+ // 1. Still images must contain either a VP8 or VP8L chunk.
+ // 2. Animated images must contain multiple frames.
+ if ($this->animation) {
+ if (\count($frames) < 2) {
+ throw new Vp8xMissingImageData(stillImage: false);
+ }
+
+ $bitstream = \array_shift($bitstreams);
+ if ($bitstream !== null) {
+ throw new UnexpectedChunk($bitstream->getFourCC(), $bitstream->getOffset());
+ }
+ } else {
+ if (\count($bitstreams) !== 1) {
+ throw new Vp8xMissingImageData(stillImage: true);
+ }
+
+ $frame = \array_shift($frames);
+ if ($frame !== null) {
+ throw new UnexpectedChunk($frame->getFourCC(), $frame->getOffset());
+ }
+ }
+
+ return $chunks;
+ }
+
+ /**
+ * Removes all chunks sharing the same class except for the first
+ * occurrence. Returns true if at least one such chunk was found.
+ *
+ * @param class-string $className
+ * @param list $chunks
+ */
+ private function removeExtraChunks(string $className, array &$chunks): bool
+ {
+ $hasChunk = false;
+ $chunks = \array_values(\array_filter($chunks, static function ($chunk) use ($className, &$hasChunk) {
+ if (!($chunk instanceof $className)) {
+ return true;
+ }
+
+ if (!$hasChunk) {
+ $hasChunk = true;
+ return true;
+ }
+
+ return false;
+ }));
+
+ return $hasChunk;
+ }
+
+ public static function fromBuffer(Buffer $buffer): self
+ {
+ // The next 4 bytes represent the length of the VP8X header which must
+ // be 10 bytes long.
+ $expectedHeaderLength = 10;
+ $length = $buffer->getUnsignedInt();
+ if ($length !== $expectedHeaderLength) {
+ throw new Vp8xHeaderLengthMismatch($expectedHeaderLength, $length);
+ }
+
+ $startOfData = $buffer->position();
+
+ // The next byte contains a bit field for the features of this image,
+ // the first two bits and the last bit are reserved and MUST be ignored.
+ $bitField = $buffer->getByte();
+ $iccProfile = ($bitField & 0b00100000) === 32;
+ $alpha = ($bitField & 0b00010000) === 16;
+ $exif = ($bitField & 0b00001000) === 8;
+ $xmp = ($bitField & 0b00000100) === 4;
+ $animation = ($bitField & 0b00000010) === 2;
+
+ // The next 24 bits are reserved.
+ $buffer->skip(3);
+
+ // The width of the canvas is represented as a uint24LE but minus one,
+ // therefore we have to add 1 when decoding the value.
+ $width = self::decodeDimension($buffer);
+
+ // The height follows the same rules as the width.
+ $height = self::decodeDimension($buffer);
+
+ // The product of `width` and `height` must not exceed 2^31 - 1, the
+ // maximum value of int32. We cannot assume that PHP is a 64 bit build
+ // therefore we calculate the largest possible value for `$height` that
+ // would not exceed the maximum value. This approach avoids hitting an
+ // integer overflow on 32 bit builds when calculating the product.
+ $maxInt32 = 2_147_483_647;
+ $maximumHeight = $maxInt32 / $width;
+ if ($maximumHeight < $height) {
+ throw new DimensionsExceedInt32($width, $height);
+ }
+
+ return new Vp8x(
+ $width,
+ $height,
+ $startOfData - 8,
+ $iccProfile,
+ $alpha,
+ $exif,
+ $xmp,
+ $animation
+ );
+ }
+
+ /**
+ * @internal
+ */
+ public static function fromParameters(
+ int $offset,
+ int $width,
+ int $height,
+ bool $iccProfile,
+ bool $alpha,
+ bool $exif,
+ bool $xmp,
+ bool $animation
+ ): self {
+ return new Vp8x($width, $height, $offset, $iccProfile, $alpha, $exif, $xmp, $animation);
+ }
+
+ private static function decodeDimension(Buffer $buffer): int
+ {
+ $a = $buffer->getUnsignedByte();
+ $b = $buffer->getUnsignedByte();
+ $c = $buffer->getUnsignedByte();
+
+ return ($a | ($b << 8) | ($c << 16)) + 1;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Xmp.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Xmp.php
new file mode 100644
index 00000000000..7864c242909
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Chunk/Xmp.php
@@ -0,0 +1,23 @@
+
+ */
+final class Xmp extends Chunk
+{
+ private function __construct(int $offset, string $data)
+ {
+ parent::__construct("XMP ", $offset, $data);
+ }
+
+ public static function forBytes(int $offset, string $bytes): self
+ {
+ return new Xmp($offset, $bytes);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/ChunkType.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/ChunkType.php
new file mode 100644
index 00000000000..9d9e83e6ecb
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/ChunkType.php
@@ -0,0 +1,42 @@
+
+ */
+enum ChunkType
+{
+ case ALPH;
+ case ANIM;
+ case ANMF;
+ case EXIF;
+ case ICCP;
+ case VP8;
+ case VP8L;
+ case VP8X;
+ case XMP;
+ case UnknownChunk;
+
+ public static function fromFourCC(string $fourCC): self
+ {
+ return match ($fourCC) {
+ "ALPH" => self::ALPH,
+ "ANIM" => self::ANIM,
+ "ANMF" => self::ANMF,
+ "EXIF" => self::EXIF,
+ "ICCP" => self::ICCP,
+ "VP8 " => self::VP8,
+ "VP8L" => self::VP8L,
+ "VP8X" => self::VP8X,
+ "XMP " => self::XMP,
+ default => self::UnknownChunk,
+ };
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Decoder.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Decoder.php
new file mode 100644
index 00000000000..86209cb2662
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Decoder.php
@@ -0,0 +1,121 @@
+
+ */
+final class Decoder
+{
+ public function fromBinary(string $binary): WebP
+ {
+ $buffer = new StringBuffer($binary);
+ $buffer->setOrder(Buffer::LITTLE_ENDIAN);
+ $buffer->setReadOnly(true);
+
+ // A RIFF container at its minimum contains the "RIFF" header, a
+ // uint32LE representing the chunk size, the "WEBP" type and the data
+ // section. The data section of a WebP at minimum contains one chunk
+ // (header + uint32LE + data).
+ //
+ // The shortest possible WebP image is a simple VP8L container that
+ // contains only the magic byte, a uint32 for the flags and dimensions,
+ // and at last a single byte of data. This takes up 26 bytes in total.
+ $expectedMinimumFileSize = 26;
+ if ($buffer->size() < $expectedMinimumFileSize) {
+ throw new NotEnoughData($expectedMinimumFileSize, $buffer->size());
+ }
+
+ $riff = $buffer->getString(4);
+ $length = $buffer->getUnsignedInt();
+ $format = $buffer->getString(4);
+ if ($riff !== 'RIFF' || $format !== 'WEBP') {
+ throw new UnrecognizedFileFormat();
+ }
+
+ // The length in the header does not include "RIFF" and the length
+ // itself. It must therefore be exactly 8 bytes shorter than the total
+ // size.
+ $actualLength = $buffer->size() - 8;
+ if ($length !== $actualLength) {
+ throw new FileSizeMismatch($length, $actualLength);
+ }
+
+ /** @var list */
+ $chunks = [];
+ do {
+ $chunks[] = $this->decodeChunk($buffer);
+ } while ($buffer->hasRemaining());
+
+ return WebP::fromChunks($chunks);
+ }
+
+ /**
+ * @internal
+ */
+ public function decodeChunk(Buffer $buffer): Chunk
+ {
+ $remainingBytes = $buffer->remaining();
+ if ($remainingBytes < 8) {
+ throw new UnexpectedEndOfFile($buffer->position(), $buffer->remaining());
+ }
+
+ $chunkPosition = $buffer->position();
+ $fourCC = $buffer->getString(4);
+ $originalOffset = $buffer->position();
+ $length = $buffer->getUnsignedInt();
+ if ($buffer->remaining() < $length) {
+ throw new LengthOutOfBounds($length, $originalOffset, $buffer->remaining());
+ }
+
+ $chunk = match (ChunkType::fromFourCC($fourCC)) {
+ ChunkType::ALPH => Alph::forBytes($chunkPosition, $buffer->getString($length)),
+ ChunkType::ANIM => Anim::forBytes($chunkPosition, $buffer->getString($length)),
+ ChunkType::ANMF => Anmf::fromBuffer($buffer->setPosition($originalOffset)),
+ ChunkType::EXIF => Exif::forBytes($chunkPosition, $buffer->getString($length)),
+ ChunkType::ICCP => Iccp::forBytes($chunkPosition, $buffer->getString($length)),
+ ChunkType::XMP => Xmp::forBytes($chunkPosition, $buffer->getString($length)),
+ default => UnknownChunk::forBytes($fourCC, $chunkPosition, $buffer->getString($length)),
+
+ // VP8, VP8L and VP8X are a bit different because these need to be
+ // able to evaluate the length of the chunk themselves for various
+ // reasons.
+ ChunkType::VP8 => Vp8::fromBuffer($buffer->setPosition($originalOffset)),
+ ChunkType::VP8L => Vp8l::fromBuffer($buffer->setPosition($originalOffset)),
+ ChunkType::VP8X => Vp8x::fromBuffer($buffer->setPosition($originalOffset)),
+ };
+
+ // The length of every chunk in a RIFF container must be of an even
+ // length. Uneven chunks must be padded by a single 0x00 at the end.
+ if ($length % 2 === 1) {
+ $buffer->setPosition($buffer->position() + 1);
+ }
+
+ return $chunk;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Encoder.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Encoder.php
new file mode 100644
index 00000000000..f2d92d838ba
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Encoder.php
@@ -0,0 +1,179 @@
+
+ */
+final class Encoder
+{
+ /**
+ * Encodes a `WebP` object into the simple or extended format depending on
+ * the contained chunks.
+ *
+ * @return string raw bytes
+ */
+ public function fromWebP(WebP $webp): string
+ {
+ if ($webp->containsOnlyBitstream()) {
+ return $this->toSimpleFormat($webp);
+ }
+
+ return $this->toExtendedFileFormat($webp);
+ }
+
+ private function toSimpleFormat(WebP $webp): string
+ {
+ $chunks = $webp->getChunks();
+ \assert(\count($chunks) === 1);
+ $bitstream = $chunks[0];
+
+ $buffer = new StringBuffer();
+ $buffer->setOrder(Buffer::LITTLE_ENDIAN);
+
+ $buffer->insertString("RIFF");
+
+ // The header length does include neither "RIFF" nor the length itself.
+ $riffHeaderLength = 4;
+ $chunkHeader = 8;
+ $buffer->insertInt($riffHeaderLength + $chunkHeader + $bitstream->getLength());
+
+ $buffer->insertString("WEBP");
+
+ $buffer->insertString($bitstream->getFourCC());
+ $buffer->insertInt($bitstream->getLength());
+ $buffer->insertString($bitstream->getRawBytes());
+
+ if (\strlen($bitstream->getRawBytes()) % 2 === 1) {
+ // The padding byte is not part of the RIFF length.
+ $buffer->insertByte(0);
+ }
+
+ return $buffer->toString();
+ }
+
+ private function toExtendedFileFormat(WebP $webp): string
+ {
+ $buffer = new StringBuffer();
+ $buffer->setOrder(Buffer::LITTLE_ENDIAN);
+
+ $buffer->insertString("RIFF");
+ // Notice: The length will be inserted here at the end.
+ $buffer->insertString("WEBP");
+
+ $buffer->insertString("VP8X");
+ // The VP8X header has a fixed length of 10 bytes.
+ $buffer->insertInt(10);
+ $this->encodeExtendedFormatFeatures($webp, $buffer);
+ $buffer->insertString("\x00\x00\x00");
+ $this->encodeDimensions($webp, $buffer);
+
+ $iccp = $webp->getIccProfile();
+ if ($iccp !== null) {
+ $this->writeChunk($iccp, $buffer);
+ }
+
+ $anim = $webp->getAnimation();
+ if ($anim !== null) {
+ $this->writeChunk($anim, $buffer);
+
+ $frames = $webp->getAnimationFrames();
+ \assert(\count($frames) !== 0);
+
+ foreach ($frames as $frame) {
+ $this->writeChunk($frame, $buffer);
+
+ foreach ($frame->getDataChunks() as $dataChunk) {
+ $this->writeChunk($dataChunk, $buffer);
+ }
+ }
+ } else {
+ $alph = $webp->getAlpha();
+ if ($alph !== null) {
+ $this->writeChunk($alph, $buffer);
+ }
+
+ $bitstream = $webp->getBitstream();
+ assert($bitstream !== null);
+ $this->writeChunk($bitstream, $buffer);
+ }
+
+ $exif = $webp->getExif();
+ if ($exif !== null) {
+ $this->writeChunk($exif, $buffer);
+ }
+
+ $xmp = $webp->getXmp();
+ if ($xmp !== null) {
+ $this->writeChunk($xmp, $buffer);
+ }
+
+ foreach ($webp->getUnknownChunks() as $chunk) {
+ $this->writeChunk($chunk, $buffer);
+ }
+
+ $buffer->setPosition(4);
+ // The header length does include neither "RIFF" nor the length itself.
+ // Only substract 4 because the length increases the size by 4 bytes.
+ $buffer->insertInt($buffer->size() - 4);
+
+ return $buffer->toString();
+ }
+
+ private function writeChunk(Chunk $chunk, Buffer $buffer): void
+ {
+ $buffer->insertString($chunk->getFourCC());
+ $buffer->insertInt($chunk->getLength());
+ $buffer->insertString($chunk->getRawBytes());
+
+ if (\strlen($chunk->getRawBytes()) % 2 === 1) {
+ $buffer->insertByte(0);
+ }
+ }
+
+ private function encodeExtendedFormatFeatures(WebP $webp, Buffer $buffer): void
+ {
+ $bitfield = 0;
+ if ($webp->getIccProfile() !== null) {
+ $bitfield |= 0b00100000;
+ }
+ if ($webp->getAlpha() !== null) {
+ $bitfield |= 0b00010000;
+ }
+ if ($webp->getExif() !== null) {
+ $bitfield |= 0b00001000;
+ }
+ if ($webp->getXmp() !== null) {
+ $bitfield |= 0b00000100;
+ }
+ if ($webp->getAnimation() !== null) {
+ $bitfield |= 0b00000010;
+ }
+
+ $buffer->insertByte($bitfield);
+ }
+
+ private function encodeDimensions(WebP $webp, Buffer $buffer): void
+ {
+ // Encode the width and height as a 3 byte value each.
+ $width = ($webp->width - 1) & 0x00FFFFFF;
+ $buffer->insertByte(($width >> 0) & 0xFF);
+ $buffer->insertByte(($width >> 8) & 0xFF);
+ $buffer->insertByte(($width >> 16) & 0xFF);
+
+ $height = ($webp->height - 1) & 0x00FFFFFF;
+ $buffer->insertByte(($height >> 0) & 0xFF);
+ $buffer->insertByte(($height >> 8) & 0xFF);
+ $buffer->insertByte(($height >> 16) & 0xFF);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/ExtraChunksInSimpleFormat.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/ExtraChunksInSimpleFormat.php
new file mode 100644
index 00000000000..f6a3bfadb86
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/ExtraChunksInSimpleFormat.php
@@ -0,0 +1,24 @@
+
+ *
+ * @internal
+ */
+final class ExtraChunksInSimpleFormat extends RuntimeException implements WebpExifException
+{
+ /** @param string[] $chunkNames */
+ public function __construct(string $fourCC, array $chunkNames)
+ {
+ $names = \implode(', ', $chunkNames);
+ parent::__construct("The file was recognized as simple {$fourCC} but contains extra chunks: {$names}");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/ExtraVp8xChunk.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/ExtraVp8xChunk.php
new file mode 100644
index 00000000000..502208acad6
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/ExtraVp8xChunk.php
@@ -0,0 +1,22 @@
+
+ *
+ * @internal
+ */
+final class ExtraVp8xChunk extends OutOfBoundsException implements WebpExifException
+{
+ public function __construct()
+ {
+ parent::__construct("An extended WebP image format may only contain a single VP8X chunk");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/FileSizeMismatch.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/FileSizeMismatch.php
new file mode 100644
index 00000000000..32041c95f55
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/FileSizeMismatch.php
@@ -0,0 +1,22 @@
+
+ *
+ * @internal
+ */
+final class FileSizeMismatch extends RuntimeException implements WebpExifException
+{
+ public function __construct(int $expected, int $found)
+ {
+ parent::__construct("The file reports a payload of {$expected} bytes, but actually contains {$found} bytes");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/LengthOutOfBounds.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/LengthOutOfBounds.php
new file mode 100644
index 00000000000..a1b2e5dfbed
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/LengthOutOfBounds.php
@@ -0,0 +1,24 @@
+
+ *
+ * @internal
+ */
+final class LengthOutOfBounds extends OutOfBoundsException implements WebpExifException
+{
+ public function __construct(int $length, int $offset, int $remainingBytes)
+ {
+ $offset = \dechex($offset);
+
+ parent::__construct("Found the length {$length} at offset 0x{$offset} but there are only {$remainingBytes} bytes remaining");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/MissingChunks.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/MissingChunks.php
new file mode 100644
index 00000000000..052746a541f
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/MissingChunks.php
@@ -0,0 +1,22 @@
+
+ *
+ * @internal
+ */
+final class MissingChunks extends RuntimeException implements WebpExifException
+{
+ public function __construct()
+ {
+ parent::__construct("A WebP container must contain at least one data chunk");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/NotEnoughData.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/NotEnoughData.php
new file mode 100644
index 00000000000..9a5b49515c0
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/NotEnoughData.php
@@ -0,0 +1,22 @@
+
+ *
+ * @internal
+ */
+final class NotEnoughData extends RuntimeException implements WebpExifException
+{
+ public function __construct(int $expected, int $found)
+ {
+ parent::__construct("The file size is expected to be at least {$expected} bytes but is only {$found} bytes long");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/UnexpectedChunk.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/UnexpectedChunk.php
new file mode 100644
index 00000000000..414338c36e0
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/UnexpectedChunk.php
@@ -0,0 +1,23 @@
+
+ *
+ * @internal
+ */
+final class UnexpectedChunk extends RuntimeException implements WebpExifException
+{
+ public function __construct(string $fourCC, int $offset)
+ {
+ $offset = \dechex($offset);
+ parent::__construct("Found the unexpected chunk `{$fourCC}` at offset 0x{$offset}");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/UnexpectedEndOfFile.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/UnexpectedEndOfFile.php
new file mode 100644
index 00000000000..6d280a15d1c
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/UnexpectedEndOfFile.php
@@ -0,0 +1,24 @@
+
+ *
+ * @internal
+ */
+final class UnexpectedEndOfFile extends RuntimeException implements WebpExifException
+{
+ public function __construct(int $offset, int $remainingBytes)
+ {
+ $offset = \dechex($offset);
+
+ parent::__construct("Expected more data after offset 0x{$offset} ({$remainingBytes} bytes remaining)");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/UnrecognizedFileFormat.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/UnrecognizedFileFormat.php
new file mode 100644
index 00000000000..77c3dd9c08f
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/UnrecognizedFileFormat.php
@@ -0,0 +1,22 @@
+
+ *
+ * @internal
+ */
+final class UnrecognizedFileFormat extends RuntimeException implements WebpExifException
+{
+ public function __construct()
+ {
+ parent::__construct("The provided source appears not be a WebP image");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xAbsentChunk.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xAbsentChunk.php
new file mode 100644
index 00000000000..a07c13ea516
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xAbsentChunk.php
@@ -0,0 +1,22 @@
+
+ *
+ * @internal
+ */
+final class Vp8xAbsentChunk extends RuntimeException implements WebpExifException
+{
+ public function __construct(string $fourCC)
+ {
+ parent::__construct("The VP8X header indicates the presence of one or more `{$fourCC}` chunks but none are present");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xHeaderLengthMismatch.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xHeaderLengthMismatch.php
new file mode 100644
index 00000000000..e1f366772bc
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xHeaderLengthMismatch.php
@@ -0,0 +1,22 @@
+
+ *
+ * @internal
+ */
+final class Vp8xHeaderLengthMismatch extends OutOfBoundsException implements WebpExifException
+{
+ public function __construct(int $expected, int $found)
+ {
+ parent::__construct("The length of the VP8X header was expected to be {$expected} but found {$found}");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xMissingImageData.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xMissingImageData.php
new file mode 100644
index 00000000000..b763f3c7ebc
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xMissingImageData.php
@@ -0,0 +1,28 @@
+
+ *
+ * @internal
+ */
+final class Vp8xMissingImageData extends OutOfBoundsException implements WebpExifException
+{
+ public function __construct(bool $stillImage)
+ {
+ if ($stillImage) {
+ $message = "The file did not contain any VP8 or VP8L chunks";
+ } else {
+ $message = "The file did not contain multiple ANMF chunks";
+ }
+
+ parent::__construct($message);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xWithoutChunks.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xWithoutChunks.php
new file mode 100644
index 00000000000..addd1cb32bc
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/Vp8xWithoutChunks.php
@@ -0,0 +1,22 @@
+
+ *
+ * @internal
+ */
+final class Vp8xWithoutChunks extends RuntimeException implements WebpExifException
+{
+ public function __construct()
+ {
+ parent::__construct("The file uses the extended WebP format but does not provide any other chunks");
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/WebpExifException.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/WebpExifException.php
new file mode 100644
index 00000000000..2460ddafbc8
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/Exception/WebpExifException.php
@@ -0,0 +1,16 @@
+
+ */
+interface WebpExifException extends Throwable {}
diff --git a/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/WebP.php b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/WebP.php
new file mode 100644
index 00000000000..cbe3892463b
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/api/woltlab/webp-exif/src/WebP.php
@@ -0,0 +1,304 @@
+
+ */
+final class WebP
+{
+ private function __construct(
+ public readonly int $width,
+ public readonly int $height,
+ /** @var list */
+ private array $chunks,
+ ) {}
+
+ /**
+ * @return list
+ */
+ public function getChunks(): array
+ {
+ return $this->chunks;
+ }
+
+ /**
+ * Returns the length of all chunks including an additional 8 bytes per
+ * chunk for the chunk header.
+ */
+ public function getByteLength(): int
+ {
+ return \array_reduce(
+ $this->chunks,
+ static fn(int $length, Chunk $chunk) => $length + $chunk->getLength() + 8,
+ 0
+ );
+ }
+
+ /**
+ * WebP can be encoded using the simple format that only contains a single
+ * VP8 or VP8L chunk and is a bit smaller. Animations or any sort of extra
+ * information is only supported in the extended file format.
+ */
+ public function containsOnlyBitstream(): bool
+ {
+ if (\count($this->chunks) > 1) {
+ return false;
+ }
+
+ // The simple format can only contain a single VP8/VP8L chunk.
+ return \array_all($this->chunks, static function ($chunk) {
+ return match ($chunk::class) {
+ Vp8::class, Vp8l::class => true,
+ default => false,
+ };
+ });
+ }
+
+ public function getIccProfile(): ?Iccp
+ {
+ return \array_find($this->chunks, static fn($chunk) => $chunk instanceof Iccp);
+ }
+
+ public function getAlpha(): ?Alph
+ {
+ return \array_find($this->chunks, static fn($chunk) => $chunk instanceof Alph);
+ }
+
+ public function getExif(): ?Exif
+ {
+ return \array_find($this->chunks, static fn($chunk) => $chunk instanceof Exif);
+ }
+
+ public function getXmp(): ?Xmp
+ {
+ return \array_find($this->chunks, static fn($chunk) => $chunk instanceof Xmp);
+ }
+
+ public function getAnimation(): ?Anim
+ {
+ return \array_find($this->chunks, static fn($chunk) => $chunk instanceof Anim);
+ }
+
+ /**
+ * @return list
+ */
+ public function getAnimationFrames(): array
+ {
+ return \array_values(
+ \array_filter(
+ $this->chunks,
+ static fn($chunk) => $chunk instanceof Anmf
+ )
+ );
+ }
+
+ public function getBitstream(): Vp8|Vp8l|null
+ {
+ if ($this->getAnimation() !== null) {
+ return null;
+ }
+
+ return \array_find(
+ $this->chunks,
+ static fn($chunk) => $chunk instanceof Vp8 || $chunk instanceof Vp8l
+ );
+ }
+
+ /**
+ * @return list
+ */
+ public function getUnknownChunks(): array
+ {
+ return \array_values(
+ \array_filter(
+ $this->chunks,
+ static fn($chunk) => $chunk instanceof UnknownChunk
+ )
+ );
+ }
+
+ /**
+ * Adds or removes EXIF data.
+ */
+ public function withExif(?Exif $exif): self
+ {
+ $chunks = \array_values(
+ \array_filter(
+ $this->chunks,
+ static fn($chunk) => !($chunk instanceof Exif)
+ )
+ );
+
+ if ($exif !== null) {
+ $chunks[] = $exif;
+ }
+
+ $webp = clone $this;
+ $webp->chunks = $chunks;
+
+ return $webp;
+ }
+
+ /**
+ * Adds or removes an ICC profile.
+ */
+ public function withIccp(?Iccp $iccp): self
+ {
+ $chunks = \array_values(
+ \array_filter(
+ $this->chunks,
+ static fn($chunk) => !($chunk instanceof Iccp)
+ )
+ );
+
+ if ($iccp !== null) {
+ $chunks[] = $iccp;
+ }
+
+ $webp = clone $this;
+ $webp->chunks = $chunks;
+
+ return $webp;
+ }
+
+ /**
+ * Adds or removes XMP data.
+ */
+ public function withXmp(?Xmp $xmp): self
+ {
+ $chunks = \array_values(
+ \array_filter(
+ $this->chunks,
+ static fn($chunk) => !($chunk instanceof Xmp)
+ )
+ );
+
+ if ($xmp !== null) {
+ $chunks[] = $xmp;
+ }
+
+ $webp = clone $this;
+ $webp->chunks = $chunks;
+
+ return $webp;
+ }
+
+ /**
+ * Adds a list of unhandled chunks. It is not possibly to remove unknown
+ * chunks at this time.
+ *
+ * @param list $chunks
+ */
+ public function withUnknownChunks(array $chunks): self
+ {
+ $newChunks = $this->chunks;
+ foreach ($chunks as $chunk) {
+ if (!($chunk instanceof UnknownChunk)) {
+ throw new BadMethodCallException(
+ \sprintf(
+ "Expected a list of %s, received %s instead",
+ UnknownChunk::class,
+ \get_debug_type($chunk),
+ ),
+ );
+ }
+
+ $newChunks[] = $chunk;
+ }
+
+ $webp = clone $this;
+ $webp->chunks = $newChunks;
+
+ return $webp;
+ }
+
+ /**
+ * Creates a new WebP object from the provided chunks. Please use the
+ * `Decoder` class to decode the binary data of a WebP image.
+ *
+ * @param list $chunks
+ */
+ public static function fromChunks(array $chunks): self
+ {
+ foreach ($chunks as $chunk) {
+ if (!($chunk instanceof Chunk)) {
+ throw new BadMethodCallException(
+ \sprintf(
+ "Expected a list of %s, received %s instead",
+ Chunk::class,
+ \get_debug_type($chunk),
+ ),
+ );
+ }
+ }
+
+ if ($chunks === []) {
+ throw new MissingChunks();
+ }
+
+ // The first chunk must be one of VP8, VP8L or VP8X.
+ $firstChunk = \array_shift($chunks);
+ \assert($firstChunk !== null);
+
+ return match ($firstChunk::class) {
+ Vp8::class, Vp8l::class => self::fromSimpleFormat($firstChunk, $chunks),
+ Vp8x::class => self::fromExtendedFormat($firstChunk, $chunks),
+ default => throw new UnexpectedChunk($firstChunk->getFourCC(), 12)
+ };
+ }
+
+ /**
+ * @param list $chunks
+ */
+ private static function fromSimpleFormat(Vp8|Vp8l $imageData, array $chunks): self
+ {
+ if ($chunks !== []) {
+ throw new ExtraChunksInSimpleFormat(
+ $imageData->getFourCC(),
+ \array_map(static fn(Chunk $chunk) => $chunk->getFourCC(), $chunks)
+ );
+ }
+
+ return new WebP(
+ $imageData->width,
+ $imageData->height,
+ [
+ $imageData,
+ ...$chunks,
+ ]
+ );
+ }
+
+ /**
+ * @param list $chunks
+ */
+ private static function fromExtendedFormat(Vp8x $vp8x, array $chunks): self
+ {
+ $chunks = $vp8x->filterChunks($chunks);
+
+ return new WebP($vp8x->width, $vp8x->height, $chunks);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/GenerateThumbnails.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/GenerateThumbnails.class.php
index 8b222784975..30261154ead 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/GenerateThumbnails.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/GenerateThumbnails.class.php
@@ -28,7 +28,9 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
{
$file = Helper::fetchObjectFromRequestParameter($variables['id'], File::class);
- FileProcessor::getInstance()->generateWebpVariant($file);
+ $file = FileProcessor::getInstance()->generateWebpVariant($file);
+ $file = FileProcessor::getInstance()->stripExif($file);
+ $file = FileProcessor::getInstance()->convertImageFormat($file);
FileProcessor::getInstance()->generateThumbnails($file);
$thumbnails = [];
@@ -39,7 +41,12 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
];
}
- return new JsonResponse($thumbnails);
+ return new JsonResponse([
+ 'filename' => $file->filename,
+ 'fileSize' => $file->fileSize,
+ 'mimeType' => $file->mimeType,
+ 'thumbnails' => $thumbnails,
+ ]);
}
/**
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/PrepareUpload.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/PrepareUpload.class.php
index 03abd26fcd6..43637c5c123 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/PrepareUpload.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/PrepareUpload.class.php
@@ -16,6 +16,7 @@
use wcf\system\file\processor\FileProcessor;
use wcf\system\file\processor\FileProcessorPreflightResult;
use wcf\util\JSON;
+use WoltLab\WebpExif\Chunk\Exif;
/**
* Prepares the upload of a file.
@@ -75,6 +76,16 @@ private function createTemporaryFile(PostUploadParameters $parameters, int $numb
$identifier = \bin2hex(\random_bytes(20));
$objectType = FileProcessor::getInstance()->getObjectType($parameters->objectType);
+ $exifBytes = null;
+ if ($parameters->exifData !== null) {
+ $exifBytes = \hex2bin($parameters->exifData);
+ }
+
+ $exifData = $this->parseExifData($exifBytes);
+ if ($exifData !== null) {
+ $exifData = JSON::encode($exifData);
+ }
+
$action = new FileTemporaryAction([], 'create', [
'data' => [
'identifier' => $identifier,
@@ -85,11 +96,41 @@ private function createTemporaryFile(PostUploadParameters $parameters, int $numb
'objectTypeID' => $objectType?->objectTypeID,
'context' => $parameters->context,
'chunks' => \str_repeat('0', $numberOfChunks),
+ 'exifData' => $exifData,
],
]);
return $action->executeAction()['returnValues'];
}
+
+ /**
+ * @return array>|null
+ */
+ private function parseExifData(?string $exifData): ?array
+ {
+ if ($exifData === null) {
+ return null;
+ }
+
+ $data = Exif::forBytes(0, $exifData)->getParsedExif();
+ if ($data === null) {
+ return null;
+ }
+
+ // Remove the `FILE` and `COMPUTED` section because those contain
+ // garbled data anyway and we do not need them in the first place.
+ unset($data['FILE'], $data['COMPUTED']);
+
+ // We can also discard the `THUMBNAIL` section because it is a
+ // pointless feature and we’re not extracting it either.
+ unset($data['THUMBNAIL']);
+
+ if ($data === []) {
+ return null;
+ }
+
+ return $data;
+ }
}
/** @internal */
@@ -110,5 +151,7 @@ public function __construct(
/** @var non-empty-string */
public readonly string $context,
+
+ public readonly ?string $exifData = null,
) {}
}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/SaveChunk.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/SaveChunk.class.php
index b31729f8cfc..4f90be14267 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/SaveChunk.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/SaveChunk.class.php
@@ -138,6 +138,10 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
$thumbnailFormats = $processor->getThumbnailFormats();
if ($thumbnailFormats !== []) {
$generateThumbnails = true;
+ } else if (\IMAGE_CONVERT_FORMAT !== 'keep' || \IMAGE_STRIP_EXIF) {
+ // The action to generate thumbnails implicitly handles the
+ // format conversion and EXIF removal.
+ $generateThumbnails = true;
}
}
diff --git a/wcfsetup/install/files/lib/system/file/command/ReplaceFileSource.class.php b/wcfsetup/install/files/lib/system/file/command/ReplaceFileSource.class.php
index fca1b7ec65c..81088963128 100644
--- a/wcfsetup/install/files/lib/system/file/command/ReplaceFileSource.class.php
+++ b/wcfsetup/install/files/lib/system/file/command/ReplaceFileSource.class.php
@@ -35,6 +35,7 @@ public function __invoke(): File
$this->validatePathname($this->file->getPathnameWebp());
$file = $this->replaceSource();
+ $file = $this->discardWebpVariantOfWebpFile($file);
$this->regenerateExistingThumbnails($file);
return $file;
@@ -120,6 +121,29 @@ private function replaceSource(): File
\unlink($this->file->getPathname());
}
+ // Move the WebP thumbnail to the new location.
+ $webpVariant = $this->file->getPathnameWebp();
+ if ($webpVariant !== null) {
+ if ($updatedFile->mimeType === 'image/webp') {
+ // The file might be missing if it was used to replace the
+ // original version with it.
+ @\unlink($webpVariant);
+
+ (new FileEditor($updatedFile))->update([
+ 'fileHashWebp' => null,
+ ]);
+ $updatedFile = new File($updatedFile->fileID);
+ } else {
+ $newWebpVariant = $updatedFile->getPathnameWebp();
+ \assert($newWebpVariant !== null);
+
+ \rename(
+ $webpVariant,
+ $newWebpVariant,
+ );
+ }
+ }
+
WCF::getDB()->commitTransaction();
$committed = true;
@@ -131,6 +155,28 @@ private function replaceSource(): File
}
}
+ private function discardWebpVariantOfWebpFile(File $file): File
+ {
+ if ($file->mimeType !== 'image/webp') {
+ return $file;
+ }
+
+ $pathname = $file->getPathnameWebp();
+ if ($pathname === null) {
+ return $file;
+ }
+
+ if (\file_exists($pathname)) {
+ \unlink($file->getPathnameWebp());
+ }
+
+ (new FileEditor($file))->update([
+ 'fileHashWebp' => null,
+ ]);
+
+ return new File($file->fileID);
+ }
+
private function regenerateExistingThumbnails(File $file): void
{
$sql = "SELECT COUNT(*)
@@ -144,7 +190,9 @@ private function regenerateExistingThumbnails(File $file): void
}
try {
- FileProcessor::getInstance()->generateWebpVariant($file);
+ $file = FileProcessor::getInstance()->generateWebpVariant($file);
+ $file = FileProcessor::getInstance()->stripExif($file);
+ $file = FileProcessor::getInstance()->convertImageFormat($file);
FileProcessor::getInstance()->generateThumbnails($file);
} catch (DamagedImage $e) {
logThrowable($e);
diff --git a/wcfsetup/install/files/lib/system/file/command/ReplaceWithWebpVariant.class.php b/wcfsetup/install/files/lib/system/file/command/ReplaceWithWebpVariant.class.php
new file mode 100644
index 00000000000..92226a4231b
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/file/command/ReplaceWithWebpVariant.class.php
@@ -0,0 +1,57 @@
+
+ * @since 6.2
+ */
+final class ReplaceWithWebpVariant
+{
+ public function __construct(
+ private readonly File $file,
+ ) {}
+
+ public function __invoke(): File
+ {
+ $pathnameWebp = $this->file->getPathnameWebp();
+ if ($pathnameWebp === null) {
+ return $this->file;
+ }
+
+ // We cannot reliably detect if a GIF is animated without ImageMagick
+ // and the generated WebP variant will always be static.
+ if ($this->file->mimeType === 'image/gif') {
+ return $this->file;
+ }
+
+ $command = new ReplaceFileSource(
+ $this->file,
+ $pathnameWebp,
+ $this->getNewFilename(),
+ );
+ $newFile = $command();
+
+ return $newFile;
+ }
+
+ private function getNewFilename(): string
+ {
+ $filename = \preg_replace(
+ '~\.(?:jpe?g|png)$~i',
+ '',
+ $this->file->filename,
+ );
+
+ return \sprintf(
+ "%s.webp",
+ $filename,
+ );
+ }
+}
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 4eaf45a4931..1d2ecf62eff 100644
--- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php
+++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php
@@ -170,7 +170,7 @@ public function getResizeConfiguration(): ResizeConfiguration
\ATTACHMENT_IMAGE_AUTOSCALE_MAX_WIDTH,
\ATTACHMENT_IMAGE_AUTOSCALE_MAX_HEIGHT,
ResizeFileType::fromString(\ATTACHMENT_IMAGE_AUTOSCALE_FILE_TYPE),
- \ATTACHMENT_IMAGE_AUTOSCALE_QUALITY
+ 80
);
}
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 eb3672587bf..d8a847ace0c 100644
--- a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php
+++ b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php
@@ -13,12 +13,15 @@
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\event\EventHandler;
use wcf\system\exception\SystemException;
+use wcf\system\file\command\ReplaceFileSource;
+use wcf\system\file\command\ReplaceWithWebpVariant;
use wcf\system\file\processor\exception\DamagedImage;
use wcf\system\image\adapter\exception\ImageNotProcessable;
use wcf\system\image\adapter\exception\ImageNotReadable;
use wcf\system\image\ImageHandler;
use wcf\system\SingletonFactory;
use wcf\system\WCF;
+use wcf\util\ExifUtil;
use wcf\util\FileUtil;
use wcf\util\JSON;
use wcf\util\StringUtil;
@@ -149,7 +152,7 @@ public function canAdopt(IFileProcessor $fileProcessor, File $file, array $conte
return $fileProcessor->canAdopt($file, $context);
}
- public function generateWebpVariant(File $file): void
+ public function generateWebpVariant(File $file): File
{
$canGenerateThumbnail = match ($file->mimeType) {
'image/jpeg', 'image/png' => true,
@@ -163,13 +166,13 @@ public function generateWebpVariant(File $file): void
]);
}
- return;
+ return $file;
}
if ($file->fileHashWebp !== null) {
$pathname = $file->getPathnameWebp();
if (\file_exists($pathname) && \hash_file('sha256', $pathname) === $file->fileHashWebp) {
- return;
+ return $file;
}
}
@@ -183,7 +186,7 @@ public function generateWebpVariant(File $file): void
if ($filename === null) {
$imageAdapter = ImageHandler::getInstance()->getAdapter();
if (!$imageAdapter->checkMemoryLimit($file->width, $file->height, $file->mimeType)) {
- return;
+ return $file;
}
try {
@@ -193,7 +196,7 @@ public function generateWebpVariant(File $file): void
} catch (ImageNotProcessable $e) {
logThrowable($e);
- return;
+ return $file;
}
$filename = FileUtil::getTemporaryFilename(extension: 'webp');
@@ -206,7 +209,7 @@ public function generateWebpVariant(File $file): void
throw $e;
}
- return;
+ return $file;
}
}
@@ -220,6 +223,8 @@ public function generateWebpVariant(File $file): void
\assert($pathname !== null);
\rename($filename, $pathname);
+
+ return $file;
}
public function generateThumbnails(File $file): void
@@ -424,6 +429,40 @@ public function copy(File $oldFile, string $objectType): File
return $newFile;
}
+ #[\NoDiscard("as the file itself could change")]
+ public function convertImageFormat(File $file): File
+ {
+ switch (\IMAGE_CONVERT_FORMAT) {
+ case 'keep':
+ return $file;
+
+ case 'webp':
+ $command = new ReplaceWithWebpVariant($file);
+ return $command();
+
+ default:
+ throw new \LogicException("Unreachable");
+ }
+ }
+
+ #[\NoDiscard("as the file itself could change")]
+ public function stripExif(File $file): File
+ {
+ if (!\IMAGE_STRIP_EXIF) {
+ return $file;
+ }
+
+ $fileWithoutExif = ExifUtil::getFileWithoutExifData($file->getPathname());
+ if ($fileWithoutExif === null) {
+ return $file;
+ }
+
+ $command = new ReplaceFileSource($file, $fileWithoutExif, $file->filename);
+ $newFile = $command();
+
+ return $newFile;
+ }
+
private function copyThumbnails(int $oldFileID, int $newFileID): void
{
$thumbnailList = new FileThumbnailList();
diff --git a/wcfsetup/install/files/lib/system/worker/FileRebuildDataWorker.class.php b/wcfsetup/install/files/lib/system/worker/FileRebuildDataWorker.class.php
index c3f59e8865b..e513c548d50 100644
--- a/wcfsetup/install/files/lib/system/worker/FileRebuildDataWorker.class.php
+++ b/wcfsetup/install/files/lib/system/worker/FileRebuildDataWorker.class.php
@@ -5,6 +5,7 @@
use wcf\data\file\File;
use wcf\data\file\FileEditor;
use wcf\data\file\FileList;
+use wcf\system\file\command\ReplaceWithWebpVariant;
use wcf\system\file\processor\exception\DamagedImage;
use wcf\system\file\processor\FileProcessor;
use wcf\util\FileUtil;
@@ -49,9 +50,11 @@ public function execute()
$this->fixMimeType();
$damagedFileIDs = [];
- foreach ($this->objectList as $file) {
+ foreach ($this->objectList->getObjects() as $file) {
try {
- FileProcessor::getInstance()->generateWebpVariant($file);
+ $file = FileProcessor::getInstance()->generateWebpVariant($file);
+ $file = FileProcessor::getInstance()->stripExif($file);
+ $file = FileProcessor::getInstance()->convertImageFormat($file);
FileProcessor::getInstance()->generateThumbnails($file);
} catch (DamagedImage $e) {
logThrowable($e);
diff --git a/wcfsetup/install/files/lib/util/ExifUtil.class.php b/wcfsetup/install/files/lib/util/ExifUtil.class.php
index a454323e32e..d8c0923d6e1 100644
--- a/wcfsetup/install/files/lib/util/ExifUtil.class.php
+++ b/wcfsetup/install/files/lib/util/ExifUtil.class.php
@@ -2,6 +2,10 @@
namespace wcf\util;
+use WoltLab\WebpExif\Decoder;
+use WoltLab\WebpExif\Encoder;
+use WoltLab\WebpExif\Exception\WebpExifException;
+
/**
* Provides exif-related functions.
*
@@ -83,11 +87,27 @@ private function __construct()
*/
public static function getExifData(string $filename): array
{
- if (\function_exists('exif_read_data')) {
- $exifData = @\exif_read_data($filename, '', true);
- if ($exifData !== false) {
- return $exifData;
+ $mimeType = FileUtil::getMimeType($filename);
+ if ($mimeType === 'image/webp') {
+ $decoder = new Decoder();
+
+ try {
+ $webp = $decoder->fromBinary(\file_get_contents($filename));
+ } catch (WebpExifException) {
+ return [];
+ }
+
+ $exifData = $webp->getExif()?->getParsedExif();
+ if ($exifData === null) {
+ return [];
}
+
+ return $exifData;
+ }
+
+ $exifData = @\exif_read_data($filename, '', true);
+ if ($exifData !== false) {
+ return $exifData;
}
return [];
@@ -246,6 +266,72 @@ public static function getOrientation(array $exifData): int
return $orientation;
}
+ #[\NoDiscard("as the sanitized version is written to a temporary file")]
+ public static function getFileWithoutExifData(string $pathname): ?string
+ {
+ $fileExtension = match (FileUtil::getMimeType($pathname)) {
+ 'image/jpeg' => 'jpg',
+ 'image/gif' => 'gif',
+ 'image/png' => 'png',
+ 'image/webp' => 'webp',
+ default => 'bin',
+ };
+
+ if ($fileExtension === 'webp') {
+ return self::getWebpWithoutExif($pathname);
+ }
+
+ if (!\class_exists(\Imagick::class)) {
+ return null;
+ }
+
+ $exifData = self::getExifData($pathname);
+ $hasIdf0 = ($exifData['IFD0'] ?? []) !== [];
+ $hasGPS = ($exifData['GPS'] ?? []) !== [];
+ $hasEXIF = ($exifData['EXIF'] ?? []) !== [];
+ if (!$hasIdf0 && !$hasGPS && !$hasEXIF) {
+ return null;
+ }
+
+ $img = new \Imagick($pathname);
+ $profiles = $img->getImageProfiles('icc', true);
+ $img->stripImage();
+ if ($profiles !== []) {
+ $img->profileImage('icc', $profiles['icc']);
+ }
+
+ $tmpFile = FileUtil::getTemporaryFilename('fileWithoutExif_', ".{$fileExtension}");
+ $img->writeImage($tmpFile);
+
+ return $tmpFile;
+ }
+
+ private static function getWebpWithoutExif(string $pathname): ?string
+ {
+ $decoder = new Decoder();
+
+ try {
+ $webp = $decoder->fromBinary(\file_get_contents($pathname));
+ } catch (WebpExifException) {
+ return null;
+ }
+
+ if ($webp->getExif() !== null) {
+ $webp = $webp->withExif(null);
+ }
+ if ($webp->getXmp() !== null) {
+ $webp = $webp->withXmp(null);
+ }
+
+ $encoder = new Encoder();
+ $bytes = $encoder->fromWebP($webp);
+
+ $tmpFile = FileUtil::getTemporaryFilename('fileWithoutExif_', '.webp');
+ \file_put_contents($tmpFile, $bytes);
+
+ return $tmpFile;
+ }
+
/**
* Converts the format of exif geo tagging coordinates.
*/
diff --git a/wcfsetup/install/files/style/ui/dialog.scss b/wcfsetup/install/files/style/ui/dialog.scss
index 1984061508e..44c181c0858 100644
--- a/wcfsetup/install/files/style/ui/dialog.scss
+++ b/wcfsetup/install/files/style/ui/dialog.scss
@@ -294,7 +294,7 @@
min-width: 500px;
overflow: hidden;
padding: 0;
- position: relative;
+ position: fixed;
}
.dialog:not([open]) {
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index a02eaf984c0..f517a614a78 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -1359,6 +1359,12 @@ Sie erreichen das Fehlerprotokoll unter: {link controller='ExceptionLogView' isE
+
+
+
+
+
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 2db9b6ba792..efd5317dd01 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -1335,6 +1335,12 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru
+
+
+
+
+
+
diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql
index 3872d826f2e..99388982a3d 100644
--- a/wcfsetup/setup/db/install.sql
+++ b/wcfsetup/setup/db/install.sql
@@ -607,7 +607,8 @@ CREATE TABLE wcf1_file (
width INT,
height INT,
fileHashWebp CHAR(64),
- uploadTime INT
+ uploadTime INT,
+ exifData MEDIUMTEXT
);
DROP TABLE IF EXISTS wcf1_file_temporary;
@@ -619,7 +620,8 @@ CREATE TABLE wcf1_file_temporary (
fileHash CHAR(64) NOT NULL,
objectTypeID INT,
context TEXT,
- chunks VARBINARY(255) NOT NULL
+ chunks VARBINARY(255) NOT NULL,
+ exifData MEDIUMTEXT
);
DROP TABLE IF EXISTS wcf1_file_thumbnail;