Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type CoreCommandNames =
| 'insertContentAt'
| 'undoInputRule'
| 'setSectionPageMarginsAtSelection'
| 'setProtectionMode'
| 'toggleList'
| 'increaseListIndent'
| 'decreaseListIndent'
Expand Down
1 change: 1 addition & 0 deletions packages/super-editor/src/core/commands/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export * from './setBodyHeaderFooter.js';
export * from './setSectionHeaderFooterAtSelection.js';
export * from './setSectionPageMarginsAtSelection.js';
export * from './insertSectionBreakAtSelection.js';
export * from './setProtectionMode.js';

// Paragraph
export * from './textIndent.js';
Expand Down
87 changes: 87 additions & 0 deletions packages/super-editor/src/core/commands/setProtectionMode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// @ts-check
import { carbonCopy } from '@core/utilities/carbonCopy.js';

const SETTINGS_PATH = 'word/settings.xml';
const DOC_PROTECTION_NODE = 'w:documentProtection';

const PROTECTION_VALUE_MAP = Object.freeze({
noProtection: null,
allowOnlyRevisions: 'trackedChanges',
allowOnlyComments: 'comments',
allowOnlyFormFields: 'forms',
allowOnlyReading: 'readOnly',
});

const DEFAULT_MODE = 'noProtection';

/**
* Normalize the caller provided document protection mode.
* @param {string} mode
* @returns {keyof typeof PROTECTION_VALUE_MAP}
*/
function normalizeMode(mode = '') {
const normalized = typeof mode === 'string' ? mode.trim() : '';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing null/undefined here becomes empty string, which then falls back to noProtection via the default.

Means invalid input silently removes protection instead of erroring.

Maybe we should return false for non-strings instead?

console.log('normalized', normalized);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like that's debug - remove?

return /** @type {keyof typeof PROTECTION_VALUE_MAP} */ (normalized || DEFAULT_MODE);
}

/**
* Build the document protection XML node.
* @param {string} editValue
* @returns {Object}
*/
function createDocProtectionNode(editValue) {
return {
type: 'element',
name: DOC_PROTECTION_NODE,
attributes: {
'w:edit': editValue,
'w:enforcement': '1',
},
};
}

/**
* Update the DOCX settings to enforce or remove document protection.
* @param {'noProtection' | 'allowOnlyRevisions' | 'allowOnlyComments' | 'allowOnlyFormFields' | 'allowOnlyReading'} mode
* @returns {import('./types').Command}
*/
export const setProtectionMode = (mode = 'noProtection') => {
return ({ editor }) => {
const convertedXml = editor?.converter?.convertedXml;
if (!convertedXml) return false;

const normalizedMode = normalizeMode(mode);
console.log('normalizedMode', normalizedMode);
console.log('PROTECTION_VALUE_MAP', PROTECTION_VALUE_MAP, normalizedMode in PROTECTION_VALUE_MAP);
if (!(normalizedMode in PROTECTION_VALUE_MAP)) return false;
Comment on lines +55 to +57

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject prototype keys when validating protection modes

The validation uses normalizedMode in PROTECTION_VALUE_MAP, which also accepts prototype properties. If a caller passes a string like "toString" or "__proto__", the check passes even though it is not a supported mode, and mappedValue becomes the inherited function/object. That results in a w:edit attribute set to a non-string value (likely serialized as [object Function]), corrupting settings.xml instead of returning false. Consider using Object.hasOwn/hasOwnProperty to restrict to defined keys.

Useful? React with 👍 / 👎.

const settingsXml = convertedXml[SETTINGS_PATH];
const settingsRoot = settingsXml?.elements?.[0];
if (!settingsRoot) return false;

const updatedSettings = carbonCopy(settingsXml);
const updatedRoot = updatedSettings.elements?.[0];
if (!updatedRoot) return false;

if (!Array.isArray(updatedRoot.elements)) {
updatedRoot.elements = [];
}

const elements = updatedRoot.elements;
const existingIndex = elements.findIndex((node) => node?.name === DOC_PROTECTION_NODE);
if (existingIndex !== -1) {
elements.splice(existingIndex, 1);
}

const mappedValue = PROTECTION_VALUE_MAP[normalizedMode];
if (mappedValue) {
const protectionNode = createDocProtectionNode(mappedValue);
const insertIndex = existingIndex >= 0 ? existingIndex : 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When no existing protection node exists, this inserts at index 0 which puts it before w:zoom.

OOXML spec says documentProtection should come at position 35, after zoom/trackRevisions/etc which might cause schema validation issues.

elements.splice(insertIndex, 0, protectionNode);
}

convertedXml[SETTINGS_PATH] = updatedSettings;
editor.updateInternalXmlFile(SETTINGS_PATH, updatedSettings);
return true;
};
};
109 changes: 109 additions & 0 deletions packages/super-editor/src/core/commands/setProtectionMode.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { setProtectionMode } from './setProtectionMode.js';

