Repository navigation
Expand file tree
/
Copy pathframes.ts
More file actions
167 lines (151 loc) · 5.82 KB
/
Copy pathframes.ts
File metadata and controls
167 lines (151 loc) · 5.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
/**
* Message bodies in the terminal.
*
* The webmail renders each message into a sandboxed <iframe srcdoc>. A
* terminal has no nested browsing context to paint, but TermDOM does lay out
* an iframe's child nodes like any other box. So whenever an iframe's srcdoc
* changes, its document is copied into a shadow tree inside the iframe
* element, where its stylesheet stays scoped to the message, and the
* messages the iframe's runtime script would post back (content height,
* link clicks) are posted from here instead. Nothing runs from the message
* itself: scripts are dropped with the rest of the head, and the inline event
* handlers and javascript: URLs that could run once the nodes join the app's
* scripted document are stripped as they are copied in.
*/
import { convertDeclarations, convertStylesheet } from './px-to-cells.js';
import type { AnyRecord } from './types';
const VIEW_CLASS = 'fe-frame-view';
const kPending = Symbol('forwardemail.frame.pending');
// Attributes whose value is a URL the document would act on.
const URL_ATTRIBUTES = [
'href',
'xlink:href',
'src',
'action',
'formaction',
'background',
'poster',
'ping',
];
// Schemes that run code rather than naming a resource.
const ACTIVE_URL_SCHEME = /^\s*(?:javascript|vbscript):/i;
// The message stylesheet targets html and body, which a shadow tree has not
// got; they become the wrapper that stands in for both.
function scopeToWrapper(css: string) {
return css
.replace(/(^|[\s,>+~}(])html\b/g, '$1.fe-frame-root')
.replace(/(^|[\s,>+~}(])body\b/g, '$1.fe-frame-root');
}
function postToApp(win: AnyRecord, iframe: AnyRecord, data: unknown) {
const event = new win.MessageEvent('message', {
data,
origin: 'null',
source: iframe.contentWindow,
});
win.dispatchEvent(event);
}
function render(win: AnyRecord, iframe: AnyRecord) {
iframe[kPending] = false;
const doc = iframe.contentDocument as AnyRecord | null;
const document = iframe.ownerDocument as AnyRecord;
if (!doc || !document) return;
let view = [...iframe.children].find((child: AnyRecord) => child.classList?.contains(VIEW_CLASS));
if (!view) {
view = document.createElement('div');
view.className = VIEW_CLASS;
view.attachShadow({ mode: 'open' });
iframe.append(view);
}
const styles = [...doc.querySelectorAll('style')]
.map((style: AnyRecord) => style.textContent ?? '')
.join('\n');
let css = '';
try {
css = convertStylesheet(scopeToWrapper(styles));
} catch {
css = '';
}
const rootClasses = [doc.documentElement?.className, doc.body?.className]
.filter(Boolean)
.join(' ');
const root = document.createElement('div');
root.className = `fe-frame-root ${rootClasses}`.trim();
for (const node of [...(doc.body?.childNodes ?? [])]) {
if (node.nodeName === 'SCRIPT') continue;
root.append(document.importNode(node, true));
}
for (const el of root.querySelectorAll('script, iframe, object, embed, link, meta')) el.remove();
// Defense in depth. The body is sanitized before it becomes the iframe's
// srcdoc, but this is where its nodes cross into the app's own, scripted
// document, so strip anything that could execute here no matter what the
// sanitizer upstream did: inline event handler attributes, and
// javascript:/vbscript: URLs on the attributes that follow one.
for (const el of root.querySelectorAll('*')) {
for (const name of el.getAttributeNames?.() ?? []) {
if (/^on/i.test(name)) el.removeAttribute(name);
}
for (const name of URL_ATTRIBUTES) {
const value = el.getAttribute(name);
// Tab, newline and carriage return are stripped from a URL before its
// scheme is read, so a "java\tscript:" href is one too.
if (value && ACTIVE_URL_SCHEME.test(value.replace(/[\t\n\r]/g, ''))) {
el.removeAttribute(name);
}
}
}
for (const el of root.querySelectorAll('[style]')) {
el.setAttribute('style', convertDeclarations(el.getAttribute('style') ?? ''));
}
const style = document.createElement('style');
style.textContent = css;
view.shadowRoot.replaceChildren(style, root);
// Links go through the app, as the iframe runtime would send them.
root.addEventListener('click', (event: AnyRecord) => {
const link = event.target?.closest?.('a[href]');
if (!link) return;
event.preventDefault();
const url = link.getAttribute('href') ?? '';
postToApp(win, iframe, {
type: 'link',
payload: { url, isMailto: url.toLowerCase().startsWith('mailto:') },
});
});
// The app sizes the frame from reported heights and treats a frame that
// never reports as broken; the content here sizes itself.
setTimeout(() => {
const height = Math.max(view.getBoundingClientRect().height, 51);
postToApp(win, iframe, { type: 'ready' });
postToApp(win, iframe, { type: 'height', payload: { height } });
}, 0);
}
function schedule(win: AnyRecord, iframe: AnyRecord) {
if (iframe[kPending]) return;
iframe[kPending] = true;
queueMicrotask(() => render(win, iframe));
}
export function installFrames(win: AnyRecord) {
const proto = win.HTMLIFrameElement?.prototype;
if (!proto) return;
const srcdoc = Object.getOwnPropertyDescriptor(proto, 'srcdoc');
if (srcdoc?.set) {
Object.defineProperty(proto, 'srcdoc', {
...srcdoc,
set(this: AnyRecord, value: unknown) {
srcdoc.set!.call(this, value);
schedule(win, this);
},
});
}
const setAttribute = win.Element.prototype.setAttribute;
Object.defineProperty(win.Element.prototype, 'setAttribute', {
configurable: true,
writable: true,
value(this: AnyRecord, name: string, value: unknown) {
const result = setAttribute.call(this, name, value);
if (this.localName === 'iframe' && String(name).toLowerCase() === 'srcdoc') {
schedule(win, this);
}
return result;
},
});
}