Skip to content

Commit c0ecabc

Browse files
committed
feature #713 [Live] Robust handling of invalid user data (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Live] Robust handling of invalid user data | Q | A | ------------- | --- | Bug fix? | yes | New feature? | yes | Tickets | Fix #528 | License | MIT Hi! Another boring PR from me 😄. t;dr When making an Ajax request, we now send the "original data" + "updated date". If the user sends something wacko (e.g. the word `apple` to a property with an `int` type), we can reject it and use the original data. Cheers! Commits ------- ebc5412 [Live] Robust handling of invalid user data
2 parents 765eacd + ebc5412 commit c0ecabc

Some content is hidden

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

57 files changed

+2155
-1837
lines changed

src/LiveComponent/assets/dist/Backend.d.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import BackendRequest from './BackendRequest';
2+
export interface BackendInterface {
3+
makeRequest(props: any, actions: BackendAction[], updated: {
4+
[key: string]: any;
5+
}, childrenFingerprints: any): BackendRequest;
6+
}
7+
export interface BackendAction {
8+
name: string;
9+
args: Record<string, string>;
10+
}
11+
export default class implements BackendInterface {
12+
private readonly requestBuilder;
13+
constructor(url: string, csrfToken?: string | null);
14+
makeRequest(props: any, actions: BackendAction[], updated: {
15+
[key: string]: any;
16+
}, childrenFingerprints: any): BackendRequest;
17+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { BackendAction } from './Backend';
2+
export default class {
3+
private url;
4+
private readonly csrfToken;
5+
constructor(url: string, csrfToken?: string | null);
6+
buildRequest(props: any, actions: BackendAction[], updated: {
7+
[key: string]: any;
8+
}, childrenFingerprints: any): {
9+
url: string;
10+
fetchOptions: RequestInit;
11+
};
12+
private willDataFitInUrl;
13+
}

src/LiveComponent/assets/dist/Component/ValueStore.d.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
export default class {
22
private readonly identifierKey;
3-
updatedModels: string[];
43
private props;
4+
private dirtyProps;
5+
private pendingProps;
56
constructor(props: any);
67
get(name: string): any;
78
has(name: string): boolean;
89
set(name: string, value: any): boolean;
9-
all(): any;
10+
getOriginalProps(): any;
11+
getDirtyProps(): any;
12+
flushDirtyPropsToPending(): void;
1013
reinitializeAllProps(props: any): void;
14+
pushPendingPropsBackToDirty(): void;
1115
reinitializeProvidedProps(props: any): boolean;
1216
private isPropNameTopLevel;
1317
private findIdentifier;

src/LiveComponent/assets/dist/Component/index.d.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { BackendInterface } from '../Backend';
1+
import { BackendInterface } from '../Backend/Backend';
22
import ValueStore from './ValueStore';
33
import { ElementDriver } from './ElementDriver';
44
import { PluginInterface } from './plugins/PluginInterface';
5-
import BackendResponse from '../BackendResponse';
5+
import BackendResponse from '../Backend/BackendResponse';
66
import { ModelBinding } from '../Directive/get_model_binding';
77
export default class Component {
88
readonly element: HTMLElement;
9-
private readonly backend;
9+
private backend;
1010
private readonly elementDriver;
1111
id: string | null;
1212
fingerprint: string | null;
@@ -23,6 +23,7 @@ export default class Component {
2323
private children;
2424
private parent;
2525
constructor(element: HTMLElement, props: any, fingerprint: string | null, id: string | null, backend: BackendInterface, elementDriver: ElementDriver);
26+
_swapBackend(backend: BackendInterface): void;
2627
addPlugin(plugin: PluginInterface): void;
2728
connect(): void;
2829
disconnect(): void;

src/LiveComponent/assets/dist/Component/plugins/LoadingPlugin.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Directive } from '../../Directive/directives_parser';
2-
import BackendRequest from '../../BackendRequest';
2+
import BackendRequest from '../../Backend/BackendRequest';
33
import Component from '../../Component';
44
import { PluginInterface } from './PluginInterface';
55
interface ElementLoadingDirectives {

src/LiveComponent/assets/dist/live_controller.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export interface LiveController {
1212
element: HTMLElement;
1313
component: Component;
1414
}
15-
export default class extends Controller<HTMLElement> implements LiveController {
15+
export default class LiveControllerDefault extends Controller<HTMLElement> implements LiveController {
1616
static values: {
1717
url: StringConstructor;
1818
props: ObjectConstructor;
@@ -39,8 +39,8 @@ export default class extends Controller<HTMLElement> implements LiveController {
3939
disconnect(): void;
4040
update(event: any): void;
4141
action(event: any): void;
42-
$render(): Promise<import("./BackendResponse").default>;
43-
$updateModel(model: string, value: any, shouldRender?: boolean, debounce?: number | boolean): Promise<import("./BackendResponse").default>;
42+
$render(): Promise<import("./Backend/BackendResponse").default>;
43+
$updateModel(model: string, value: any, shouldRender?: boolean, debounce?: number | boolean): Promise<import("./Backend/BackendResponse").default>;
4444
private handleInputEvent;
4545
private handleChangeEvent;
4646
private updateModelFromElementEvent;

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 58 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -352,37 +352,23 @@ const parseDeepData = function (data, propertyPath) {
352352
parts,
353353
};
354354
};
355-
function setDeepData(data, propertyPath, value) {
356-
const { currentLevelData, finalData, finalKey, parts } = parseDeepData(data, propertyPath);
357-
if (typeof currentLevelData !== 'object') {
358-
const lastPart = parts.pop();
359-
if (typeof currentLevelData === 'undefined') {
360-
throw new Error(`Cannot set data-model="${propertyPath}". The parent "${parts.join('.')}" data does not exist. Did you forget to expose "${parts[0]}" as a LiveProp?`);
361-
}
362-
throw new Error(`Cannot set data-model="${propertyPath}". The parent "${parts.join('.')}" data does not appear to be an object (it's "${currentLevelData}"). Did you forget to add exposed={"${lastPart}"} to its LiveProp?`);
363-
}
364-
if (currentLevelData[finalKey] === undefined) {
365-
const lastPart = parts.pop();
366-
if (parts.length > 0) {
367-
throw new Error(`The model name ${propertyPath} was never initialized. Did you forget to add exposed={"${lastPart}"} to its LiveProp?`);
368-
}
369-
else {
370-
throw new Error(`The model name "${propertyPath}" was never initialized. Did you forget to expose "${lastPart}" as a LiveProp? Available models values are: ${Object.keys(data).length > 0 ? Object.keys(data).join(', ') : '(none)'}`);
371-
}
372-
}
373-
currentLevelData[finalKey] = value;
374-
return finalData;
375-
}
376355

377356
class ValueStore {
378357
constructor(props) {
379358
this.identifierKey = '@id';
380-
this.updatedModels = [];
381359
this.props = {};
360+
this.dirtyProps = {};
361+
this.pendingProps = {};
382362
this.props = props;
383363
}
384364
get(name) {
385365
const normalizedName = normalizeModelName(name);
366+
if (this.dirtyProps[normalizedName] !== undefined) {
367+
return this.dirtyProps[normalizedName];
368+
}
369+
if (this.pendingProps[normalizedName] !== undefined) {
370+
return this.pendingProps[normalizedName];
371+
}
386372
const value = getDeepData(this.props, normalizedName);
387373
if (null === value) {
388374
return value;
@@ -396,26 +382,31 @@ class ValueStore {
396382
return this.get(name) !== undefined;
397383
}
398384
set(name, value) {
399-
let normalizedName = normalizeModelName(name);
400-
if (this.isPropNameTopLevel(normalizedName)
401-
&& this.props[normalizedName] !== null
402-
&& typeof this.props[normalizedName] === 'object'
403-
&& this.props[normalizedName][this.identifierKey] !== undefined) {
404-
normalizedName = normalizedName + '.' + this.identifierKey;
405-
}
385+
const normalizedName = normalizeModelName(name);
406386
const currentValue = this.get(normalizedName);
407-
if (currentValue !== value && !this.updatedModels.includes(normalizedName)) {
408-
this.updatedModels.push(normalizedName);
387+
if (currentValue === value) {
388+
return false;
409389
}
410-
this.props = setDeepData(this.props, normalizedName, value);
411-
return currentValue !== value;
390+
this.dirtyProps[normalizedName] = value;
391+
return true;
412392
}
413-
all() {
393+
getOriginalProps() {
414394
return Object.assign({}, this.props);
415395
}
396+
getDirtyProps() {
397+
return Object.assign({}, this.dirtyProps);
398+
}
399+
flushDirtyPropsToPending() {
400+
this.pendingProps = Object.assign({}, this.dirtyProps);
401+
this.dirtyProps = {};
402+
}
416403
reinitializeAllProps(props) {
417-
this.updatedModels = [];
418404
this.props = props;
405+
this.pendingProps = {};
406+
}
407+
pushPendingPropsBackToDirty() {
408+
this.dirtyProps = Object.assign(Object.assign({}, this.pendingProps), this.dirtyProps);
409+
this.pendingProps = {};
419410
}
420411
reinitializeProvidedProps(props) {
421412
let changed = false;
@@ -1414,6 +1405,9 @@ class Component {
14141405
this.resetPromise();
14151406
this.onChildComponentModelUpdate = this.onChildComponentModelUpdate.bind(this);
14161407
}
1408+
_swapBackend(backend) {
1409+
this.backend = backend;
1410+
}
14171411
addPlugin(plugin) {
14181412
plugin.attachToComponent(this);
14191413
}
@@ -1535,10 +1529,10 @@ class Component {
15351529
const thisPromiseResolve = this.nextRequestPromiseResolve;
15361530
this.resetPromise();
15371531
this.unsyncedInputsTracker.resetUnsyncedFields();
1538-
this.backendRequest = this.backend.makeRequest(this.valueStore.all(), this.pendingActions, this.valueStore.updatedModels, this.getChildrenFingerprints());
1532+
this.backendRequest = this.backend.makeRequest(this.valueStore.getOriginalProps(), this.pendingActions, this.valueStore.getDirtyProps(), this.getChildrenFingerprints());
15391533
this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest);
15401534
this.pendingActions = [];
1541-
this.valueStore.updatedModels = [];
1535+
this.valueStore.flushDirtyPropsToPending();
15421536
this.isRequestPending = false;
15431537
this.backendRequest.promise.then(async (response) => {
15441538
this.backendRequest = null;
@@ -1547,6 +1541,7 @@ class Component {
15471541
const headers = backendResponse.response.headers;
15481542
if (headers.get('Content-Type') !== 'application/vnd.live-component+html' && !headers.get('X-Live-Redirect')) {
15491543
const controls = { displayError: true };
1544+
this.valueStore.pushPendingPropsBackToDirty();
15501545
this.hooks.triggerHook('response:error', backendResponse, controls);
15511546
if (controls.displayError) {
15521547
this.renderError(html);
@@ -1580,7 +1575,7 @@ class Component {
15801575
}
15811576
this.hooks.triggerHook('loading.state:finished', this.element);
15821577
const modifiedModelValues = {};
1583-
this.valueStore.updatedModels.forEach((modelName) => {
1578+
Object.keys(this.valueStore.getDirtyProps()).forEach((modelName) => {
15841579
modifiedModelValues[modelName] = this.valueStore.get(modelName);
15851580
});
15861581
let newElement;
@@ -1734,12 +1729,12 @@ class BackendRequest {
17341729
}
17351730
}
17361731

1737-
class Backend {
1732+
class RequestBuilder {
17381733
constructor(url, csrfToken = null) {
17391734
this.url = url;
17401735
this.csrfToken = csrfToken;
17411736
}
1742-
makeRequest(data, actions, updatedModels, childrenFingerprints) {
1737+
buildRequest(props, actions, updated, childrenFingerprints) {
17431738
const splitUrl = this.url.split('?');
17441739
let [url] = splitUrl;
17451740
const [, queryString] = splitUrl;
@@ -1749,25 +1744,19 @@ class Backend {
17491744
Accept: 'application/vnd.live-component+html',
17501745
};
17511746
const hasFingerprints = Object.keys(childrenFingerprints).length > 0;
1752-
const hasUpdatedModels = Object.keys(updatedModels).length > 0;
17531747
if (actions.length === 0 &&
1754-
this.willDataFitInUrl(JSON.stringify(data), params, JSON.stringify(childrenFingerprints))) {
1755-
params.set('data', JSON.stringify(data));
1748+
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(childrenFingerprints))) {
1749+
params.set('props', JSON.stringify(props));
1750+
params.set('updated', JSON.stringify(updated));
17561751
if (hasFingerprints) {
17571752
params.set('childrenFingerprints', JSON.stringify(childrenFingerprints));
17581753
}
1759-
updatedModels.forEach((model) => {
1760-
params.append('updatedModels[]', model);
1761-
});
17621754
fetchOptions.method = 'GET';
17631755
}
17641756
else {
17651757
fetchOptions.method = 'POST';
17661758
fetchOptions.headers['Content-Type'] = 'application/json';
1767-
const requestData = { data };
1768-
if (hasUpdatedModels) {
1769-
requestData.updatedModels = updatedModels;
1770-
}
1759+
const requestData = { props, updated };
17711760
if (hasFingerprints) {
17721761
requestData.childrenFingerprints = childrenFingerprints;
17731762
}
@@ -1787,14 +1776,27 @@ class Backend {
17871776
fetchOptions.body = JSON.stringify(requestData);
17881777
}
17891778
const paramsString = params.toString();
1790-
return new BackendRequest(fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions), actions.map((backendAction) => backendAction.name), updatedModels);
1779+
return {
1780+
url: `${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`,
1781+
fetchOptions,
1782+
};
17911783
}
1792-
willDataFitInUrl(dataJson, params, childrenFingerprintsJson) {
1793-
const urlEncodedJsonData = new URLSearchParams(dataJson + childrenFingerprintsJson).toString();
1784+
willDataFitInUrl(propsJson, updatedJson, params, childrenFingerprintsJson) {
1785+
const urlEncodedJsonData = new URLSearchParams(propsJson + updatedJson + childrenFingerprintsJson).toString();
17941786
return (urlEncodedJsonData + params.toString()).length < 1500;
17951787
}
17961788
}
17971789

1790+
class Backend {
1791+
constructor(url, csrfToken = null) {
1792+
this.requestBuilder = new RequestBuilder(url, csrfToken);
1793+
}
1794+
makeRequest(props, actions, updated, childrenFingerprints) {
1795+
const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, childrenFingerprints);
1796+
return new BackendRequest(fetch(url, fetchOptions), actions.map((backendAction) => backendAction.name), Object.keys(updated));
1797+
}
1798+
}
1799+
17981800
class StandardElementDriver {
17991801
getModelName(element) {
18001802
const modelDirective = getModelDirectiveFromElement(element, false);
@@ -2219,7 +2221,7 @@ const ComponentRegistry = class {
22192221
var ComponentRegistry$1 = new ComponentRegistry();
22202222

22212223
const getComponent = (element) => ComponentRegistry$1.getComponent(element);
2222-
class default_1 extends Controller {
2224+
class LiveControllerDefault extends Controller {
22232225
constructor() {
22242226
super(...arguments);
22252227
this.pendingActionTriggerModelElement = null;
@@ -2388,7 +2390,7 @@ class default_1 extends Controller {
23882390
this.dispatch(name, { detail, prefix: 'live', cancelable, bubbles: canBubble });
23892391
}
23902392
}
2391-
default_1.values = {
2393+
LiveControllerDefault.values = {
23922394
url: String,
23932395
props: Object,
23942396
csrf: String,
@@ -2397,4 +2399,4 @@ default_1.values = {
23972399
fingerprint: String,
23982400
};
23992401

2400-
export { Component, default_1 as default, getComponent };
2402+
export { Component, LiveControllerDefault as default, getComponent };

0 commit comments

Comments
 (0)