const SETTINGS_PATH = 'word/settings.xml';

const createBaseSettings = () => ({
elements: [
{
type: 'element',
name: 'w:settings',
attributes: {
'xmlns:w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
},
elements: [
{
type: 'element',
name: 'w:zoom',
attributes: { 'w:percent': '120' },
},
],
},
],
});

const clone = (obj) => JSON.parse(JSON.stringify(obj));

const buildEditor = (settings = createBaseSettings()) => {
const editor = {
converter: {
convertedXml: {
[SETTINGS_PATH]: clone(settings),
},
},
updateInternalXmlFile: vi.fn(),
};

return editor;
};

describe('setProtectionMode command', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('inserts docProtection node for the requested mode', () => {
const editor = buildEditor();
const command = setProtectionMode('allowOnlyReading');
const result = command({ editor });

expect(result).toBe(true);
const updatedSettings = editor.converter.convertedXml[SETTINGS_PATH];
const rootElements = updatedSettings.elements[0].elements;
expect(rootElements[0].name).toBe('w:documentProtection');
expect(rootElements[0].attributes).toMatchObject({ 'w:edit': 'readOnly', 'w:enforcement': '1' });
expect(editor.updateInternalXmlFile).toHaveBeenCalledWith(SETTINGS_PATH, updatedSettings);
});

it('supports AllowOnlyFormFields casing and replaces existing node', () => {
const base = createBaseSettings();
base.elements[0].elements.unshift({
type: 'element',
name: 'w:documentProtection',
attributes: { 'w:edit': 'comments', 'w:enforcement': '1' },
});

const editor = buildEditor(base);
const command = setProtectionMode('allowOnlyFormFields');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name says it tests casing but only uses one case

const success = command({ editor });

expect(success).toBe(true);
const updated = editor.converter.convertedXml[SETTINGS_PATH].elements[0].elements;
expect(updated[0].attributes['w:edit']).toBe('forms');
expect(updated).toHaveLength(base.elements[0].elements.length);
});

it('removes docProtection node when switching to noProtection', () => {
const base = createBaseSettings();
base.elements[0].elements.unshift({
type: 'element',
name: 'w:documentProtection',
attributes: { 'w:edit': 'trackedChanges', 'w:enforcement': '1' },
});

const editor = buildEditor(base);
const removed = setProtectionMode('noProtection')({ editor });

expect(removed).toBe(true);
const nodes = editor.converter.convertedXml[SETTINGS_PATH].elements[0].elements;
expect(nodes.find((node) => node.name === 'w:documentProtection')).toBeUndefined();
});

it('returns false when settings.xml is missing', () => {
const editor = buildEditor();
delete editor.converter.convertedXml[SETTINGS_PATH];
const result = setProtectionMode('allowOnlyComments')({ editor });
expect(result).toBe(false);
expect(editor.updateInternalXmlFile).not.toHaveBeenCalled();
});

it('rejects invalid modes without mutating the XML', () => {
const editor = buildEditor();
const before = clone(editor.converter.convertedXml[SETTINGS_PATH]);
const result = setProtectionMode('unsupported-mode')({ editor });

expect(result).toBe(false);
expect(editor.converter.convertedXml[SETTINGS_PATH]).toEqual(before);
expect(editor.updateInternalXmlFile).not.toHaveBeenCalled();
});
});
6 changes: 6 additions & 0 deletions packages/super-editor/src/extensions/document/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Node } from '@core/index.js';
import { setSectionPageMarginsAtSelection } from '@core/commands/setSectionPageMarginsAtSelection.js';
import { setProtectionMode } from '@core/commands/setProtectionMode.js';

/**
* Configuration options for Document
Expand Down Expand Up @@ -105,6 +106,11 @@ export const Document = Node.create({
* Set section page margins (top/right/bottom/left) for the section at the current selection.
*/
setSectionPageMarginsAtSelection,

/**
* Set document protection mode (updates word/settings.xml docProtection element).
*/
setProtectionMode,
};
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ export type DocumentStats = {
paragraphs: number;
};

/** Supported document protection modes for document settings
*
* Reference : https://learn.microsoft.com/pt-br/javascript/api/word/word.document?view=word-js-preview#word-word-document-protect-member(1)
*/
export type DocumentProtectionMode =
| 'noProtection'
| 'allowOnlyRevisions'
| 'allowOnlyComments'
| 'allowOnlyFormFields'
| 'allowOnlyReading';

export interface MiscellaneousCommands {
// ============================================
// FIELD ANNOTATION COMMANDS
Expand Down Expand Up @@ -281,6 +292,11 @@ export interface MiscellaneousCommands {
* console.log(`${stats.words} words`)
*/
getDocumentStats: () => DocumentStats;

/**
* Set the document protection mode (updates docProtection in settings.xml)
*/
setProtectionMode: (mode?: DocumentProtectionMode) => boolean;
}

declare module '../../core/types/ChainedCommands.js' {
Expand Down
Loading