Skip to content

Commit e34e9e4

Browse files
authored
text: add checkbox to control server-side markdown conversion (#5378)
* define plugin_util.safe_html() with no markdown interpretation * make plugin_util.safe_html() handle unicode vs bytes clearly * add markdown=false request parameter to disable markdown interpretation * fix bytes to str conversion bug in text plugin no-markdown codepath * text: add checkbox to control server-side markdown conversion * yarn fix-lint
1 parent a734e5a commit e34e9e4

File tree

7 files changed

+198
-22
lines changed

7 files changed

+198
-22
lines changed

tensorboard/plugin_util.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,22 @@ def __init__(self):
9090
_CLEANER_STORE = _CleanerStore()
9191

9292

93+
def safe_html(unsafe_string):
94+
"""Return the input as a str, sanitized for insertion into the DOM.
95+
96+
Arguments:
97+
unsafe_string: A Unicode string or UTF-8--encoded bytestring
98+
possibly containing unsafe HTML markup.
99+
100+
Returns:
101+
A string containing safe HTML.
102+
"""
103+
total_null_bytes = 0
104+
if isinstance(unsafe_string, bytes):
105+
unsafe_string = unsafe_string.decode("utf-8")
106+
return _CLEANER_STORE.cleaner.clean(unsafe_string)
107+
108+
93109
def markdown_to_safe_html(markdown_string):
94110
"""Convert Markdown to HTML that's safe to splice into the DOM.
95111

tensorboard/plugin_util_test.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,58 @@
2222
from tensorboard.backend import experiment_id
2323

2424

25+
class SafeHTMLTest(tb_test.TestCase):
26+
def test_empty_input(self):
27+
self.assertEqual(plugin_util.safe_html(""), "")
28+
29+
def test_whitelisted_tags_and_attributes_allowed(self):
30+
s = (
31+
'Check out <a href="http://example.com" title="do it">'
32+
"my website</a>!"
33+
)
34+
self.assertEqual(plugin_util.safe_html(s), "%s" % s)
35+
36+
def test_arbitrary_tags_and_attributes_removed(self):
37+
self.assertEqual(
38+
plugin_util.safe_html(
39+
"We should bring back the <blink>blink tag</blink>; "
40+
'<a name="bookmark" href="http://please-dont.com">'
41+
"sign the petition!</a>"
42+
),
43+
"We should bring back the "
44+
"&lt;blink&gt;blink tag&lt;/blink&gt;; "
45+
'<a href="http://please-dont.com">sign the petition!</a>',
46+
)
47+
48+
def test_javascript_hrefs_sanitized(self):
49+
self.assertEqual(
50+
plugin_util.safe_html(
51+
'A <a href="javascript:void0">sketchy link</a> for you'
52+
),
53+
"A <a>sketchy link</a> for you",
54+
)
55+
56+
def test_byte_strings_interpreted_as_utf8(self):
57+
s = "Look\u2014some UTF-8!".encode("utf-8")
58+
assert isinstance(s, bytes), (type(s), bytes)
59+
self.assertEqual(plugin_util.safe_html(s), "Look\u2014some UTF-8!")
60+
61+
def test_unicode_strings_passed_through(self):
62+
s = "Look\u2014some UTF-8!"
63+
assert not isinstance(s, bytes), (type(s), bytes)
64+
self.assertEqual(plugin_util.safe_html(s), "Look\u2014some UTF-8!")
65+
66+
def test_null_bytes_stripped(self):
67+
# If this function is mistakenly called with UTF-16 or UTF-32 encoded text,
68+
# there will probably be a bunch of null bytes. Ensure these are stripped.
69+
s = "un_der_score".encode("utf-32-le")
70+
# UTF-32 encoding of ASCII will have 3 null bytes per char. 36 = 3 * 12.
71+
self.assertEqual(
72+
plugin_util.safe_html(s),
73+
"un_der_score",
74+
)
75+
76+
2577
class MarkdownToSafeHTMLTest(tb_test.TestCase):
2678
def _test(self, markdown_string, expected):
2779
actual = plugin_util.markdown_to_safe_html(markdown_string)

tensorboard/plugins/text/text_plugin.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def reduce_to_2d(arr):
152152
return arr[slices]
153153

154154

155-
def text_array_to_html(text_arr):
155+
def text_array_to_html(text_arr, enable_markdown):
156156
"""Take a numpy.ndarray containing strings, and convert it into html.
157157
158158
If the ndarray contains a single scalar string, that string is converted to
@@ -164,29 +164,42 @@ def text_array_to_html(text_arr):
164164
165165
Args:
166166
text_arr: A numpy.ndarray containing strings.
167+
enable_markdown: boolean, whether to enable Markdown
167168
168169
Returns:
169170
The array converted to html.
170171
"""
171172
if not text_arr.shape:
172-
# It is a scalar. No need to put it in a table, just apply markdown
173-
return plugin_util.markdown_to_safe_html(text_arr.item())
173+
# It is a scalar. No need to put it in a table.
174+
if enable_markdown:
175+
return plugin_util.markdown_to_safe_html(text_arr.item())
176+
else:
177+
return plugin_util.safe_html(text_arr.item())
174178
warning = ""
175179
if len(text_arr.shape) > 2:
176180
warning = plugin_util.markdown_to_safe_html(
177181
WARNING_TEMPLATE % len(text_arr.shape)
178182
)
179183
text_arr = reduce_to_2d(text_arr)
180-
table = plugin_util.markdowns_to_safe_html(
181-
text_arr.reshape(-1),
182-
lambda xs: make_table(np.array(xs).reshape(text_arr.shape)),
183-
)
184+
if enable_markdown:
185+
table = plugin_util.markdowns_to_safe_html(
186+
text_arr.reshape(-1),
187+
lambda xs: make_table(np.array(xs).reshape(text_arr.shape)),
188+
)
189+
else:
190+
# Convert utf-8 bytes to str. The built-in np.char.decode doesn't work on
191+
# object arrays, and converting to an numpy chararray is lossy.
192+
decode = lambda bs: bs.decode("utf-8") if isinstance(bs, bytes) else bs
193+
text_arr_str = np.array(
194+
[decode(bs) for bs in text_arr.reshape(-1)]
195+
).reshape(text_arr.shape)
196+
table = plugin_util.safe_html(make_table(text_arr_str))
184197
return warning + table
185198

186199

187-
def process_event(wall_time, step, string_ndarray):
200+
def process_event(wall_time, step, string_ndarray, enable_markdown):
188201
"""Convert a text event into a JSON-compatible response."""
189-
html = text_array_to_html(string_ndarray)
202+
html = text_array_to_html(string_ndarray, enable_markdown)
190203
return {
191204
"wall_time": wall_time,
192205
"step": step,
@@ -242,7 +255,7 @@ def tags_route(self, request):
242255
index = self.index_impl(ctx, experiment)
243256
return http_util.Respond(request, index, "application/json")
244257

245-
def text_impl(self, ctx, run, tag, experiment):
258+
def text_impl(self, ctx, run, tag, experiment, enable_markdown):
246259
all_text = self._data_provider.read_tensors(
247260
ctx,
248261
experiment_id=experiment,
@@ -253,15 +266,20 @@ def text_impl(self, ctx, run, tag, experiment):
253266
text = all_text.get(run, {}).get(tag, None)
254267
if text is None:
255268
return []
256-
return [process_event(d.wall_time, d.step, d.numpy) for d in text]
269+
return [
270+
process_event(d.wall_time, d.step, d.numpy, enable_markdown)
271+
for d in text
272+
]
257273

258274
@wrappers.Request.application
259275
def text_route(self, request):
260276
ctx = plugin_util.context(request.environ)
261277
experiment = plugin_util.experiment_id(request.environ)
262278
run = request.args.get("run")
263279
tag = request.args.get("tag")
264-
response = self.text_impl(ctx, run, tag, experiment)
280+
markdown_arg = request.args.get("markdown")
281+
enable_markdown = markdown_arg != "false" # Default to enabled.
282+
response = self.text_impl(ctx, run, tag, experiment, enable_markdown)
265283
return http_util.Respond(request, response, "application/json")
266284

267285
def get_plugin_apps(self):

tensorboard/plugins/text/text_plugin_test.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ def generate_testdata(self, logdir=None):
7676
writer.add_summary(summ, global_step=step)
7777
step += 1
7878

79-
vector_message = ["one", "two", "three", "four"]
79+
# Test unicode superscript 4.
80+
vector_message = ["one", "two", "three", "\u2074"]
8081
summ = sess.run(
8182
vector_summary, feed_dict={placeholder: vector_message}
8283
)
@@ -101,22 +102,38 @@ def testIndex(self):
101102
def testText(self):
102103
plugin = self.load_plugin()
103104
fry = plugin.text_impl(
104-
context.RequestContext(), "fry", "message", experiment="123"
105+
context.RequestContext(),
106+
"fry",
107+
"message",
108+
experiment="123",
109+
enable_markdown=True,
105110
)
106111
leela = plugin.text_impl(
107-
context.RequestContext(), "leela", "message", experiment="123"
112+
context.RequestContext(),
113+
"leela",
114+
"message",
115+
experiment="123",
116+
enable_markdown=False,
108117
)
109118
self.assertEqual(len(fry), 4)
110119
self.assertEqual(len(leela), 4)
111120
for i in range(4):
112121
self.assertEqual(fry[i]["step"], i)
113122
self.assertEqual(leela[i]["step"], i)
114-
115-
table = plugin.text_impl(
116-
context.RequestContext(), "fry", "vector", experiment="123"
123+
self.assertEqual(
124+
fry[i]["text"], "<p>fry <em>loves</em> %s</p>" % GEMS[i]
125+
)
126+
self.assertEqual(leela[i]["text"], "leela *loves* %s" % GEMS[i])
127+
128+
md_table = plugin.text_impl(
129+
context.RequestContext(),
130+
"fry",
131+
"vector",
132+
experiment="123",
133+
enable_markdown=True,
117134
)[0]["text"]
118135
self.assertEqual(
119-
table,
136+
md_table,
120137
textwrap.dedent(
121138
"""\
122139
<table>
@@ -131,7 +148,37 @@ def testText(self):
131148
<td><p>three</p></td>
132149
</tr>
133150
<tr>
134-
<td><p>four</p></td>
151+
<td><p>\u2074</p></td>
152+
</tr>
153+
</tbody>
154+
</table>
155+
""".rstrip()
156+
),
157+
)
158+
plain_table = plugin.text_impl(
159+
context.RequestContext(),
160+
"fry",
161+
"vector",
162+
experiment="123",
163+
enable_markdown=False,
164+
)[0]["text"]
165+
self.assertEqual(
166+
plain_table,
167+
textwrap.dedent(
168+
"""\
169+
<table>
170+
<tbody>
171+
<tr>
172+
<td>one</td>
173+
</tr>
174+
<tr>
175+
<td>two</td>
176+
</tr>
177+
<tr>
178+
<td>three</td>
179+
</tr>
180+
<tr>
181+
<td>\u2074</td>
135182
</tr>
136183
</tbody>
137184
</table>
@@ -297,7 +344,9 @@ def make_range_array(dim):
297344
np.testing.assert_array_equal(actual, expected)
298345

299346
def test_text_array_to_html(self):
300-
convert = text_plugin.text_array_to_html
347+
convert = lambda x: text_plugin.text_array_to_html(
348+
x, enable_markdown=True
349+
)
301350
scalar = np.array("foo")
302351
scalar_expected = "<p>foo</p>"
303352
self.assertEqual(convert(scalar), scalar_expected)

tensorboard/plugins/text/tf_text_dashboard/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ tf_ts_library(
2222
"//tensorboard/components/tf_markdown_view",
2323
"//tensorboard/components/tf_paginated_view",
2424
"//tensorboard/components/tf_runs_selector",
25+
"//tensorboard/components/tf_storage",
2526
"@npm//@polymer/decorators",
2627
"@npm//@polymer/polymer",
2728
"@npm//@types/d3",

tensorboard/plugins/text/tf_text_dashboard/tf-text-dashboard.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,21 @@ import '../../../components/tf_dashboard_common/dashboard-style';
3030
import '../../../components/tf_dashboard_common/tf-dashboard-layout';
3131
import '../../../components/tf_paginated_view/tf-category-paginated-view';
3232
import '../../../components/tf_runs_selector/tf-runs-selector';
33-
33+
import * as tf_storage from '../../../components/tf_storage/storage';
3434
import './tf-text-loader';
3535

3636
@customElement('tf-text-dashboard')
3737
class TfTextDashboard extends LegacyElementMixin(PolymerElement) {
3838
static readonly template = html`
3939
<tf-dashboard-layout>
4040
<div class="sidebar" slot="sidebar">
41+
<div class="sidebar-section">
42+
<div class="line-item">
43+
<paper-checkbox checked="{{_markdownEnabled}}"
44+
>Enable Markdown</paper-checkbox
45+
>
46+
</div>
47+
</div>
4148
<div class="sidebar-section runs-selector">
4249
<tf-runs-selector selected-runs="{{_selectedRuns}}">
4350
</tf-runs-selector>
@@ -90,6 +97,7 @@ class TfTextDashboard extends LegacyElementMixin(PolymerElement) {
9097
tag="[[item.tag]]"
9198
run="[[item.run]]"
9299
request-manager="[[_requestManager]]"
100+
markdown-enabled="[[_markdownEnabled]]"
93101
></tf-text-loader>
94102
</template>
95103
</tf-category-paginated-view>
@@ -109,6 +117,18 @@ class TfTextDashboard extends LegacyElementMixin(PolymerElement) {
109117
@property({type: Boolean})
110118
reloadOnReady: boolean = true;
111119

120+
@property({
121+
type: Boolean,
122+
notify: true,
123+
observer: '_markdownEnabledStorageObserver',
124+
})
125+
_markdownEnabled: boolean = tf_storage
126+
.getBooleanInitializer('_markdownEnabled', {
127+
defaultValue: true,
128+
useLocalStorage: true,
129+
})
130+
.call(this);
131+
112132
@property({type: Array})
113133
_selectedRuns: string[];
114134

@@ -130,6 +150,10 @@ class TfTextDashboard extends LegacyElementMixin(PolymerElement) {
130150
@property({type: Object})
131151
_requestManager = new RequestManager();
132152

153+
static get observers() {
154+
return ['_markdownEnabledObserver(_markdownEnabled)'];
155+
}
156+
133157
ready() {
134158
super.ready();
135159
if (this.reloadOnReady) this.reload();
@@ -174,4 +198,16 @@ class TfTextDashboard extends LegacyElementMixin(PolymerElement) {
174198
var tagFilter = this._tagFilter;
175199
return categorizeRunTagCombinations(runToTag, selectedRuns, tagFilter);
176200
}
201+
202+
_markdownEnabledStorageObserver = tf_storage.getBooleanObserver(
203+
'_markdownEnabled',
204+
{
205+
defaultValue: true,
206+
useLocalStorage: true,
207+
}
208+
);
209+
210+
_markdownEnabledObserver() {
211+
this._reloadTexts();
212+
}
177213
}

tensorboard/plugins/text/tf_text_dashboard/tf-text-loader.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ class TfTextLoader extends LegacyElementMixin(PolymerElement) {
104104
@property({type: String})
105105
tag: string;
106106

107+
@property({type: Boolean})
108+
markdownEnabled: boolean;
109+
107110
// Ordered from newest to oldest.
108111
@property({type: Array})
109112
_texts: Array<{wall_time: Date; step: number; text: string}> = [];
@@ -141,6 +144,7 @@ class TfTextLoader extends LegacyElementMixin(PolymerElement) {
141144
const url = addParams(router.pluginRoute('text', '/text'), {
142145
tag: this.tag,
143146
run: this.run,
147+
markdown: this.markdownEnabled ? 'true' : 'false',
144148
});
145149
const updateTexts = this._canceller.cancellable((result) => {
146150
if (result.cancelled) {

0 commit comments

Comments
 (0)