Story 4.2 completed
This commit is contained in:
@@ -5,15 +5,34 @@
|
||||
* their on-screen presence. Settings are stored as user flags on the user document.
|
||||
*
|
||||
* Storage key: game.user.setFlag('video-view-manager', key, value)
|
||||
* Shape: { reactionCamEnabled: boolean, hpReactiveCamStylingEnabled: boolean }
|
||||
* Shape: { reactionCamEnabled: boolean, hpReactiveCamStylingEnabled: boolean, customPortraitFallback: string | null }
|
||||
*
|
||||
* @module contracts/privacy-settings
|
||||
*/
|
||||
|
||||
/**
|
||||
* Maximum portrait file size in bytes (5MB).
|
||||
* Note: FoundryVTT user flags typically have a ~50KB limit, so images should be optimized.
|
||||
* @type {number}
|
||||
*/
|
||||
export const MAX_PORTRAIT_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Supported portrait image MIME types.
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const VALID_PORTRAIT_FORMATS = Object.freeze([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @typedef {Object} PrivacySettings
|
||||
* @property {boolean} reactionCamEnabled - Whether Reaction Cam automation is enabled for this user.
|
||||
* @property {boolean} hpReactiveCamStylingEnabled - Whether HP-Reactive Cam Styling is enabled for this user.
|
||||
* @property {string|null} customPortraitFallback - DataURL string for custom portrait fallback image, or null if not set.
|
||||
*/
|
||||
|
||||
export const PRIVACY_SETTINGS_VERSION = 1;
|
||||
@@ -25,6 +44,7 @@ export const PRIVACY_SETTINGS_VERSION = 1;
|
||||
export const PRIVACY_SETTINGS_DEFAULT = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,6 +54,7 @@ export const PRIVACY_SETTINGS_DEFAULT = {
|
||||
export const PRIVACY_SETTING_KEYS = Object.freeze([
|
||||
"reactionCamEnabled",
|
||||
"hpReactiveCamStylingEnabled",
|
||||
"customPortraitFallback",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -45,6 +66,55 @@ export const FEATURE_NAME_MAP = Object.freeze({
|
||||
hpReactiveCamStyling: "hpReactiveCamStylingEnabled",
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates a DataURL for portrait images.
|
||||
* Accepts DataURLs with supported MIME types or null/undefined/empty string.
|
||||
* @param {unknown} dataURL - The DataURL string to validate.
|
||||
* @returns {string|null|undefined} The validated DataURL (or null/undefined if valid).
|
||||
* @throws {TypeError} If the DataURL format is invalid or uses unsupported MIME type.
|
||||
*/
|
||||
export function validatePortraitDataURL(dataURL) {
|
||||
// Accept null, undefined, or empty string as valid (no custom portrait)
|
||||
if (dataURL === null || dataURL === undefined) {
|
||||
return dataURL;
|
||||
}
|
||||
if (typeof dataURL !== "string") {
|
||||
throw new TypeError(`Invalid DataURL: expected string, got ${typeof dataURL}`);
|
||||
}
|
||||
if (dataURL === "") {
|
||||
return dataURL;
|
||||
}
|
||||
|
||||
// Validate DataURL format: must start with "data:"
|
||||
if (!dataURL.startsWith("data:")) {
|
||||
throw new TypeError("Invalid DataURL format: must start with 'data:'");
|
||||
}
|
||||
|
||||
// Extract MIME type from DataURL (format: data:<mediatype>;base64,... or data:<mediatype>,...)
|
||||
// Match image MIME type after data: (captures the part before ; or ,)
|
||||
const mimeMatch = dataURL.match(/^data:(image\/[a-zA-Z0-9+\-.]+)/);
|
||||
if (!mimeMatch) {
|
||||
throw new TypeError("Invalid DataURL format: missing or invalid MIME type");
|
||||
}
|
||||
|
||||
const mimeType = mimeMatch[1].toLowerCase();
|
||||
|
||||
// Validate against supported formats
|
||||
if (!VALID_PORTRAIT_FORMATS.includes(mimeType)) {
|
||||
throw new TypeError(
|
||||
`Unsupported portrait format: ${mimeType}. Supported: ${VALID_PORTRAIT_FORMATS.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// Verify actual data content exists after MIME header
|
||||
const dataPart = dataURL.substring(dataURL.indexOf(",") + 1);
|
||||
if (!dataPart || dataPart.length === 0) {
|
||||
throw new TypeError("Invalid DataURL: empty image data");
|
||||
}
|
||||
|
||||
return dataURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new PrivacySettings object with defaults.
|
||||
* Only known keys from PRIVACY_SETTINGS_DEFAULT are included; extra properties are ignored.
|
||||
@@ -63,6 +133,7 @@ export function createPrivacySettings(overrides = {}) {
|
||||
|
||||
/**
|
||||
* Validates a PrivacySettings DTO. Throws TypeError on any violation.
|
||||
* Backward compatible: accepts settings without customPortraitFallback key.
|
||||
* @param {unknown} data - Value to validate.
|
||||
* @returns {PrivacySettings} The validated settings.
|
||||
* @throws {TypeError} If data fails validation.
|
||||
@@ -75,23 +146,50 @@ export function isValidPrivacySettings(data) {
|
||||
throw new TypeError("PrivacySettings: must be an object");
|
||||
}
|
||||
const obj = /** @type {Record<string, unknown>} */ (data);
|
||||
const { reactionCamEnabled, hpReactiveCamStylingEnabled, ...rest } = obj;
|
||||
if (Object.keys(rest).length > 0) {
|
||||
|
||||
// Check for unknown keys (keys not in PRIVACY_SETTING_KEYS)
|
||||
const knownKeys = new Set(PRIVACY_SETTING_KEYS);
|
||||
const unknownKeys = Object.keys(obj).filter((k) => !knownKeys.has(k));
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new TypeError(
|
||||
`PrivacySettings: unknown keys: ${Object.keys(rest).join(", ")}`
|
||||
`PrivacySettings: unknown keys: ${unknownKeys.join(", ")}`
|
||||
);
|
||||
}
|
||||
if (typeof reactionCamEnabled !== "boolean") {
|
||||
throw new TypeError(
|
||||
`PrivacySettings: reactionCamEnabled must be a boolean, got ${typeof reactionCamEnabled}`
|
||||
);
|
||||
|
||||
// Validate each known key if present
|
||||
if ("reactionCamEnabled" in obj) {
|
||||
if (typeof obj.reactionCamEnabled !== "boolean") {
|
||||
throw new TypeError(
|
||||
`PrivacySettings: reactionCamEnabled must be a boolean, got ${typeof obj.reactionCamEnabled}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof hpReactiveCamStylingEnabled !== "boolean") {
|
||||
throw new TypeError(
|
||||
`PrivacySettings: hpReactiveCamStylingEnabled must be a boolean, got ${typeof hpReactiveCamStylingEnabled}`
|
||||
);
|
||||
if ("hpReactiveCamStylingEnabled" in obj) {
|
||||
if (typeof obj.hpReactiveCamStylingEnabled !== "boolean") {
|
||||
throw new TypeError(
|
||||
`PrivacySettings: hpReactiveCamStylingEnabled must be a boolean, got ${typeof obj.hpReactiveCamStylingEnabled}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return /** @type {PrivacySettings} */ (data);
|
||||
if ("customPortraitFallback" in obj) {
|
||||
if (obj.customPortraitFallback !== null && typeof obj.customPortraitFallback !== "string") {
|
||||
throw new TypeError(
|
||||
`PrivacySettings: customPortraitFallback must be a string or null, got ${typeof obj.customPortraitFallback}`
|
||||
);
|
||||
}
|
||||
// If it's a string, validate it's a valid DataURL
|
||||
if (typeof obj.customPortraitFallback === "string") {
|
||||
try {
|
||||
validatePortraitDataURL(obj.customPortraitFallback);
|
||||
} catch (e) {
|
||||
throw new TypeError(
|
||||
`PrivacySettings: customPortraitFallback ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return /** @type {PrivacySettings} */ (obj);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user