diff --git a/.github/workflows/FrontendTest.yml b/.github/workflows/FrontendTest.yml index 07e6cf55da..99db72eeee 100644 --- a/.github/workflows/FrontendTest.yml +++ b/.github/workflows/FrontendTest.yml @@ -68,6 +68,7 @@ jobs: options = Pluto.Configuration.from_flat_kwargs(; port=parse(Int, ENV["PLUTO_PORT"]), require_secret_for_access=false, + workspace_use_distributed_stdlib=false, ) 🍭 = Pluto.ServerSession(; options) server = Pluto.run!(🍭) diff --git a/frontend/common/Binder.js b/frontend/common/Binder.js index dee8a749f3..21c9842dd9 100644 --- a/frontend/common/Binder.js +++ b/frontend/common/Binder.js @@ -144,6 +144,7 @@ export const start_binder = async ({ setStatePromise, connect, launch_params }) const upload_url = with_token( with_query_params(new URL("notebookupload", binder_session_url), { name: new URLSearchParams(window.location.search).get("name"), + execution_allowed: "true", }) ) console.log(`downloading locally and uploading `, upload_url, launch_params.notebookfile) diff --git a/frontend/common/InstallTimeEstimate.js b/frontend/common/InstallTimeEstimate.js new file mode 100644 index 0000000000..89aaad2326 --- /dev/null +++ b/frontend/common/InstallTimeEstimate.js @@ -0,0 +1,99 @@ +import { useEffect, useState } from "../imports/Preact.js" +import _ from "../imports/lodash.js" + +const loading_times_url = `https://julia-loading-times-test.netlify.app/pkg_load_times.csv` +const package_list_url = `https://julia-loading-times-test.netlify.app/top_packages_sorted_with_deps.txt` + +/** @typedef {{ install: Number, precompile: Number, load: Number }} LoadingTime */ + +/** + * @typedef PackageTimingData + * @type {{ + * times: Map, + * packages: Map, + * }} + */ + +/** @type {{ current: Promise? }} */ +const data_promise_ref = { current: null } + +export const get_data = () => { + if (data_promise_ref.current != null) { + return data_promise_ref.current + } else { + const times_p = fetch(loading_times_url) + .then((res) => res.text()) + .then((text) => { + const lines = text.split("\n") + const header = lines[0].split(",") + return new Map( + lines.slice(1).map((line) => { + let [pkg, ...times] = line.split(",") + + return [pkg, { install: Number(times[0]), precompile: Number(times[1]), load: Number(times[2]) }] + }) + ) + }) + + const packages_p = fetch(package_list_url) + .then((res) => res.text()) + .then( + (text) => + new Map( + text.split("\n").map((line) => { + let [pkg, ...deps] = line.split(",") + return [pkg, deps] + }) + ) + ) + + data_promise_ref.current = Promise.all([times_p, packages_p]).then(([times, packages]) => ({ times, packages })) + + return data_promise_ref.current + } +} + +export const usePackageTimingData = () => { + const [data, set_data] = useState(/** @type {PackageTimingData?} */ (null)) + + useEffect(() => { + get_data().then(set_data) + }, []) + + return data +} + +const recursive_deps = (/** @type {PackageTimingData} */ data, /** @type {string} */ pkg, found = []) => { + const deps = data.packages.get(pkg) + if (deps == null) { + return [] + } else { + const newfound = _.union(found, deps) + return [...deps, ..._.difference(deps, found).flatMap((dep) => recursive_deps(data, dep, newfound))] + } +} + +export const time_estimate = (/** @type {PackageTimingData} */ data, /** @type {string[]} */ packages) => { + let deps = packages.flatMap((pkg) => recursive_deps(data, pkg)) + let times = _.uniq([...packages, ...deps]) + .map((pkg) => data.times.get(pkg)) + .filter((x) => x != null) + + console.log({ packages, deps, times, z: _.uniq([...packages, ...deps]) }) + let sum = (xs) => xs.reduce((acc, x) => acc + (x == null || isNaN(x) ? 0 : x), 0) + + return { + install: sum(times.map(_.property("install"))) * timing_weights.install, + precompile: sum(times.map(_.property("precompile"))) * timing_weights.precompile, + load: sum(times.map(_.property("load"))) * timing_weights.load, + } +} + +const timing_weights = { + // Because the GitHub Action runner has superfast internet + install: 2, + // Because the GitHub Action runner has average compute speed + load: 1, + // Because precompilation happens in parallel + precompile: 0.3, +} diff --git a/frontend/common/ProcessStatus.js b/frontend/common/ProcessStatus.js new file mode 100644 index 0000000000..00fa9d5e73 --- /dev/null +++ b/frontend/common/ProcessStatus.js @@ -0,0 +1,7 @@ +export const ProcessStatus = { + ready: "ready", + starting: "starting", + no_process: "no_process", + waiting_to_restart: "waiting_to_restart", + waiting_for_permission: "waiting_for_permission", +} diff --git a/frontend/common/RunLocal.js b/frontend/common/RunLocal.js index 60a7dc4e4b..b43e8b90b5 100644 --- a/frontend/common/RunLocal.js +++ b/frontend/common/RunLocal.js @@ -41,6 +41,7 @@ export const start_local = async ({ setStatePromise, connect, launch_params }) = with_query_params(new URL("notebookupload", binder_session_url), { name: new URLSearchParams(window.location.search).get("name"), clear_frontmatter: "yesplease", + execution_allowed: "yepperz", }) ), { diff --git a/frontend/components/Cell.js b/frontend/components/Cell.js index 8ae055ab7f..8014c24fc9 100644 --- a/frontend/components/Cell.js +++ b/frontend/components/Cell.js @@ -8,6 +8,7 @@ import { RunArea, useDebouncedTruth } from "./RunArea.js" import { cl } from "../common/ClassTable.js" import { PlutoActionsContext } from "../common/PlutoContext.js" import { open_pluto_popup } from "./Popup.js" +import { SafePreviewOutput } from "./SafePreviewUI.js" const useCellApi = (node_ref, published_object_keys, pluto_actions) => { const [cell_api_ready, set_cell_api_ready] = useState(false) @@ -96,6 +97,8 @@ const on_jump = (hasBarrier, pluto_actions, cell_id) => () => { * selected: boolean, * force_hide_input: boolean, * focus_after_creation: boolean, + * process_waiting_for_permission: boolean, + * sanitize_html: boolean, * [key: string]: any, * }} props * */ @@ -110,6 +113,8 @@ export const Cell = ({ focus_after_creation, is_process_ready, disable_input, + process_waiting_for_permission, + sanitize_html = true, nbpkg, global_definition_locations, }) => { @@ -134,8 +139,8 @@ export const Cell = ({ const remount = useMemo(() => () => setKey(key + 1)) // cm_forced_focus is null, except when a line needs to be highlighted because it is part of a stack trace - const [cm_forced_focus, set_cm_forced_focus] = useState(/** @type{any} */ (null)) - const [cm_highlighted_range, set_cm_highlighted_range] = useState(null) + const [cm_forced_focus, set_cm_forced_focus] = useState(/** @type {any} */ (null)) + const [cm_highlighted_range, set_cm_highlighted_range] = useState(/** @type {{from, to}?} */ (null)) const [cm_highlighted_line, set_cm_highlighted_line] = useState(null) const [cm_diagnostics, set_cm_diagnostics] = useState([]) @@ -200,9 +205,11 @@ export const Cell = ({ const class_code_differs = code !== (cell_input_local?.code ?? code) const class_code_folded = code_folded && cm_forced_focus == null + const no_output_yet = (output?.last_run_timestamp ?? 0) === 0 + const code_not_trusted_yet = process_waiting_for_permission && no_output_yet // during the initial page load, force_hide_input === true, so that cell outputs render fast, and codemirrors are loaded after - let show_input = !force_hide_input && (errored || class_code_differs || !class_code_folded) + let show_input = !force_hide_input && (code_not_trusted_yet || errored || class_code_differs || !class_code_folded) const [line_heights, set_line_heights] = useState([15]) const node_ref = useRef(null) @@ -211,7 +218,13 @@ export const Cell = ({ disable_input_ref.current = disable_input const should_set_waiting_to_run_ref = useRef(true) should_set_waiting_to_run_ref.current = !running_disabled && !depends_on_disabled_cells - const set_waiting_to_run_smart = (x) => set_waiting_to_run(x && should_set_waiting_to_run_ref.current) + useEffect(() => { + const handler = (e) => { + if (e.detail.cell_ids.includes(cell_id)) set_waiting_to_run(should_set_waiting_to_run_ref.current) + } + window.addEventListener("set_waiting_to_run_smart", handler) + return () => window.removeEventListener("set_waiting_to_run_smart", handler) + }, [cell_id]) const cell_api_ready = useCellApi(node_ref, published_object_keys, pluto_actions) const on_delete = useCallback(() => { @@ -219,10 +232,9 @@ export const Cell = ({ }, [pluto_actions, selected, cell_id]) const on_submit = useCallback(() => { if (!disable_input_ref.current) { - set_waiting_to_run_smart(true) pluto_actions.set_and_run_multiple([cell_id]) } - }, [pluto_actions, set_waiting_to_run, cell_id]) + }, [pluto_actions, cell_id]) const on_change_cell_input = useCallback( (new_code) => { if (!disable_input_ref.current) { @@ -242,8 +254,7 @@ export const Cell = ({ }, [pluto_actions, cell_id, selected, code_folded]) const on_run = useCallback(() => { pluto_actions.set_and_run_multiple(pluto_actions.get_selected_cells(cell_id, selected)) - set_waiting_to_run_smart(true) - }, [pluto_actions, cell_id, selected, set_waiting_to_run_smart]) + }, [pluto_actions, cell_id, selected]) const set_show_logs = useCallback( (show_logs) => pluto_actions.update_notebook((notebook) => { @@ -272,21 +283,23 @@ export const Cell = ({ key=${cell_key} ref=${node_ref} class=${cl({ - queued: queued || (waiting_to_run && is_process_ready), - running, - activate_animation, - errored, - selected, - code_differs: class_code_differs, - code_folded: class_code_folded, - skip_as_script, - running_disabled, - depends_on_disabled_cells, - depends_on_skipped_cells, - show_input, - shrunk: Object.values(logs).length > 0, - hooked_up: output?.has_pluto_hook_features ?? false, - })} + queued: queued || (waiting_to_run && is_process_ready), + internal_test_queued: !is_process_ready && (queued || waiting_to_run), + running, + activate_animation, + errored, + selected, + code_differs: class_code_differs, + code_folded: class_code_folded, + skip_as_script, + running_disabled, + depends_on_disabled_cells, + depends_on_skipped_cells, + show_input, + shrunk: Object.values(logs).length > 0, + hooked_up: output?.has_pluto_hook_features ?? false, + no_output_yet, + })} id=${cell_id} > ${variables.map((name) => html``)} @@ -298,14 +311,18 @@ export const Cell = ({ - ${cell_api_ready ? html`<${CellOutput} errored=${errored} ...${output} cell_id=${cell_id} />` : html``} + ${code_not_trusted_yet + ? html`<${SafePreviewOutput} />` + : cell_api_ready + ? html`<${CellOutput} errored=${errored} ...${output} sanitize_html=${sanitize_html} cell_id=${cell_id} />` + : html``} <${CellInput} local_code=${cell_input_local?.code ?? code} remote_code=${code} @@ -337,7 +354,12 @@ export const Cell = ({ onerror=${remount} /> ${show_logs && cell_api_ready - ? html`<${Logs} logs=${Object.values(logs)} line_heights=${line_heights} set_cm_highlighted_line=${set_cm_highlighted_line} />` + ? html`<${Logs} + logs=${Object.values(logs)} + line_heights=${line_heights} + set_cm_highlighted_line=${set_cm_highlighted_line} + sanitize_html=${sanitize_html} + />` : null} <${RunArea} cell_id=${cell_id} @@ -345,8 +367,8 @@ export const Cell = ({ depends_on_disabled_cells=${depends_on_disabled_cells} on_run=${on_run} on_interrupt=${() => { - pluto_actions.interrupt_remote(cell_id) - }} + pluto_actions.interrupt_remote(cell_id) + }} set_cell_disabled=${set_cell_disabled} runtime=${runtime} running=${running} @@ -356,42 +378,42 @@ export const Cell = ({ /> ${skip_as_script - ? html`
{ - open_pluto_popup({ - type: "info", - source_element: e.target, - body: html`This cell is currently stored in the notebook file as a Julia comment, instead of code.
+ open_pluto_popup({ + type: "info", + source_element: e.target, + body: html`This cell is currently stored in the notebook file as a Julia comment, instead of code.
This way, it will not run when the notebook runs as a script outside of Pluto.
Use the context menu to enable it again`, - }) - }} + }) + }} >
` - : depends_on_skipped_cells + : depends_on_skipped_cells ? html`
{ - open_pluto_popup({ - type: "info", - source_element: e.target, - body: html`This cell is currently stored in the notebook file as a Julia comment, instead of code.
+ open_pluto_popup({ + type: "info", + source_element: e.target, + body: html`This cell is currently stored in the notebook file as a Julia comment, instead of code.
This way, it will not run when the notebook runs as a script outside of Pluto.
An upstream cell is indirectly disabling in file this one; enable the upstream one to affect this cell.`, - }) - }} + }) + }} >
` : null} @@ -404,7 +426,7 @@ export const Cell = ({ * [key: string]: any, * }} props * */ -export const IsolatedCell = ({ cell_input: { cell_id, metadata }, cell_result: { logs, output, published_object_keys }, hidden }) => { +export const IsolatedCell = ({ cell_input: { cell_id, metadata }, cell_result: { logs, output, published_object_keys }, hidden }, sanitize_html = true) => { const node_ref = useRef(null) let pluto_actions = useContext(PlutoActionsContext) const cell_api_ready = useCellApi(node_ref, published_object_keys, pluto_actions) @@ -412,8 +434,8 @@ export const IsolatedCell = ({ cell_input: { cell_id, metadata }, cell_result: { return html` - ${cell_api_ready ? html`<${CellOutput} ...${output} cell_id=${cell_id} />` : html``} - ${show_logs ? html`<${Logs} logs=${Object.values(logs)} line_heights=${[15]} set_cm_highlighted_line=${() => { }} />` : null} + ${cell_api_ready ? html`<${CellOutput} ...${output} sanitize_html=${sanitize_html} cell_id=${cell_id} />` : html``} + ${show_logs ? html`<${Logs} logs=${Object.values(logs)} line_heights=${[15]} set_cm_highlighted_line=${() => {}} />` : null} ` } diff --git a/frontend/components/CellOutput.js b/frontend/components/CellOutput.js index 8eb7b4943a..0b65b7dcc7 100644 --- a/frontend/components/CellOutput.js +++ b/frontend/components/CellOutput.js @@ -1,5 +1,7 @@ import { html, Component, useRef, useLayoutEffect, useContext } from "../imports/Preact.js" +import DOMPurify from "../imports/DOMPurify.js" + import { ErrorMessage, ParseError } from "./ErrorMessage.js" import { TreeView, TableView, DivElement } from "./TreeView.js" @@ -24,6 +26,7 @@ import { pluto_syntax_colors, ENABLE_CM_MIXED_PARSER } from "./CellInput.js" import hljs from "../imports/highlightjs.js" import { julia_mixed } from "./CellInput/mixedParsers.js" import { julia_andrey } from "../imports/CodemirrorPlutoSetup.js" +import { SafePreviewSanitizeMessage } from "./SafePreviewUI.js" export class CellOutput extends Component { constructor() { @@ -50,8 +53,8 @@ export class CellOutput extends Component { }) } - shouldComponentUpdate({ last_run_timestamp }) { - return last_run_timestamp !== this.props.last_run_timestamp + shouldComponentUpdate({ last_run_timestamp, sanitize_html }) { + return last_run_timestamp !== this.props.last_run_timestamp || sanitize_html !== this.props.sanitize_html } componentDidMount() { @@ -113,7 +116,7 @@ export let PlutoImage = ({ body, mime }) => { return html`` } -export const OutputBody = ({ mime, body, cell_id, persist_js_state = false, last_run_timestamp }) => { +export const OutputBody = ({ mime, body, cell_id, persist_js_state = false, last_run_timestamp, sanitize_html = true }) => { switch (mime) { case "image/png": case "image/jpg": @@ -130,23 +133,24 @@ export const OutputBody = ({ mime, body, cell_id, persist_js_state = false, last // NOTE: Jupyter doesn't do this, jupyter renders everything directly in pages DOM. // -DRAL if (body.startsWith("` + return sanitize_html ? null : html`<${IframeContainer} body=${body} />` } else { return html`<${RawHTMLContainer} cell_id=${cell_id} body=${body} persist_js_state=${persist_js_state} last_run_timestamp=${last_run_timestamp} + sanitize_html=${sanitize_html} />` } break case "application/vnd.pluto.tree+object": return html`
- <${TreeView} cell_id=${cell_id} body=${body} persist_js_state=${persist_js_state} /> + <${TreeView} cell_id=${cell_id} body=${body} persist_js_state=${persist_js_state} sanitize_html=${sanitize_html} />
` break case "application/vnd.pluto.table+object": - return html`<${TableView} cell_id=${cell_id} body=${body} persist_js_state=${persist_js_state} />` + return html`<${TableView} cell_id=${cell_id} body=${body} persist_js_state=${persist_js_state} sanitize_html=${sanitize_html} />` break case "application/vnd.pluto.parseerror+object": return html`
<${ParseError} cell_id=${cell_id} ...${body} />
` @@ -155,7 +159,7 @@ export const OutputBody = ({ mime, body, cell_id, persist_js_state = false, last return html`
<${ErrorMessage} cell_id=${cell_id} ...${body} />
` break case "application/vnd.pluto.divelement+object": - return DivElement({ cell_id, ...body, persist_js_state }) + return DivElement({ cell_id, ...body, persist_js_state, sanitize_html }) break case "text/plain": if (body) { @@ -177,7 +181,7 @@ export const OutputBody = ({ mime, body, cell_id, persist_js_state = false, last } } -register(OutputBody, "pluto-display", ["mime", "body", "cell_id", "persist_js_state", "last_run_timestamp"]) +register(OutputBody, "pluto-display", ["mime", "body", "cell_id", "persist_js_state", "last_run_timestamp", "sanitize_html"]) let IframeContainer = ({ body }) => { let iframeref = useRef() @@ -469,7 +473,7 @@ let declarative_shadow_dom_polyfill = (template) => { } } -export let RawHTMLContainer = ({ body, className = "", persist_js_state = false, last_run_timestamp }) => { +export let RawHTMLContainer = ({ body, className = "", persist_js_state = false, last_run_timestamp, sanitize_html = true }) => { let pluto_actions = useContext(PlutoActionsContext) let pluto_bonds = useContext(PlutoBondsContext) let js_init_set = useContext(PlutoJSInitializingContext) @@ -481,7 +485,7 @@ export let RawHTMLContainer = ({ body, className = "", persist_js_state = false, useLayoutEffect(() => { if (container_ref.current && pluto_bonds) set_bound_elements_to_their_value(container_ref.current.querySelectorAll("bond"), pluto_bonds) - }, [body, persist_js_state, pluto_actions, pluto_bonds]) + }, [body, persist_js_state, pluto_actions, pluto_bonds, sanitize_html]) useLayoutEffect(() => { const container = container_ref.current @@ -498,8 +502,30 @@ export let RawHTMLContainer = ({ body, className = "", persist_js_state = false, // @ts-ignore dump.append(...container.childNodes) + let html_content_to_set = sanitize_html + ? DOMPurify.sanitize(body, { + FORBID_TAGS: ["style"], + }) + : body + // Actually "load" the html - container.innerHTML = body + container.innerHTML = html_content_to_set + + if (html_content_to_set !== body) { + // DOMPurify also resolves HTML entities, which can give a false positive. To fix this, we use DOMParser to parse both strings, and we compare the innerHTML of the resulting documents. + const parser = new DOMParser() + const p1 = parser.parseFromString(body, "text/html") + const p2 = parser.parseFromString(html_content_to_set, "text/html") + + if (p2.documentElement.innerHTML !== p1.documentElement.innerHTML) { + console.info("HTML sanitized", { body, html_content_to_set }) + let info_element = document.createElement("div") + info_element.innerHTML = SafePreviewSanitizeMessage + container.prepend(info_element) + } + } + + if (sanitize_html) return let scripts_in_shadowroots = Array.from(container.querySelectorAll("template[shadowroot]")).flatMap((template) => { // @ts-ignore @@ -564,7 +590,7 @@ export let RawHTMLContainer = ({ body, className = "", persist_js_state = false, js_init_set?.delete(container) invalidate_scripts.current?.() } - }, [body, persist_js_state, last_run_timestamp, pluto_actions]) + }, [body, persist_js_state, last_run_timestamp, pluto_actions, sanitize_html]) return html`
` } diff --git a/frontend/components/EditOrRunButton.js b/frontend/components/EditOrRunButton.js index aad48acaa5..77632e0ac4 100644 --- a/frontend/components/EditOrRunButton.js +++ b/frontend/components/EditOrRunButton.js @@ -180,10 +180,17 @@ const expected_runtime_str = (/** @type {import("./Editor.js").NotebookData} */ } const sec = _.round(runtime_overhead + ex * runtime_multiplier, -1) + return pretty_long_time(sec) +} + +export const pretty_long_time = (/** @type {number} */ sec) => { + const min = sec / 60 + const sec_r = Math.ceil(sec) + const min_r = Math.round(min) + if (sec < 60) { - return `${Math.ceil(sec)} second${sec > 1 ? "s" : ""}` + return `${sec_r} second${sec_r > 1 ? "s" : ""}` } else { - const min = sec / 60 - return `${Math.ceil(min)} minute${min > 1 ? "s" : ""}` + return `${min_r} minute${min_r > 1 ? "s" : ""}` } } diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index e4e02b697f..d5fd127593 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -35,6 +35,8 @@ import { HijackExternalLinksToOpenInNewTab } from "./HackySideStuff/HijackExtern import { FrontMatterInput } from "./FrontmatterInput.js" import { EditorLaunchBackendButton } from "./Editor/LaunchBackendButton.js" import { get_environment } from "../common/Environment.js" +import { ProcessStatus } from "../common/ProcessStatus.js" +import { SafePreviewUI } from "./SafePreviewUI.js" // This is imported asynchronously - uncomment for development // import environment from "../common/Environment.js" @@ -64,13 +66,6 @@ const Main = ({ children }) => { return html`
${children}
` } -const ProcessStatus = { - ready: "ready", - starting: "starting", - no_process: "no_process", - waiting_to_restart: "waiting_to_restart", -} - /** * Map of status => Bool. In order of decreasing priority. */ @@ -82,11 +77,12 @@ const statusmap = (/** @type {EditorState} */ state, /** @type {LaunchParameters state.backend_launch_phase < BackendLaunchPhase.ready) || state.initializing || state.moving_file, + process_waiting_for_permission: state.notebook.process_status === ProcessStatus.waiting_for_permission && !state.initializing, process_restarting: state.notebook.process_status === ProcessStatus.waiting_to_restart, process_dead: state.notebook.process_status === ProcessStatus.no_process || state.notebook.process_status === ProcessStatus.waiting_to_restart, nbpkg_restart_required: state.notebook.nbpkg?.restart_required_msg != null, nbpkg_restart_recommended: state.notebook.nbpkg?.restart_recommended_msg != null, - nbpkg_disabled: state.notebook.nbpkg?.enabled === false, + nbpkg_disabled: state.notebook.nbpkg?.enabled === false || state.notebook.nbpkg?.waiting_for_permission_but_probably_disabled === true, static_preview: state.static_preview, bonds_disabled: !(state.connected || state.initializing || launch_params.slider_server_url != null), offer_binder: state.backend_launch_phase === BackendLaunchPhase.wait_for_user && launch_params.binder_url != null, @@ -98,6 +94,7 @@ const statusmap = (/** @type {EditorState} */ state, /** @type {LaunchParameters recording_waiting_to_start: state.recording_waiting_to_start, is_recording: state.is_recording, isolated_cell_view: launch_params.isolated_cell_ids != null && launch_params.isolated_cell_ids.length > 0, + sanitize_html: state.notebook.process_status === ProcessStatus.waiting_for_permission, }) const first_true_key = (obj) => { @@ -185,6 +182,8 @@ const first_true_key = (obj) => { * @typedef NotebookPkgData * @type {{ * enabled: boolean, + * waiting_for_permission: boolean?, + * waiting_for_permission_but_probably_disabled: boolean?, * restart_recommended_msg: string?, * restart_required_msg: string?, * installed_versions: { [pkg_name: string]: string }, @@ -256,6 +255,7 @@ export const url_logo_small = document.head.querySelector("link[rel='pluto-logo- * @type {{ * launch_params: LaunchParameters, * initial_notebook_state: NotebookData, + * preamble_element: preact.ReactElement?, * }} */ @@ -591,6 +591,14 @@ export class Editor extends Component { set_and_run_multiple: async (cell_ids) => { // TODO: this function is called with an empty list sometimes, where? if (cell_ids.length > 0) { + window.dispatchEvent( + new CustomEvent("set_waiting_to_run_smart", { + detail: { + cell_ids, + }, + }) + ) + await update_notebook((notebook) => { for (let cell_id of cell_ids) { if (this.state.cell_inputs_local[cell_id]) { @@ -1405,6 +1413,7 @@ patch: ${JSON.stringify( cell_input=${notebook.cell_inputs[cell_id]} cell_result=${this.state.notebook.cell_results[cell_id]} hidden=${!launch_params.isolated_cell_ids?.includes(cell_id)} + sanitize_html=${status.sanitize_html} /> ` )} @@ -1415,19 +1424,31 @@ patch: ${JSON.stringify( ` } - const restart_button = (text) => html` { - this.client.send( + const warn_about_untrusted_code = this.client.session_options?.security?.warn_about_untrusted_code ?? true + + const restart = async (maybe_confirm = false) => { + let source = notebook.metadata?.risky_file_source + if ( + !warn_about_untrusted_code || + !maybe_confirm || + source == null || + confirm(`⚠️ Danger! Are you sure that you trust this file? \n\n${source}\n\nA malicious notebook can steal passwords and data.`) + ) { + await this.actions.update_notebook((notebook) => { + delete notebook.metadata.risky_file_source + }) + await this.client.send( "restart_process", {}, { notebook_id: notebook.notebook_id, } ) - }} - >${text}` + } + } + + const restart_button = (text, maybe_confirm = false) => + html` restart(maybe_confirm)}>${text}` return html` ${this.state.disable_ui === false && html`<${HijackExternalLinksToOpenInNewTab} />`} @@ -1481,7 +1502,7 @@ patch: ${JSON.stringify( on_submit=${this.submit_file_change} on_desktop_submit=${this.desktop_submit_file_change} suggest_new_file=${{ - base: this.client.session_options == null ? "" : this.client.session_options.server.notebook_path_suggestion, + base: this.client.session_options?.server?.notebook_path_suggestion ?? "", name: notebook.shortpath, }} placeholder="Save notebook..." @@ -1507,11 +1528,20 @@ patch: ${JSON.stringify( ? "Process exited — restarting..." : statusval === "process_dead" ? html`${"Process exited — "}${restart_button("restart")}` + : statusval === "process_waiting_for_permission" + ? html`${restart_button("Run notebook code", true)}` : null } + <${SafePreviewUI} + process_waiting_for_permission=${status.process_waiting_for_permission} + risky_file_source=${notebook.metadata?.risky_file_source} + restart=${restart} + warn_about_untrusted_code=${warn_about_untrusted_code} + /> + <${RecordingUI} notebook_name=${notebook.shortpath} recording_waiting_to_start=${this.state.recording_waiting_to_start} @@ -1539,7 +1569,7 @@ patch: ${JSON.stringify( nb.metadata["frontmatter"] = newval })} /> - ${launch_params.preamble_html ? html`<${RawHTMLContainer} body=${launch_params.preamble_html} className=${"preamble"} />` : null} + ${this.props.preamble_element} <${Main}> <${Preamble} last_update_time=${this.state.last_update_time} @@ -1555,6 +1585,8 @@ patch: ${JSON.stringify( selected_cells=${this.state.selected_cells} is_initializing=${this.state.initializing} is_process_ready=${this.is_process_ready()} + process_waiting_for_permission=${status.process_waiting_for_permission} + sanitize_html=${status.sanitize_html} /> <${DropRuler} actions=${this.actions} diff --git a/frontend/components/Logs.js b/frontend/components/Logs.js index 950b909680..529d55bb7a 100644 --- a/frontend/components/Logs.js +++ b/frontend/components/Logs.js @@ -20,7 +20,7 @@ const is_stdout_log = (log) => { return log.level == STDOUT_LOG_LEVEL } -export const Logs = ({ logs, line_heights, set_cm_highlighted_line }) => { +export const Logs = ({ logs, line_heights, set_cm_highlighted_line, sanitize_html }) => { const progress_logs = logs.filter(is_progress_log) const latest_progress_logs = progress_logs.reduce((progress_logs, log) => ({ ...progress_logs, [log.id]: log }), {}) const stdout_log = logs.reduce((stdout_log, log) => { @@ -63,6 +63,7 @@ export const Logs = ({ logs, line_heights, set_cm_highlighted_line }) => { level=${log.level} msg=${log.msg} kwargs=${log.kwargs} + sanitize_html=${sanitize_html} key=${i} y=${is_hidden_input ? 0 : log.line - 1} /> ` @@ -97,9 +98,7 @@ const Progress = ({ progress }) => { return html`${Math.ceil(100 * progress)}%` } -const mimepair_output = (pair) => html`<${SimpleOutputBody} cell_id=${"adsf"} mime=${pair[1]} body=${pair[0]} persist_js_state=${false} />` - -const Dot = ({ set_cm_highlighted_line, msg, kwargs, y, level }) => { +const Dot = ({ set_cm_highlighted_line, msg, kwargs, y, level, sanitize_html }) => { const is_progress = is_progress_log({ level, kwargs }) const is_stdout = level === STDOUT_LOG_LEVEL let progress = null @@ -119,6 +118,9 @@ const Dot = ({ set_cm_highlighted_line, msg, kwargs, y, level }) => { level = "Stdout" } + const mimepair_output = (pair) => + html`<${SimpleOutputBody} cell_id=${"adsf"} mime=${pair[1]} body=${pair[0]} persist_js_state=${false} sanitize_html=${sanitize_html} />` + return html` is_progress || set_cm_highlighted_line(y + 1)} diff --git a/frontend/components/Notebook.js b/frontend/components/Notebook.js index c5d8d9d736..6158966bba 100644 --- a/frontend/components/Notebook.js +++ b/frontend/components/Notebook.js @@ -35,6 +35,8 @@ let CellMemo = ({ force_hide_input, is_process_ready, disable_input, + sanitize_html = true, + process_waiting_for_permission, show_logs, set_show_logs, nbpkg, @@ -56,6 +58,8 @@ let CellMemo = ({ focus_after_creation=${focus_after_creation} is_process_ready=${is_process_ready} disable_input=${disable_input} + process_waiting_for_permission=${process_waiting_for_permission} + sanitize_html=${sanitize_html} nbpkg=${nbpkg} global_definition_locations=${global_definition_locations} /> @@ -87,6 +91,8 @@ let CellMemo = ({ focus_after_creation, is_process_ready, disable_input, + process_waiting_for_permission, + sanitize_html, ...nbpkg_fingerprint(nbpkg), global_definition_locations, ]) @@ -114,9 +120,21 @@ const render_cell_outputs_minimum = 20 * is_initializing: boolean, * is_process_ready: boolean, * disable_input: boolean, + * process_waiting_for_permission: boolean, + * sanitize_html: boolean, * }} props * */ -export const Notebook = ({ notebook, cell_inputs_local, last_created_cell, selected_cells, is_initializing, is_process_ready, disable_input }) => { +export const Notebook = ({ + notebook, + cell_inputs_local, + last_created_cell, + selected_cells, + is_initializing, + is_process_ready, + disable_input, + process_waiting_for_permission, + sanitize_html = true, +}) => { let pluto_actions = useContext(PlutoActionsContext) // Add new cell when the last cell gets deleted @@ -170,6 +188,8 @@ export const Notebook = ({ notebook, cell_inputs_local, last_created_cell, selec force_hide_input=${false} is_process_ready=${is_process_ready} disable_input=${disable_input} + process_waiting_for_permission=${process_waiting_for_permission} + sanitize_html=${sanitize_html} nbpkg=${notebook.nbpkg} global_definition_locations=${global_definition_locations} />` diff --git a/frontend/components/PkgStatusMark.js b/frontend/components/PkgStatusMark.js index 3083b42839..4779948a58 100644 --- a/frontend/components/PkgStatusMark.js +++ b/frontend/components/PkgStatusMark.js @@ -42,7 +42,8 @@ export const package_status = ({ nbpkg, package_name, available_versions, is_dis let hint = html`error` let offer_update = false const chosen_version = nbpkg?.installed_versions[package_name] ?? null - const busy = (nbpkg?.busy_packages ?? []).includes(package_name) || !(nbpkg?.instantiated ?? true) + const nbpkg_waiting_for_permission = nbpkg?.waiting_for_permission ?? false + const busy = !nbpkg_waiting_for_permission && ((nbpkg?.busy_packages ?? []).includes(package_name) || !(nbpkg?.instantiated ?? true)) if (is_disable_pkg) { const f_name = package_name @@ -55,7 +56,12 @@ export const package_status = ({ nbpkg, package_name, available_versions, is_dis hint_raw = `${package_name} is part of Julia's pre-installed 'standard library'.` hint = html`${package_name} is part of Julia's pre-installed standard library.` } else { - if (busy) { + if (nbpkg_waiting_for_permission) { + status = "will_be_installed" + hint_raw = `${package_name} (v${_.last(available_versions)}) will be installed when you run this notebook.` + hint = html`
${package_name} v${_.last(available_versions)}
+ will be installed when you run this notebook.` + } else if (busy) { status = "busy" hint_raw = `${package_name} (v${chosen_version}) is installing...` hint = html`
${package_name} v${chosen_version}
@@ -88,6 +94,7 @@ export const package_status = ({ nbpkg, package_name, available_versions, is_dis } /** + * The little icon that appears inline next to a package import in code (e.g. `using PlutoUI ✅`) * @param {{ * package_name: string, * pluto_actions: any, diff --git a/frontend/components/Popup.js b/frontend/components/Popup.js index 27013725a9..f3b78f6918 100644 --- a/frontend/components/Popup.js +++ b/frontend/components/Popup.js @@ -7,7 +7,9 @@ import { RawHTMLContainer, highlight } from "./CellOutput.js" import { PlutoActionsContext } from "../common/PlutoContext.js" import { package_status, nbpkg_fingerprint_without_terminal } from "./PkgStatusMark.js" import { PkgTerminalView } from "./PkgTerminalView.js" -import { useDebouncedTruth } from "./RunArea.js" +import { prettytime, useDebouncedTruth } from "./RunArea.js" +import { time_estimate, usePackageTimingData } from "../common/InstallTimeEstimate.js" +import { pretty_long_time } from "./EditOrRunButton.js" // This funny thing is a way to tell parcel to bundle these files.. // Eventually I'll write a plugin that is able to parse html`...`, but this is it for now. @@ -29,7 +31,7 @@ export const help_circle_icon = new URL("https://cdn.jsdelivr.net/gh/ionic-team/ * @typedef MiscPopupDetails * @property {"info" | "warn"} type * @property {import("../imports/Preact.js").ReactElement} body - * @property {HTMLElement} [source_element] + * @property {HTMLElement?} [source_element] * @property {Boolean} [big] */ @@ -174,6 +176,11 @@ const PkgPopup = ({ notebook, recent_event, clear_recent_event, disable_input }) const showupdate = pkg_status?.offer_update ?? false + const timingdata = usePackageTimingData() + const estimate = timingdata == null || recent_event?.package_name == null ? null : time_estimate(timingdata, [recent_event?.package_name]) + const total_time = estimate == null ? 0 : estimate.install + estimate.load + estimate.precompile + const total_second_time = estimate == null ? 0 : estimate.load + //
${recent_event?.package_name}
return html` ${pkg_status?.hint ?? "Loading..."} + ${(pkg_status?.status === "will_be_installed" || pkg_status?.status === "busy") && total_time > 10 + ? html`
+ Installation can take ${pretty_long_time(total_time)}${`. `}
${`Afterwards, it loads in `} + ${pretty_long_time(total_second_time)}. +
` + : null}
{ if (busy) { diff --git a/frontend/components/RecordingUI.js b/frontend/components/RecordingUI.js index 2d773f7581..ebd170f7ba 100644 --- a/frontend/components/RecordingUI.js +++ b/frontend/components/RecordingUI.js @@ -180,7 +180,7 @@ export const RecordingUI = ({ notebook_name, is_recording, recording_waiting_to_ return html`
${recording_waiting_to_start - ? html`
+ ? html`
` : is_recording - ? html`
+ ? html`` + : null} + ` +} + +export const SafePreviewOutput = () => { + return html`
+ ${`Code not executed in `}Safe preview +
` +} + +export const SafePreviewSanitizeMessage = `
+${`Scripts and styles not rendered in `}Safe preview +
` diff --git a/frontend/components/TreeView.js b/frontend/components/TreeView.js index 32e8de53c9..48ae5ddedd 100644 --- a/frontend/components/TreeView.js +++ b/frontend/components/TreeView.js @@ -11,7 +11,7 @@ import { PlutoActionsContext } from "../common/PlutoContext.js" // We use a `
${body}` instead of `
${body}`, also for some CSS reasons that I forgot
 //
 // TODO: remove this, use OutputBody instead (maybe add a `wrap_in_div` option), and fix the CSS classes so that i all looks nice again
-export const SimpleOutputBody = ({ mime, body, cell_id, persist_js_state }) => {
+export const SimpleOutputBody = ({ mime, body, cell_id, persist_js_state, sanitize_html = true }) => {
     switch (mime) {
         case "image/png":
         case "image/jpg":
@@ -27,7 +27,7 @@ export const SimpleOutputBody = ({ mime, body, cell_id, persist_js_state }) => {
             return html`<${TreeView} cell_id=${cell_id} body=${body} persist_js_state=${persist_js_state} />`
             break
         default:
-            return OutputBody({ mime, body, cell_id, persist_js_state, last_run_timestamp: null })
+            return OutputBody({ mime, body, cell_id, persist_js_state, sanitize_html, last_run_timestamp: null })
             break
     }
 }
@@ -56,7 +56,7 @@ const actions_show_more = ({ pluto_actions, cell_id, node_ref, objectid, dim })
     actions.reshow_cell(cell_id ?? node_ref.current.closest("pluto-cell").id, objectid, dim)
 }
 
-export const TreeView = ({ mime, body, cell_id, persist_js_state }) => {
+export const TreeView = ({ mime, body, cell_id, persist_js_state, sanitize_html = true }) => {
     let pluto_actions = useContext(PlutoActionsContext)
     const node_ref = useRef(/** @type {HTMLElement?} */ (null))
     const onclick = (e) => {
@@ -87,7 +87,8 @@ export const TreeView = ({ mime, body, cell_id, persist_js_state }) => {
         })
     }
 
-    const mimepair_output = (pair) => html`<${SimpleOutputBody} cell_id=${cell_id} mime=${pair[1]} body=${pair[0]} persist_js_state=${persist_js_state} />`
+    const mimepair_output = (pair) =>
+        html`<${SimpleOutputBody} cell_id=${cell_id} mime=${pair[1]} body=${pair[0]} persist_js_state=${persist_js_state} sanitize_html=${sanitize_html} />`
     const more = html`<${More} on_click_more=${on_click_more} />`
 
     let inner = null
diff --git a/frontend/components/welcome/Open.js b/frontend/components/welcome/Open.js
index 70aac04696..3a0dc213c1 100644
--- a/frontend/components/welcome/Open.js
+++ b/frontend/components/welcome/Open.js
@@ -17,15 +17,8 @@ import { guess_notebook_location } from "../../common/NotebookLocationFromURL.js
 export const Open = ({ client, connected, CustomPicker, show_samples, on_start_navigation }) => {
     const on_open_path = async (new_path) => {
         const processed = await guess_notebook_location(new_path)
-        if (processed.type === "path") {
-            on_start_navigation(processed.path_or_url)
-            window.location.href = link_open_path(processed.path_or_url)
-        } else {
-            if (confirm("Are you sure? This will download and run the file at\n\n" + processed.path_or_url)) {
-                on_start_navigation(processed.path_or_url)
-                window.location.href = link_open_url(processed.path_or_url)
-            }
-        }
+        on_start_navigation(processed.path_or_url)
+        window.location.href = (processed.type === "path" ? link_open_path : link_open_url)(processed.path_or_url)
     }
 
     const desktop_on_open_path = async (_p) => {
@@ -65,7 +58,6 @@ export const Open = ({ client, connected, CustomPicker, show_samples, on_start_n
         
` } -// /open will execute a script from your hard drive, so we include a token in the URL to prevent a mean person from getting a bad file on your computer _using another hypothetical intrusion_, and executing it using Pluto -export const link_open_path = (path) => "open?" + new URLSearchParams({ path: path }).toString() +export const link_open_path = (path, execution_allowed = false) => "open?" + new URLSearchParams({ path: path }).toString() export const link_open_url = (url) => "open?" + new URLSearchParams({ url: url }).toString() export const link_edit = (notebook_id) => "edit?id=" + notebook_id diff --git a/frontend/components/welcome/Recent.js b/frontend/components/welcome/Recent.js index ac81a507c6..2fbc72d5d4 100644 --- a/frontend/components/welcome/Recent.js +++ b/frontend/components/welcome/Recent.js @@ -4,30 +4,41 @@ import * as preact from "../../imports/Preact.js" import { cl } from "../../common/ClassTable.js" import { link_edit, link_open_path } from "./Open.js" +import { ProcessStatus } from "../../common/ProcessStatus.js" /** * @typedef CombinedNotebook * @type {{ - * path: String, + * path: string, * transitioning: Boolean, - * notebook_id: String?, + * entry?: import("./Welcome.js").NotebookListEntry, * }} */ /** - * * @param {string} path - * @param {string?} notebook_id * @returns {CombinedNotebook} */ -const entry = (path, notebook_id = null) => { +const entry_notrunning = (path) => { return { transitioning: false, // between running and being shut down - notebook_id: notebook_id, // null means that it is not running + entry: undefined, // undefined means that it is not running path: path, } } +/** + * @param {import("./Welcome.js").NotebookListEntry} entry + * @returns {CombinedNotebook} + */ +const entry_running = (entry) => { + return { + transitioning: false, // between running and being shut down + entry, + path: entry.path, + } +} + const split_at_level = (path, level) => path.split(/\/|\\/).slice(-level).join("/") const shortest_path = (path, allpaths) => { @@ -68,7 +79,7 @@ export const Recent = ({ client, connected, remote_notebooks, CustomRecent, on_s useEffect(() => { if (client != null && connected) { client.send("get_all_notebooks", {}, {}).then(({ message }) => { - const running = /** @type {Array} */ (message.notebooks).map((nb) => entry(nb.path, nb.notebook_id)) + const running = /** @type {Array} */ (message.notebooks).map((nb) => entry_running(nb)) const recent_notebooks = get_stored_recent_notebooks() @@ -100,48 +111,48 @@ export const Recent = ({ client, connected, remote_notebooks, CustomRecent, on_s // try to find a matching notebook in the remote list let running_version = null - if (nb.notebook_id) { + if (nb.entry != null) { // match notebook_ids to handle a path change - running_version = new_running.find((rnb) => rnb.notebook_id == nb.notebook_id) + running_version = new_running.find((rnb) => rnb.notebook_id === nb.entry?.notebook_id) } else { // match paths to handle a notebook bootup - running_version = new_running.find((rnb) => rnb.path == nb.path) + running_version = new_running.find((rnb) => rnb.path === nb.path) } if (running_version == null) { - return entry(nb.path) + return entry_notrunning(nb.path) } else { - const new_notebook = entry(running_version.path, running_version.notebook_id) + const new_notebook = entry_running(running_version) rendered_and_running.push(running_version) return new_notebook } }) - const not_rendered_but_running = new_running.filter((rnb) => !rendered_and_running.includes(rnb)).map((rnb) => entry(rnb.path, rnb.notebook_id)) + const not_rendered_but_running = new_running.filter((rnb) => !rendered_and_running.includes(rnb)).map(entry_running) set_combined_notebooks([...not_rendered_but_running, ...new_combined_notebooks]) } }, [remote_notebooks]) - const on_session_click = (nb) => { + const on_session_click = (/** @type {CombinedNotebook} */ nb) => { if (nb.transitioning) { return } - const running = nb.notebook_id != null + const running = nb.entry != null if (running) { if (client == null) return - if (confirm("Shut down notebook process?")) { + if (confirm(nb.entry?.process_status === ProcessStatus.waiting_for_permission ? "Close notebook session?" : "Shut down notebook process?")) { set_notebook_state(nb.path, { running: false, transitioning: true, }) - client.send("shutdown_notebook", { keep_in_session: false }, { notebook_id: nb.notebook_id }, false) + client.send("shutdown_notebook", { keep_in_session: false }, { notebook_id: nb.entry?.notebook_id }, false) } } else { set_notebook_state(nb.path, { transitioning: true, }) - fetch(link_open_path(nb.path), { + fetch(link_open_path(nb.path) + "&execution_allowed=true", { method: "GET", }) .then((r) => { @@ -172,7 +183,7 @@ export const Recent = ({ client, connected, remote_notebooks, CustomRecent, on_s combined_notebooks == null ? html`
  • Loading...
  • ` : combined_notebooks.map((nb) => { - const running = nb.notebook_id != null + const running = nb.entry != null return html`
  • - { if (!running) { @@ -225,5 +243,5 @@ const get_stored_recent_notebooks = () => { const storedString = localStorage.getItem("recent notebooks") const storedData = storedString != null ? JSON.parse(storedString) : [] const storedList = storedData instanceof Array ? storedData : [] - return storedList.map((path) => entry(path)) + return storedList.map(entry_notrunning) } diff --git a/frontend/components/welcome/Welcome.js b/frontend/components/welcome/Welcome.js index aca858f838..f836db7cd7 100644 --- a/frontend/components/welcome/Welcome.js +++ b/frontend/components/welcome/Welcome.js @@ -20,6 +20,7 @@ import default_featured_sources from "../../featured_sources.js" * path: string, * in_temp_dir: boolean, * shortpath: string, + * process_status: string, * }} */ diff --git a/frontend/dark_color.css b/frontend/dark_color.css index 3249d19803..4a676bab4a 100644 --- a/frontend/dark_color.css +++ b/frontend/dark_color.css @@ -96,6 +96,7 @@ /*header*/ --restart-recc-header-color: rgb(44 106 157 / 56%); + --restart-recc-accent-color: rgb(44 106 157); --restart-req-header-color: rgb(145 66 60 / 56%); --dead-process-header-color: rgba(250, 75, 21, 0.473); --loading-header-color: hsl(0deg 0% 20% / 50%); diff --git a/frontend/editor.css b/frontend/editor.css index 0ca807c9f4..e349ee748e 100644 --- a/frontend/editor.css +++ b/frontend/editor.css @@ -798,6 +798,7 @@ body.binder header#pluto-nav > nav#at_the_top > pluto-filepicker > a { body.nbpkg_restart_recommended header#pluto-nav, body.nbpkg_restart_required header#pluto-nav, body.binder.loading header#pluto-nav, +body.process_waiting_for_permission header#pluto-nav, body.process_dead header#pluto-nav, body.disconnected header#pluto-nav { position: sticky; @@ -815,6 +816,9 @@ body.nbpkg_restart_required header#pluto-nav { body.process_dead header#pluto-nav { background-color: var(--dead-process-header-color); } +body.process_waiting_for_permission header#pluto-nav { + background-color: var(--restart-recc-header-color); +} body.loading header#pluto-nav { background-color: var(--loading-header-color); } @@ -879,7 +883,7 @@ body.binder.loading #binder_spinners { } .outline-frame { - z-index: 9990; + z-index: 1500; pointer-events: none; position: fixed; top: 0px; @@ -890,6 +894,10 @@ body.binder.loading #binder_spinners { box-sizing: border-box; } +body.process_waiting_for_permission .outline-frame.safe-preview { + border-bottom: 12px solid var(--restart-recc-header-color); +} + body.recording_waiting_to_start .outline-frame.recording { border: 12px solid #be6f6fba; } @@ -910,24 +918,29 @@ body.is_recording header#pluto-nav { display: none; } -#record-ui-container { +.outline-frame-actions-container { position: fixed; top: 3px; - z-index: 9999; + z-index: 1501; display: flex; flex-direction: row; flex-wrap: wrap; } -#record-ui-container > .overlay-button { +.outline-frame-actions-container.safe-preview { + top: auto; + bottom: 4px; +} + +.outline-frame-actions-container > .overlay-button { /* background: pink; */ border-color: #e86f6c; margin: 0 3px; } -#record-ui-container > .overlay-button.record-no-audio { +.outline-frame-actions-container > .overlay-button.record-no-audio { border-color: #dcc6c6; } -#record-ui-container > .overlay-button.playback { +.outline-frame-actions-container > .overlay-button.playback { border-color: #c6c6dc; } span.pluto-icon.stop-recording-icon::after { @@ -939,6 +952,9 @@ span.pluto-icon.microphone-icon::after { span.pluto-icon.info-icon::after { background-image: url("https://cdn.jsdelivr.net/gh/ionic-team/ionicons@5.5.1/src/svg/information-circle-outline.svg"); } +span.pluto-icon.offline-icon::after { + background-image: url("https://cdn.jsdelivr.net/gh/ionic-team/ionicons@5.5.1/src/svg/cloud-offline-outline.svg"); +} span.pluto-icon.mute-icon::after { background-image: url("https://cdn.jsdelivr.net/gh/ionic-team/ionicons@5.5.1/src/svg/mic-off-outline.svg"); } @@ -950,12 +966,46 @@ div.recording-playback { width: min(500px, 90vw); position: fixed; bottom: 16px; - z-index: 9999; + z-index: 1501; } div.recording-playback audio { width: 100%; } +.safe-preview-info { + color: var(--black); + font-family: var(--system-ui-font-stack); + font-weight: 700; + background: var(--white); + border: 3px solid var(--restart-recc-accent-color); + padding: 0.3em 0.8em; + border-radius: 0.8em; +} +.safe-preview-info > span { + display: flex; + /* flex-direction: row; */ +} +.safe-preview-info button { + background: none; + border: none; + cursor: pointer; +} + +.safe-preview-output { + color: var(--helpbox-header-color); + font-family: var(--system-ui-font-stack); + font-weight: 700; + opacity: 0.5; + font-size: 0.8rem; + padding: 0.2em 0.4em; + background: var(--restart-recc-header-color); + border-radius: 0.4em; + display: inline-flex; + margin: 0.7em 0; + gap: 0.3em; + align-items: baseline; +} + /* PREAMBLE */ .raw-html-wrapper.preamble { @@ -1261,6 +1311,9 @@ pluto-input .cm-editor .cm-line.cm-highlighted-line { pluto-cell:not(.show_input) > pluto-input { display: none; } +pluto-cell.code_folded.show_input > pluto-input:not(:focus-within) { + opacity: 0.4; +} pluto-cell.code_differs > pluto-input > .cm-editor { border: 1px solid var(--code-differs-cell-color); @@ -1835,6 +1888,8 @@ pluto-popup > * { /* Slightly changes the layout of the three pkg buttons... in just the way that we want it! */ position: absolute; max-width: 100%; + max-height: 80vh; + overflow-y: auto; } pluto-popup > div > *:first-child { @@ -1902,6 +1957,14 @@ pkg-popup .toggle-terminal { right: 20px; } +.pkg-time-estimate { + font-size: 0.8em; + margin: 0.5em 0em; + padding: 0.5em 0.5em; + background: var(--pluto-logs-info-color); + border-radius: 0.5em; +} + pkg-terminal { display: block; /* width: 20rem; */ diff --git a/frontend/editor.js b/frontend/editor.js index c74face3df..6fb9826699 100644 --- a/frontend/editor.js +++ b/frontend/editor.js @@ -5,6 +5,7 @@ import { Editor, default_path } from "./components/Editor.js" import { FetchProgress, read_Uint8Array_with_progress } from "./components/FetchProgress.js" import { unpack } from "./common/MsgPack.js" import { RawHTMLContainer } from "./components/CellOutput.js" +import { ProcessStatus } from "./common/ProcessStatus.js" const url_params = new URLSearchParams(window.location.search) @@ -73,6 +74,8 @@ const from_attribute = (element, name) => { } } +const preamble_html_comes_from_url_params = url_params.has("preamble_url") + /** * * @returns {import("./components/Editor.js").NotebookData} @@ -83,7 +86,7 @@ export const empty_notebook_state = ({ notebook_id }) => ({ path: default_path, shortpath: "", in_temp_dir: true, - process_status: "starting", + process_status: ProcessStatus.starting, last_save_time: 0.0, last_hot_reload_time: 0.0, cell_inputs: {}, @@ -153,7 +156,9 @@ const EditorLoader = ({ launch_params }) => { set_disable_ui_css(launch_params.disable_ui) }, [launch_params.disable_ui]) - const preamble_element = launch_params.preamble_html ? html`<${RawHTMLContainer} body=${launch_params.preamble_html} className=${"preamble"} />` : null + const preamble_element = launch_params.preamble_html + ? html`<${RawHTMLContainer} body=${launch_params.preamble_html} className=${"preamble"} sanitize_html=${preamble_html_comes_from_url_params} />` + : null return ready_for_editor ? html`<${Editor} initial_notebook_state=${initial_notebook_state_ref.current} launch_params=${launch_params} preamble_element=${preamble_element} />` diff --git a/frontend/imports/DOMPurify.d.ts b/frontend/imports/DOMPurify.d.ts new file mode 100644 index 0000000000..ff1c9b5cf6 --- /dev/null +++ b/frontend/imports/DOMPurify.d.ts @@ -0,0 +1,148 @@ +// Type definitions for DOM Purify 3.0 +// Project: https://github.com/cure53/DOMPurify +// Definitions by: Dave Taylor https://github.com/davetayls +// Samira Bazuzi +// FlowCrypt +// Exigerr +// Piotr Błażejewicz +// Nicholas Ellul +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped +// Minimum TypeScript Version: 4.5 + + +export as namespace DOMPurify; +export = DOMPurify; + +declare const DOMPurify: createDOMPurifyI; + +type WindowLike = Pick< + typeof globalThis, + | 'NodeFilter' + | 'Node' + | 'Element' + | 'HTMLTemplateElement' + | 'DocumentFragment' + | 'HTMLFormElement' + | 'DOMParser' + | 'NamedNodeMap' +>; + +interface createDOMPurifyI extends DOMPurify.DOMPurifyI { + (window?: Window | WindowLike): DOMPurify.DOMPurifyI; +} + +declare namespace DOMPurify { + interface DOMPurifyI { + sanitize(source: string | Node): string; + // sanitize(source: string | Node, config: Config & { RETURN_TRUSTED_TYPE: true }): TrustedHTML; + sanitize( + source: string | Node, + config: Config & { RETURN_DOM_FRAGMENT?: false | undefined; RETURN_DOM?: false | undefined }, + ): string; + sanitize(source: string | Node, config: Config & { RETURN_DOM_FRAGMENT: true }): DocumentFragment; + sanitize(source: string | Node, config: Config & { RETURN_DOM: true }): HTMLElement; + sanitize(source: string | Node, config: Config): string | HTMLElement | DocumentFragment; + + addHook( + hook: 'uponSanitizeElement', + cb: (currentNode: Element, data: SanitizeElementHookEvent, config: Config) => void, + ): void; + addHook( + hook: 'uponSanitizeAttribute', + cb: (currentNode: Element, data: SanitizeAttributeHookEvent, config: Config) => void, + ): void; + addHook(hook: HookName, cb: (currentNode: Element, data: HookEvent, config: Config) => void): void; + + setConfig(cfg: Config): void; + clearConfig(): void; + isValidAttribute(tag: string, attr: string, value: string): boolean; + + removeHook(entryPoint: HookName): void; + removeHooks(entryPoint: HookName): void; + removeAllHooks(): void; + + version: string; + removed: any[]; + isSupported: boolean; + } + + interface Config { + ADD_ATTR?: string[] | undefined; + ADD_DATA_URI_TAGS?: string[] | undefined; + ADD_TAGS?: string[] | undefined; + ADD_URI_SAFE_ATTR?: string[] | undefined; + ALLOW_ARIA_ATTR?: boolean | undefined; + ALLOW_DATA_ATTR?: boolean | undefined; + ALLOW_UNKNOWN_PROTOCOLS?: boolean | undefined; + ALLOW_SELF_CLOSE_IN_ATTR?: boolean | undefined; + ALLOWED_ATTR?: string[] | undefined; + ALLOWED_TAGS?: string[] | undefined; + ALLOWED_NAMESPACES?: string[] | undefined; + ALLOWED_URI_REGEXP?: RegExp | undefined; + FORBID_ATTR?: string[] | undefined; + FORBID_CONTENTS?: string[] | undefined; + FORBID_TAGS?: string[] | undefined; + FORCE_BODY?: boolean | undefined; + IN_PLACE?: boolean | undefined; + KEEP_CONTENT?: boolean | undefined; + /** + * change the default namespace from HTML to something different + */ + NAMESPACE?: string | undefined; + PARSER_MEDIA_TYPE?: string | undefined; + RETURN_DOM_FRAGMENT?: boolean | undefined; + /** + * This defaults to `true` starting DOMPurify 2.2.0. Note that setting it to `false` + * might cause XSS from attacks hidden in closed shadowroots in case the browser + * supports Declarative Shadow: DOM https://web.dev/declarative-shadow-dom/ + */ + RETURN_DOM_IMPORT?: boolean | undefined; + RETURN_DOM?: boolean | undefined; + RETURN_TRUSTED_TYPE?: boolean | undefined; + SAFE_FOR_TEMPLATES?: boolean | undefined; + SANITIZE_DOM?: boolean | undefined; + /** @default false */ + SANITIZE_NAMED_PROPS?: boolean | undefined; + USE_PROFILES?: + | false + | { + mathMl?: boolean | undefined; + svg?: boolean | undefined; + svgFilters?: boolean | undefined; + html?: boolean | undefined; + } + | undefined; + WHOLE_DOCUMENT?: boolean | undefined; + CUSTOM_ELEMENT_HANDLING?: { + tagNameCheck?: RegExp | ((tagName: string) => boolean) | null | undefined; + attributeNameCheck?: RegExp | ((lcName: string) => boolean) | null | undefined; + allowCustomizedBuiltInElements?: boolean | undefined; + }; + } + + type HookName = + | 'beforeSanitizeElements' + | 'uponSanitizeElement' + | 'afterSanitizeElements' + | 'beforeSanitizeAttributes' + | 'uponSanitizeAttribute' + | 'afterSanitizeAttributes' + | 'beforeSanitizeShadowDOM' + | 'uponSanitizeShadowNode' + | 'afterSanitizeShadowDOM'; + + type HookEvent = SanitizeElementHookEvent | SanitizeAttributeHookEvent | null; + + interface SanitizeElementHookEvent { + tagName: string; + allowedTags: { [key: string]: boolean }; + } + + interface SanitizeAttributeHookEvent { + attrName: string; + attrValue: string; + keepAttr: boolean; + allowedAttributes: { [key: string]: boolean }; + forceKeepAttr?: boolean | undefined; + } +} \ No newline at end of file diff --git a/frontend/imports/DOMPurify.js b/frontend/imports/DOMPurify.js new file mode 100644 index 0000000000..f49c6bfe36 --- /dev/null +++ b/frontend/imports/DOMPurify.js @@ -0,0 +1,4 @@ +// @ts-ignore +import purify from "https://esm.sh/dompurify@3.0.3?pin=v122" + +export default purify diff --git a/frontend/light_color.css b/frontend/light_color.css index 0f7e2ce49f..0dd3749c1d 100644 --- a/frontend/light_color.css +++ b/frontend/light_color.css @@ -99,6 +99,7 @@ /*header*/ --restart-recc-header-color: rgba(114, 192, 255, 0.56); + --restart-recc-accent-color: rgba(114, 192, 255); --restart-req-header-color: rgba(170, 41, 32, 0.56); --dead-process-header-color: rgb(230 88 46 / 38%); --loading-header-color: hsla(290, 10%, 80%, 0.5); diff --git a/src/Configuration.jl b/src/Configuration.jl index b99c429723..9fd22cbb58 100644 --- a/src/Configuration.jl +++ b/src/Configuration.jl @@ -109,6 +109,7 @@ end const REQUIRE_SECRET_FOR_OPEN_LINKS_DEFAULT = true const REQUIRE_SECRET_FOR_ACCESS_DEFAULT = true +const WARN_ABOUT_UNTRUSTED_CODE_DEFAULT = true """ SecurityOptions([; kwargs...]) @@ -125,10 +126,14 @@ Security settings for the HTTP server. - `require_secret_for_access::Bool = $REQUIRE_SECRET_FOR_ACCESS_DEFAULT` - If false, you do not need to use a `secret` in the URL to access Pluto: you will be authenticated by visiting `http://localhost:1234/` in your browser. An authentication cookie is still used for access (to prevent XSS and deceptive links or an img src to `http://localhost:1234/open?url=badpeople.org/script.jl`), and is set automatically, but this request to `/` is protected by cross-origin policy. + If `false`, you do not need to use a `secret` in the URL to access Pluto: you will be authenticated by visiting `http://localhost:1234/` in your browser. An authentication cookie is still used for access (to prevent XSS and deceptive links or an img src to `http://localhost:1234/open?url=badpeople.org/script.jl`), and is set automatically, but this request to `/` is protected by cross-origin policy. Use `true` on a computer used by multiple people simultaneously. Only use `false` if necessary. +- `warn_about_untrusted_code::Bool = $WARN_ABOUT_UNTRUSTED_CODE_DEFAULT` + + Should the Pluto GUI show warning messages about executing code from an unknown source, e.g. when opening a notebook from a URL? When `false`, notebooks will still open in Safe mode, but there is no scary message when you run it. + **Leave these options on `true` for the most secure setup.** Note that Pluto is quickly evolving software, maintained by designers, educators and enthusiasts — not security experts. If security is a serious concern for your application, then we recommend running Pluto inside a container and verifying the relevant security aspects of Pluto yourself. @@ -136,6 +141,7 @@ Note that Pluto is quickly evolving software, maintained by designers, educators @option mutable struct SecurityOptions require_secret_for_open_links::Bool = REQUIRE_SECRET_FOR_OPEN_LINKS_DEFAULT require_secret_for_access::Bool = REQUIRE_SECRET_FOR_ACCESS_DEFAULT + warn_about_untrusted_code::Bool = WARN_ABOUT_UNTRUSTED_CODE_DEFAULT end const RUN_NOTEBOOK_ON_LOAD_DEFAULT = true @@ -151,7 +157,7 @@ const WORKSPACE_CUSTOM_STARTUP_EXPR_DEFAULT = nothing Options to change Pluto's evaluation behaviour during internal testing and by downstream packages. These options are not intended to be changed during normal use. -- `run_notebook_on_load::Bool = $RUN_NOTEBOOK_ON_LOAD_DEFAULT` Whether to evaluate a notebook on load. +- `run_notebook_on_load::Bool = $RUN_NOTEBOOK_ON_LOAD_DEFAULT` When running a notebook (not in Safe mode), should all cells evaluate immediately? Warning: this is only for internal testing, and using it will lead to unexpected behaviour and hard-to-reproduce notebooks. It's not the Pluto way! - `workspace_use_distributed::Bool = $WORKSPACE_USE_DISTRIBUTED_DEFAULT` Whether to start notebooks in a separate process. - `workspace_use_distributed_stdlib::Bool? = $WORKSPACE_USE_DISTRIBUTED_STDLIB_DEFAULT` Should we use the Distributed stdlib to run processes? Distributed will be replaced by Malt.jl, you can use this option to already get the old behaviour. `nothing` means: determine automatically (which is currently the same as `true`). - `lazy_workspace_creation::Bool = $LAZY_WORKSPACE_CREATION_DEFAULT` @@ -292,6 +298,7 @@ function from_flat_kwargs(; require_secret_for_open_links::Bool = REQUIRE_SECRET_FOR_OPEN_LINKS_DEFAULT, require_secret_for_access::Bool = REQUIRE_SECRET_FOR_ACCESS_DEFAULT, + warn_about_untrusted_code::Bool = WARN_ABOUT_UNTRUSTED_CODE_DEFAULT, run_notebook_on_load::Bool = RUN_NOTEBOOK_ON_LOAD_DEFAULT, workspace_use_distributed::Bool = WORKSPACE_USE_DISTRIBUTED_DEFAULT, @@ -340,6 +347,7 @@ function from_flat_kwargs(; security = SecurityOptions(; require_secret_for_open_links, require_secret_for_access, + warn_about_untrusted_code, ) evaluation = EvaluationOptions(; run_notebook_on_load, diff --git a/src/evaluation/Run.jl b/src/evaluation/Run.jl index b9eb7679f9..7fb349082c 100644 --- a/src/evaluation/Run.jl +++ b/src/evaluation/Run.jl @@ -337,7 +337,17 @@ function set_output!(cell::Cell, run, expr_cache::ExprAnalysisCache; persist_js_ cell.running = cell.queued = false end -will_run_code(notebook::Notebook) = notebook.process_status != ProcessStatus.no_process && notebook.process_status != ProcessStatus.waiting_to_restart +function clear_output!(cell::Cell) + cell.output = CellOutput() + cell.published_objects = Dict{String,Any}() + + cell.runtime = nothing + cell.errored = false + cell.running = cell.queued = false +end + +will_run_code(notebook::Notebook) = notebook.process_status ∈ (ProcessStatus.ready, ProcessStatus.starting) +will_run_pkg(notebook::Notebook) = notebook.process_status !== ProcessStatus.waiting_for_permission "Do all the things!" @@ -348,6 +358,7 @@ function update_save_run!( save::Bool=true, run_async::Bool=false, prerender_text::Bool=false, + clear_not_prerenderable_cells::Bool=false, auto_solve_multiple_defs::Bool=false, on_auto_solve_multiple_defs::Function=identity, kwargs... @@ -377,9 +388,8 @@ function update_save_run!( save && save_notebook(session, notebook) # _assume `prerender_text == false` if you want to skip some details_ - to_run_online = if !prerender_text - cells - else + to_run_online = cells + if prerender_text # this code block will run cells that only contain text offline, i.e. on the server process, before doing anything else # this makes the notebook load a lot faster - the front-end does not have to wait for each output, and perform costly reflows whenever one updates # "A Workspace on the main process, used to prerender markdown before starting a notebook process for speedy UI." @@ -392,16 +402,20 @@ function update_save_run!( is_offline_renderer=true, ) - to_run_offline = filter(c -> !c.running && is_just_text(new, c) && is_just_text(old, c), cells) + to_run_offline = filter(c -> !c.running && is_just_text(new, c), cells) for cell in to_run_offline run_single!(offline_workspace, cell, new.nodes[cell], new.codes[cell]) end cd(original_pwd) - setdiff(cells, to_run_offline) + to_run_online = setdiff(cells, to_run_offline) + + clear_not_prerenderable_cells && foreach(clear_output!, to_run_online) + + send_notebook_changes!(ClientRequest(; session, notebook)) end - - # this setting is not officially supported (default is `false`), so you can skip this block when reading the code + + # this setting is not officially supported (default is `true`), so you can skip this block when reading the code if !session.options.evaluation.run_notebook_on_load && prerender_text # these cells do something like settings up an environment, we should always run them setup_cells = filter(notebook.cells) do c @@ -438,10 +452,13 @@ function update_save_run!( @async WorkspaceManager.get_workspace((session, notebook)) end - sync_nbpkg(session, notebook, old, new; - save=(save && !session.options.server.disable_writing_notebook_files), - take_token=false - ) + if will_run_pkg(notebook) + # downloading and precompiling packages from the General registry is also arbitrary code execution + sync_nbpkg(session, notebook, old, new; + save=(save && !session.options.server.disable_writing_notebook_files), + take_token=false + ) + end if run_code # not async because that would be double async diff --git a/src/evaluation/WorkspaceManager.jl b/src/evaluation/WorkspaceManager.jl index 178b44f5f9..d993c0d7a2 100644 --- a/src/evaluation/WorkspaceManager.jl +++ b/src/evaluation/WorkspaceManager.jl @@ -23,6 +23,7 @@ Base.@kwdef mutable struct Workspace module_name::Symbol dowork_token::Token=Token() nbpkg_was_active::Bool=false + has_executed_effectful_code::Bool=false is_offline_renderer::Bool=false original_LOAD_PATH::Vector{String}=String[] original_ACTIVE_PROJECT::Union{Nothing,String}=nothing @@ -124,7 +125,7 @@ function make_workspace((session, notebook)::SN; is_offline_renderer::Bool=false # TODO: precompile 1+1 with display # sleep(3) - eval_format_fetch_in_workspace(workspace, Expr(:toplevel, LineNumberNode(-1), :(1+1)), uuid1()) + eval_format_fetch_in_workspace(workspace, Expr(:toplevel, LineNumberNode(-1), :(1+1)), uuid1(); code_is_effectful=false) Status.report_business_finished!(init_status, Symbol(4)) Status.report_business_finished!(workspace_business, :init_process) @@ -410,7 +411,8 @@ function eval_format_fetch_in_workspace( forced_expr_id::Union{PlutoRunner.ObjectID,Nothing}=nothing, known_published_objects::Vector{String}=String[], user_requested_run::Bool=true, - capture_stdout::Bool=true + capture_stdout::Bool=true, + code_is_effectful::Bool=true, )::PlutoRunner.FormattedCellResult workspace = get_workspace(session_notebook) @@ -425,6 +427,7 @@ function eval_format_fetch_in_workspace( # A try block (on this process) to catch an InterruptException take!(workspace.dowork_token) + workspace.has_executed_effectful_code |= code_is_effectful early_result = try Malt.remote_eval_wait(workspace.worker, quote PlutoRunner.run_expression( diff --git a/src/notebook/Notebook.jl b/src/notebook/Notebook.jl index f7b2dbaea8..45f2be62ab 100644 --- a/src/notebook/Notebook.jl +++ b/src/notebook/Notebook.jl @@ -19,6 +19,7 @@ const ProcessStatus = ( starting="starting", no_process="no_process", waiting_to_restart="waiting_to_restart", + waiting_for_permission="waiting_for_permission", ) "Like a [`Diary`](@ref) but more serious. 📓" @@ -72,6 +73,17 @@ function _initial_nb_status() return b end +function _report_business_cells_planned!(notebook::Notebook) + run_status = Status.report_business_planned!(notebook.status_tree, :run) + Status.report_business_planned!(run_status, :resolve_topology) + cell_status = Status.report_business_planned!(run_status, :evaluate) + for (i,c) in enumerate(notebook.cells) + c.running = true + c.queued = true + Status.report_business_planned!(cell_status, Symbol(i)) + end +end + _collect_cells(cells_dict::Dict{UUID,Cell}, cells_order::Vector{UUID}) = map(i -> cells_dict[i], cells_order) _initial_topology(cells_dict::Dict{UUID,Cell}, cells_order::Vector{UUID}) = diff --git a/src/packages/Packages.jl b/src/packages/Packages.jl index f7ff3ea335..37a3437438 100644 --- a/src/packages/Packages.jl +++ b/src/packages/Packages.jl @@ -323,6 +323,8 @@ In addition to the steps performed by [`sync_nbpkg_core`](@ref): - `try` `catch` and reset the package environment on failure. """ function sync_nbpkg(session, notebook, old_topology::NotebookTopology, new_topology::NotebookTopology; save::Bool=true, take_token::Bool=true) + @assert will_run_pkg(notebook) + cleanup = Ref{Function}(_default_cleanup) try Status.report_business_started!(notebook.status_tree, :pkg) @@ -353,16 +355,18 @@ function sync_nbpkg(session, notebook, old_topology::NotebookTopology, new_topol end if pkg_result.did_something - @debug "PlutoPkg: success!" notebook.path pkg_result - - if pkg_result.restart_recommended - notebook.nbpkg_restart_recommended_msg = "Yes, something changed during regular sync." - @debug "PlutoPkg: Notebook restart recommended" notebook.path notebook.nbpkg_restart_recommended_msg - end - if pkg_result.restart_required - notebook.nbpkg_restart_required_msg = "Yes, something changed during regular sync." - @debug "PlutoPkg: Notebook restart REQUIRED" notebook.path notebook.nbpkg_restart_required_msg - end + @debug "PlutoPkg: success!" notebook.path pkg_result + + if _has_executed_effectful_code(session, notebook) + if pkg_result.restart_recommended + notebook.nbpkg_restart_recommended_msg = "Yes, something changed during regular sync." + @debug "PlutoPkg: Notebook restart recommended" notebook.path notebook.nbpkg_restart_recommended_msg + end + if pkg_result.restart_required + notebook.nbpkg_restart_required_msg = "Yes, something changed during regular sync." + @debug "PlutoPkg: Notebook restart REQUIRED" notebook.path notebook.nbpkg_restart_required_msg + end + end notebook.nbpkg_busy_packages = String[] update_nbpkg_cache!(notebook) @@ -403,6 +407,12 @@ function sync_nbpkg(session, notebook, old_topology::NotebookTopology, new_topol end end +function _has_executed_effectful_code(session::ServerSession, notebook::Notebook) + workspace = WorkspaceManager.get_workspace((session, notebook); allow_creation=false) + workspace === nothing ? false : workspace.has_executed_effectful_code +end + + function writebackup(notebook::Notebook) backup_path = backup_filename(notebook.path) Pluto.readwrite(notebook.path, backup_path) @@ -613,6 +623,8 @@ end function update_nbpkg(session, notebook::Notebook; level::Pkg.UpgradeLevel=Pkg.UPLEVEL_MAJOR, backup::Bool=true, save::Bool=true) + @assert will_run_pkg(notebook) + bp = if backup && save writebackup(notebook) end diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl index 60e3677510..8a138813e9 100644 --- a/src/webserver/Dynamic.jl +++ b/src/webserver/Dynamic.jl @@ -101,8 +101,8 @@ function notebook_to_js(notebook::Notebook) Dict{String,Any}( "notebook_id" => notebook.notebook_id, "path" => notebook.path, - "in_temp_dir" => startswith(notebook.path, new_notebooks_directory()), "shortpath" => basename(notebook.path), + "in_temp_dir" => startswith(notebook.path, new_notebooks_directory()), "process_status" => notebook.process_status, "last_save_time" => notebook.last_save_time, "last_hot_reload_time" => notebook.last_hot_reload_time, @@ -154,6 +154,8 @@ function notebook_to_js(notebook::Notebook) ctx = notebook.nbpkg_ctx Dict{String,Any}( "enabled" => ctx !== nothing, + "waiting_for_permission" => notebook.process_status === ProcessStatus.waiting_for_permission, + "waiting_for_permission_but_probably_disabled" => notebook.process_status === ProcessStatus.waiting_for_permission && !use_plutopkg(notebook.topology), "restart_recommended_msg" => notebook.nbpkg_restart_recommended_msg, "restart_required_msg" => notebook.nbpkg_restart_required_msg, "installed_versions" => ctx === nothing ? Dict{String,String}() : notebook.nbpkg_installed_versions_cache, @@ -247,6 +249,18 @@ const effects_of_changed_state = Dict( @info "Process status set by client" newstatus end, + # "execution_allowed" => function(; request::ClientRequest, patch::Firebasey.ReplacePatch) + # Firebasey.applypatch!(request.notebook, patch) + # newstatus = patch.value + + # @info "execution_allowed set by client" newstatus + # if newstatus + # @info "lets run some cells!" + # update_save_run!(request.session, request.notebook, notebook.cells; + # run_async=true, save=true + # ) + # end + # end, "in_temp_dir" => function(; _...) no_changes end, "cell_inputs" => Dict( Wildcard() => function(cell_id, rest...; request::ClientRequest, patch::Firebasey.JSONPatch) @@ -434,10 +448,14 @@ responses[:run_multiple_cells] = function response_run_multiple_cells(🙋::Clie putclientupdates!(🙋.session, 🙋.initiator, UpdateMessage(:run_feedback, response, 🙋.notebook, nothing, 🙋.initiator)) end - # save=true fixes the issue where "Submit all changes" or `Ctrl+S` has no effect. + wfp = 🙋.notebook.process_status == ProcessStatus.waiting_for_permission + update_save_run!(🙋.session, 🙋.notebook, cells; run_async=true, save=true, - auto_solve_multiple_defs=true, on_auto_solve_multiple_defs + auto_solve_multiple_defs=true, on_auto_solve_multiple_defs, + # special case: just render md cells in "Safe preview" mode + prerender_text=wfp, + clear_not_prerenderable_cells=wfp, ) end @@ -473,8 +491,10 @@ responses[:restart_process] = function response_restart_process(🙋::ClientRequ if 🙋.notebook.process_status != ProcessStatus.waiting_to_restart 🙋.notebook.process_status = ProcessStatus.waiting_to_restart + 🙋.session.options.evaluation.run_notebook_on_load && _report_business_cells_planned!(🙋.notebook) send_notebook_changes!(🙋 |> without_initiator) + # TODO skip necessary? SessionActions.shutdown(🙋.session, 🙋.notebook; keep_in_session=true, async=true) 🙋.notebook.process_status = ProcessStatus.starting @@ -487,6 +507,8 @@ end responses[:reshow_cell] = function response_reshow_cell(🙋::ClientRequest) require_notebook(🙋) + @assert will_run_code(🙋.notebook) + cell = let cell_id = UUID(🙋.body["cell_id"]) 🙋.notebook.cells_dict[cell_id] diff --git a/src/webserver/Router.jl b/src/webserver/Router.jl index a3cca00625..cfbfed3d28 100644 --- a/src/webserver/Router.jl +++ b/src/webserver/Router.jl @@ -13,7 +13,12 @@ function http_router_for(session::ServerSession) HTTP.register!(router, "GET", "/ping", r -> HTTP.Response(200, "OK!")) HTTP.register!(router, "GET", "/possible_binder_token_please", r -> session.binder_token === nothing ? HTTP.Response(200,"") : HTTP.Response(200, session.binder_token)) - function try_launch_notebook_response(action::Function, path_or_url::AbstractString; title="", advice="", home_url="./", as_redirect=true, action_kwargs...) + function try_launch_notebook_response( + action::Function, path_or_url::AbstractString; + as_redirect=true, + title="", advice="", home_url="./", + action_kwargs... + ) try nb = action(session, path_or_url; action_kwargs...) notebook_response(nb; home_url, as_redirect) @@ -39,13 +44,16 @@ function http_router_for(session::ServerSession) uri = HTTP.URI(request.target) query = HTTP.queryparams(uri) as_sample = haskey(query, "as_sample") + execution_allowed = haskey(query, "execution_allowed") if haskey(query, "path") path = tamepath(maybe_convert_path_to_wsl(query["path"])) if isfile(path) return try_launch_notebook_response( SessionActions.open, path; + execution_allowed, as_redirect=(request.method == "GET"), as_sample, + risky_file_source=nothing, title="Failed to load notebook", advice="The file $(htmlesc(path)) could not be loaded. Please report this error!", ) @@ -56,8 +64,10 @@ function http_router_for(session::ServerSession) url = query["url"] return try_launch_notebook_response( SessionActions.open_url, url; + execution_allowed, as_redirect=(request.method == "GET"), as_sample, + risky_file_source=url, title="Failed to load notebook", advice="The notebook from $(htmlesc(url)) could not be loaded. Please report this error!" ) @@ -192,7 +202,8 @@ function http_router_for(session::ServerSession) save_path; as_redirect=false, as_sample=false, - clear_frontmatter=!isnothing(get(query, "clear_frontmatter", nothing)), + execution_allowed=haskey(query, "execution_allowed"), + clear_frontmatter=haskey(query, "clear_frontmatter"), title="Failed to load notebook", advice="The contents could not be read as a Pluto notebook file. When copying contents from somewhere else, make sure that you copy the entire notebook file. You can also report this error!" ) diff --git a/src/webserver/Session.jl b/src/webserver/Session.jl index c7dfb2a240..36b912ae82 100644 --- a/src/webserver/Session.jl +++ b/src/webserver/Session.jl @@ -82,7 +82,8 @@ function clientupdate_notebook_list(notebooks; initiator::Union{Initiator,Nothin :notebook_id => notebook.notebook_id, :path => notebook.path, :in_temp_dir => startswith(notebook.path, new_notebooks_directory()), - :shortpath => basename(notebook.path) + :shortpath => basename(notebook.path), + :process_status => notebook.process_status, ) for notebook in values(notebooks) ] ), nothing, nothing, initiator) diff --git a/src/webserver/SessionActions.jl b/src/webserver/SessionActions.jl index 8355cf552d..a8ef828d12 100644 --- a/src/webserver/SessionActions.jl +++ b/src/webserver/SessionActions.jl @@ -1,6 +1,6 @@ module SessionActions -import ..Pluto: Pluto, Status, ServerSession, Notebook, Cell, emptynotebook, tamepath, new_notebooks_directory, without_pluto_file_extension, numbered_until_new, cutename, readwrite, update_save_run!, update_from_file, wait_until_file_unchanged, putnotebookupdates!, putplutoupdates!, load_notebook, clientupdate_notebook_list, WorkspaceManager, try_event_call, NewNotebookEvent, OpenNotebookEvent, ShutdownNotebookEvent, @asynclog, ProcessStatus, maybe_convert_path_to_wsl, move_notebook!, throttled +import ..Pluto: Pluto, Status, ServerSession, Notebook, Cell, emptynotebook, tamepath, new_notebooks_directory, without_pluto_file_extension, numbered_until_new, cutename, readwrite, update_save_run!, update_nbpkg_cache!, update_from_file, wait_until_file_unchanged, putnotebookupdates!, putplutoupdates!, load_notebook, clientupdate_notebook_list, WorkspaceManager, try_event_call, NewNotebookEvent, OpenNotebookEvent, ShutdownNotebookEvent, @asynclog, ProcessStatus, maybe_convert_path_to_wsl, move_notebook!, throttled using FileWatching import ..Pluto.DownloadCool: download_cool import HTTP @@ -42,9 +42,11 @@ end "Open the notebook at `path` into `session::ServerSession` and run it. Returns the `Notebook`." function open(session::ServerSession, path::AbstractString; + execution_allowed::Bool=true, run_async::Bool=true, compiler_options=nothing, as_sample::Bool=false, + risky_file_source::Union{Nothing,String}=nothing, clear_frontmatter::Bool=false, notebook_id::UUID=uuid1() ) @@ -64,7 +66,13 @@ function open(session::ServerSession, path::AbstractString; end notebook = load_notebook(tamepath(path); disable_writing_notebook_files=session.options.server.disable_writing_notebook_files) + execution_allowed = execution_allowed && !haskey(notebook.metadata, "risky_file_source") + notebook.notebook_id = notebook_id + if !isnothing(risky_file_source) + notebook.metadata["risky_file_source"] = risky_file_source + end + notebook.process_status = execution_allowed ? ProcessStatus.starting : ProcessStatus.waiting_for_permission # overwrites the notebook environment if specified if compiler_options !== nothing @@ -76,15 +84,17 @@ function open(session::ServerSession, path::AbstractString; session.notebooks[notebook.notebook_id] = notebook - run_status = Status.report_business_planned!(notebook.status_tree, :run) - if session.options.evaluation.run_notebook_on_load - Status.report_business_planned!(run_status, :resolve_topology) - cell_status = Status.report_business_planned!(run_status, :evaluate) - for (i,c) in enumerate(notebook.cells) - c.queued = true - Status.report_business_planned!(cell_status, Symbol(i)) - end + if execution_allowed && session.options.evaluation.run_notebook_on_load + Pluto._report_business_cells_planned!(notebook) end + + if !execution_allowed + Status.delete_business!(notebook.status_tree, :run) + Status.delete_business!(notebook.status_tree, :workspace) + Status.delete_business!(notebook.status_tree, :pkg) + end + + update_nbpkg_cache!(notebook) update_save_run!(session, notebook, notebook.cells; run_async, prerender_text=true) add(session, notebook; run_async) @@ -229,7 +239,7 @@ function shutdown(session::ServerSession, notebook::Notebook; keep_in_session::B notebook.nbpkg_restart_recommended_msg = nothing notebook.nbpkg_restart_required_msg = nothing - if notebook.process_status == ProcessStatus.ready || notebook.process_status == ProcessStatus.starting + if notebook.process_status ∈ (ProcessStatus.ready, ProcessStatus.starting) notebook.process_status = ProcessStatus.no_process end diff --git a/src/webserver/Status.jl b/src/webserver/Status.jl index 8a139af2c0..8586659d90 100644 --- a/src/webserver/Status.jl +++ b/src/webserver/Status.jl @@ -78,6 +78,10 @@ finally report_business_finished!(parent, args...) end +delete_business!(business::Business, name::Symbol) = lock(business.lock) do + delete!(business.subtasks, name) +end + # GLOBAL diff --git a/test/Configuration.jl b/test/Configuration.jl index 8b92d5b179..3cabe2a942 100644 --- a/test/Configuration.jl +++ b/test/Configuration.jl @@ -58,6 +58,8 @@ end end @testset "Authentication" begin + basic_nb_path = Pluto.project_relative_path("sample", "Basic.jl") + port = 1238 options = Pluto.Configuration.from_flat_kwargs(; port, launch_browser=false, workspace_use_distributed=false) 🍭 = Pluto.ServerSession(; options) @@ -71,11 +73,12 @@ end @test HTTP.get(local_url("favicon.ico")).status == 200 function requeststatus(url, method) - r = HTTP.request(method, url; status_exception=false, redirect=false) + r = HTTP.request(method, url, nothing, method == "POST" ? read(basic_nb_path) : UInt8[]; status_exception=false, redirect=false) r.status end + - nb = SessionActions.open(🍭, Pluto.project_relative_path("sample", "Basic.jl"); as_sample=true) + nb = SessionActions.open(🍭, basic_nb_path; as_sample=true) simple_routes = [ ("", "GET"), @@ -90,18 +93,20 @@ end Pluto.readwrite(x, p) p end - @assert isfile(Pluto.project_relative_path("sample", "Basic.jl")) + @assert isfile(basic_nb_path) effect_routes = [ ("new", "GET"), ("new", "POST"), ("open?url=$(URIs.escapeuri("https://raw.githubusercontent.com/fonsp/Pluto.jl/v0.14.5/sample/Basic.jl"))", "GET"), + ("open?url=$(URIs.escapeuri("https://raw.githubusercontent.com/fonsp/Pluto.jl/v0.14.5/sample/Basic.jl"))&execution_allowed=asdf", "GET"), ("open?url=$(URIs.escapeuri("https://raw.githubusercontent.com/fonsp/Pluto.jl/v0.14.5/sample/Basic.jl"))", "POST"), - ("open?path=$(URIs.escapeuri(Pluto.project_relative_path("sample", "Basic.jl") |> tempcopy))", "GET"), - ("open?path=$(URIs.escapeuri(Pluto.project_relative_path("sample", "Basic.jl") |> tempcopy))", "POST"), + ("open?path=$(URIs.escapeuri(basic_nb_path |> tempcopy))", "GET"), + ("open?path=$(URIs.escapeuri(basic_nb_path |> tempcopy))", "POST"), ("sample/Basic.jl", "GET"), ("sample/Basic.jl", "POST"), ("notebookupload", "POST"), + ("notebookupload?execution_allowed=asdf", "POST"), ] for (suffix, method) in simple_routes ∪ effect_routes @@ -117,9 +122,23 @@ end @test requeststatus(url, method) ∈ 200:299 end - for (suffix, method) in setdiff(effect_routes, [("notebookupload", "POST")]) + for (suffix, method) in effect_routes + old_ids = collect(keys(🍭.notebooks)) + url = local_url(suffix) |> withsecret @test requeststatus(url, method) ∈ 200:399 # 3xx are redirects + + new_ids = collect(keys(🍭.notebooks)) + nb = 🍭.notebooks[only(setdiff(new_ids, old_ids))] + + if any(x -> occursin(x, suffix), ["new", "execution_allowed", "sample/Basic.jl"]) + @test Pluto.will_run_code(nb) + @test Pluto.will_run_pkg(nb) + else + @test !Pluto.will_run_code(nb) + @test !Pluto.will_run_pkg(nb) + @test nb.process_status === Pluto.ProcessStatus.waiting_for_permission + end end close(server) diff --git a/test/frontend/README.md b/test/frontend/README.md index 36c12d61c7..7d05333478 100644 --- a/test/frontend/README.md +++ b/test/frontend/README.md @@ -9,13 +9,13 @@ All commands here are executed in this folder (`Pluto.jl/test/frontend`). ## Run Pluto.jl server ``` -PLUTO_PORT=2345; julia --project=/path/to/PlutoDev -e "import Pluto; Pluto.run(port=$PLUTO_PORT, require_secret_for_access=false, require_secret_for_open_links=false, launch_browser=false)" +PLUTO_PORT=2345; julia --project=/path/to/PlutoDev -e "import Pluto; Pluto.run(port=$PLUTO_PORT, require_secret_for_access=false, launch_browser=false)" ``` or if Pluto is dev'ed in your global environment: ``` -PLUTO_PORT=2345; julia -e "import Pluto; Pluto.run(port=$PLUTO_PORT, require_secret_for_access=false, require_secret_for_open_links=false, launch_browser=false)" +PLUTO_PORT=2345; julia -e "import Pluto; Pluto.run(port=$PLUTO_PORT, require_secret_for_access=false, launch_browser=false)" ``` ## Run tests @@ -26,13 +26,13 @@ PLUTO_PORT=2345; julia -e "import Pluto; Pluto.run(port=$PLUTO_PORT, require_sec Add `HEADLESS=false` when running the test command. -`clear && HEADLESS=false PLUTO_PORT=2345 npm run test` +`clear && HEADLESS=false PLUTO_PORT=1234 npm run test` ## Run a particular suite of tests Add `-- -t=name of the suite` to the end of the test command. -`clear && PLUTO_PORT=2345 npm run test -- -t=PlutoAutocomplete` +`clear && HEADLESS=false PLUTO_PORT=1234 npm run test -- -t=PlutoAutocomplete` ## To make a test fail on a case that does not crash Pluto diff --git a/test/frontend/__tests__/autocomplete_test.js b/test/frontend/__tests__/autocomplete_test.js index 7af4ca3a42..bef1588aac 100644 --- a/test/frontend/__tests__/autocomplete_test.js +++ b/test/frontend/__tests__/autocomplete_test.js @@ -1,5 +1,5 @@ import puppeteer from "puppeteer" -import { lastElement, saveScreenshot, createPage } from "../helpers/common" +import { lastElement, saveScreenshot, createPage, waitForContentToBecome } from "../helpers/common" import { getCellIds, importNotebook, @@ -81,13 +81,7 @@ describe("PlutoAutocomplete", () => { // Trigger autocomplete await page.keyboard.press("Tab") - await page.waitForTimeout(5000) - // Get suggestions - const autocompletedInput = await page.evaluate( - (selector) => document.querySelector(selector).textContent.trim(), - `pluto-cell[id="${lastPlutoCellId}"] pluto-input .CodeMirror-line` - ) - expect(autocompletedInput).toEqual("my_subtract") + expect(await waitForContentToBecome(page, `pluto-cell[id="${lastPlutoCellId}"] pluto-input .CodeMirror-line`, "my_subtract")).toBe("my_subtract") }) }) diff --git a/test/frontend/__tests__/bonds.js b/test/frontend/__tests__/bonds.js index 50f9ee67ba..af633a3400 100644 --- a/test/frontend/__tests__/bonds.js +++ b/test/frontend/__tests__/bonds.js @@ -1,6 +1,6 @@ import puppeteer from "puppeteer" import { saveScreenshot, createPage, paste } from "../helpers/common" -import { createNewNotebook, getPlutoUrl, setupPlutoBrowser, shutdownCurrentNotebook, waitForPlutoToCalmDown } from "../helpers/pluto" +import { createNewNotebook, getPlutoUrl, runAllChanged, setupPlutoBrowser, shutdownCurrentNotebook, waitForPlutoToCalmDown } from "../helpers/pluto" // https://github.com/fonsp/Pluto.jl/issues/928 describe("Bonds should run once when refreshing page", () => { @@ -47,11 +47,7 @@ describe("Bonds should run once when refreshing page", () => { @bind z html"" ` ) - await page.waitForSelector(`.runallchanged`, { visible: true, polling: 200, timeout: 0 }) - await page.click(`.runallchanged`) - - await page.waitForSelector(`pluto-cell.running`, { visible: true, timeout: 0 }) - await waitForPlutoToCalmDown(page) + await runAllChanged(page) await paste( page, @@ -64,9 +60,7 @@ numberoftimes = Ref(0) ` ) - await page.waitForSelector(`.runallchanged`, { visible: true, polling: 200, timeout: 0 }) - await page.click(`.runallchanged`) - await waitForPlutoToCalmDown(page) + await runAllChanged(page) await page.waitForFunction(() => Boolean(document.querySelector("pluto-cell:nth-of-type(5) pluto-output")?.textContent)) await waitForPlutoToCalmDown(page) diff --git a/test/frontend/__tests__/import_notebook_test.js b/test/frontend/__tests__/import_notebook_test.js index 591a7bb697..bf49cb81b1 100644 --- a/test/frontend/__tests__/import_notebook_test.js +++ b/test/frontend/__tests__/import_notebook_test.js @@ -9,6 +9,7 @@ import { writeSingleLineInPlutoInput, shutdownCurrentNotebook, setupPlutoBrowser, + runAllChanged, } from "../helpers/pluto" describe("PlutoImportNotebook", () => { @@ -61,11 +62,7 @@ describe("PlutoImportNotebook", () => { // Use the previously defined sum function in the new cell lastPlutoCellId = lastElement(await getCellIds(page)) await writeSingleLineInPlutoInput(page, `pluto-cell[id="${lastPlutoCellId}"] pluto-input`, "sum(2, 3)") - - // Run cells - - await page.waitForSelector(`.runallchanged`, { visible: true, polling: 200, timeout: 0 }) - await page.click(".runallchanged") + await runAllChanged(page) const lastCellContent = await waitForCellOutput(page, lastPlutoCellId) expect(lastCellContent).toBe("5") }) diff --git a/test/frontend/__tests__/javascript_api.js b/test/frontend/__tests__/javascript_api.js index 6c6c700529..252132f37d 100644 --- a/test/frontend/__tests__/javascript_api.js +++ b/test/frontend/__tests__/javascript_api.js @@ -1,6 +1,14 @@ import puppeteer from "puppeteer" import { saveScreenshot, waitForContentToBecome, createPage, paste } from "../helpers/common" -import { createNewNotebook, waitForNoUpdateOngoing, getPlutoUrl, shutdownCurrentNotebook, setupPlutoBrowser, waitForPlutoToCalmDown } from "../helpers/pluto" +import { + createNewNotebook, + waitForNoUpdateOngoing, + getPlutoUrl, + shutdownCurrentNotebook, + setupPlutoBrowser, + waitForPlutoToCalmDown, + runAllChanged, +} from "../helpers/pluto" describe("JavaScript API", () => { /** @@ -43,12 +51,7 @@ describe("JavaScript API", () => { """ ` ) - await page.waitForSelector(`.runallchanged`, { - visible: true, - polling: 200, - timeout: 0, - }) - await page.click(`.runallchanged`) + await runAllChanged(page) await waitForPlutoToCalmDown(page, { polling: 100 }) const initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output`, expected) expect(initialLastCellContent).toBe(expected) @@ -64,12 +67,7 @@ describe("JavaScript API", () => { """ ` ) - await page.waitForSelector(`.runallchanged`, { - visible: true, - polling: 200, - timeout: 0, - }) - await page.click(`.runallchanged`) + await runAllChanged(page) await waitForPlutoToCalmDown(page, { polling: 100 }) let initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output`, expected) expect(initialLastCellContent).toBe(expected) @@ -84,12 +82,7 @@ describe("JavaScript API", () => { """ ` ) - await page.waitForSelector(`.runallchanged`, { - visible: true, - polling: 200, - timeout: 0, - }) - await page.click(`.runallchanged`) + await runAllChanged(page) await waitForPlutoToCalmDown(page, { polling: 100 }) initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output`, expected) expect(initialLastCellContent).toBe(expected) @@ -117,12 +110,7 @@ describe("JavaScript API", () => { v ` ) - await page.waitForSelector(`.runallchanged`, { - visible: true, - polling: 200, - timeout: 0, - }) - await page.click(`.runallchanged`) + await runAllChanged(page) await waitForPlutoToCalmDown(page, { polling: 100 }) await waitForContentToBecome(page, `pluto-cell:nth-child(2) pluto-output`, "emitter") page.waitForTimeout(2000) diff --git a/test/frontend/__tests__/new_notebook_test.js b/test/frontend/__tests__/new_notebook_test.js index 4945328b29..87f0a6f935 100644 --- a/test/frontend/__tests__/new_notebook_test.js +++ b/test/frontend/__tests__/new_notebook_test.js @@ -10,22 +10,11 @@ import { shutdownCurrentNotebook, setupPlutoBrowser, waitForPlutoToCalmDown, + manuallyEnterCells, + runAllChanged, + clearPlutoInput, } from "../helpers/pluto" -const manuallyEnterCells = async (page, cells) => { - const plutoCellIds = [] - for (const cell of cells) { - const plutoCellId = lastElement(await getCellIds(page)) - plutoCellIds.push(plutoCellId) - await page.waitForSelector(`pluto-cell[id="${plutoCellId}"] pluto-input .cm-content`) - await writeSingleLineInPlutoInput(page, `pluto-cell[id="${plutoCellId}"] pluto-input`, cell) - - await page.click(`pluto-cell[id="${plutoCellId}"] .add_cell.after`) - await page.waitForFunction((nCells) => document.querySelectorAll("pluto-cell").length === nCells, {}, plutoCellIds.length + 1) - } - return plutoCellIds -} - describe("PlutoNewNotebook", () => { /** * Launch a shared browser instance for all tests. @@ -55,12 +44,6 @@ describe("PlutoNewNotebook", () => { browser = null }) - it("should create new notebook", async () => { - // A pluto-input should exist in a new notebook - const plutoInput = await page.evaluate(() => document.querySelector("pluto-input")) - expect(plutoInput).not.toBeNull() - }) - it("should run a single cell", async () => { const cellInputSelector = "pluto-input .cm-content" await page.waitForSelector(cellInputSelector) @@ -72,24 +55,12 @@ describe("PlutoNewNotebook", () => { const content = await waitForContent(page, "pluto-output") expect(content).toBe("2") - }) - it("should run multiple cells", async () => { - const cells = ["a = 1", "b = 2", "c = 3", "a + b + c"] - const plutoCellIds = await manuallyEnterCells(page, cells) - await page.waitForSelector(`.runallchanged`, { visible: true, polling: 200, timeout: 0 }) - await page.click(`.runallchanged`) - await waitForPlutoToCalmDown(page, { polling: 100 }) - const content = await waitForContentToBecome(page, `pluto-cell[id="${plutoCellIds[3]}"] pluto-output`, "6") - expect(content).toBe("6") - }) + await clearPlutoInput(page, "pluto-input") - it("should reactively re-evaluate dependent cells", async () => { const cells = ["a = 1", "b = 2", "c = 3", "a + b + c"] const plutoCellIds = await manuallyEnterCells(page, cells) - await page.waitForSelector(`.runallchanged`, { visible: true, polling: 200, timeout: 0 }) - await page.click(`.runallchanged`) - await waitForPlutoToCalmDown(page, { polling: 100 }) + await runAllChanged(page) const initialLastCellContent = await waitForContentToBecome(page, `pluto-cell[id="${plutoCellIds[3]}"] pluto-output`, "6") expect(initialLastCellContent).toBe("6") diff --git a/test/frontend/__tests__/paste_test.disabled b/test/frontend/__tests__/paste_test.disabled index b85f288577..5adde25082 100644 --- a/test/frontend/__tests__/paste_test.disabled +++ b/test/frontend/__tests__/paste_test.disabled @@ -44,9 +44,7 @@ describe("Paste Functionality", () => { it("should *not* create new cell when you paste code into cell", async () => { const cells = ["a = 1", "b = 2", "c = 3", "a + b + c"] const plutoCellIds = await manuallyEnterCells(page, cells) - await page.waitForSelector(`.runallchanged`, { visible: true, polling: 200, timeout: 0 }) - await page.click(`.runallchanged`) - await waitForPlutoToCalmDown(page, { polling: 100 }) + await runAllChanged(page) const initialLastCellContent = await waitForContentToBecome(page, `pluto-cell[id="${plutoCellIds[3]}"] pluto-output`, "6") expect(initialLastCellContent).toBe("6") @@ -75,9 +73,7 @@ describe("Paste Functionality", () => { it("should create new cell when you paste cell into page", async () => { const cells = ["a = 1", "b = 2", "c = 3", "a + b + c"] const plutoCellIds = await manuallyEnterCells(page, cells) - await page.waitForSelector(`.runallchanged`, { visible: true, polling: 200, timeout: 0 }) - await page.click(`.runallchanged`) - await waitForPlutoToCalmDown(page, { polling: 100 }) + await runAllChanged(page) const initialLastCellContent = await waitForContentToBecome(page, `pluto-cell[id="${plutoCellIds[3]}"] pluto-output`, "6") expect(initialLastCellContent).toBe("6") @@ -117,9 +113,7 @@ describe("Paste Functionality", () => { it("should create new cell when you paste cell into cell", async () => { const cells = ["a = 1", "b = 2", "c = 3", "a + b + c"] const plutoCellIds = await manuallyEnterCells(page, cells) - await page.waitForSelector(`.runallchanged`, { visible: true, polling: 200, timeout: 0 }) - await page.click(`.runallchanged`) - await waitForPlutoToCalmDown(page, { polling: 100 }) + await runAllChanged(page) const initialLastCellContent = await waitForContentToBecome(page, `pluto-cell[id="${plutoCellIds[3]}"] pluto-output`, "6") expect(initialLastCellContent).toBe("6") diff --git a/test/frontend/__tests__/safe_preview.js b/test/frontend/__tests__/safe_preview.js new file mode 100644 index 0000000000..e8b37b1d03 --- /dev/null +++ b/test/frontend/__tests__/safe_preview.js @@ -0,0 +1,248 @@ +import puppeteer from "puppeteer" +import { saveScreenshot, createPage, paste, clickAndWaitForNavigation } from "../helpers/common" +import { + importNotebook, + getPlutoUrl, + shutdownCurrentNotebook, + setupPlutoBrowser, + waitForPlutoToCalmDown, + restartProcess, + getCellIds, + clearPlutoInput, + writeSingleLineInPlutoInput, + runAllChanged, + openPathOrURLNotebook, + getAllCellOutputs, +} from "../helpers/pluto" + +describe("safe_preview", () => { + /** + * Launch a shared browser instance for all tests. + * I don't use jest-puppeteer because it takes away a lot of control and works buggy for me, + * so I need to manually create the shared browser. + * @type {puppeteer.Browser} + */ + let browser = null + /** @type {puppeteer.Page} */ + let page = null + beforeAll(async () => { + browser = await setupPlutoBrowser() + }) + beforeEach(async () => { + page = await createPage(browser) + await page.goto(getPlutoUrl(), { waitUntil: "networkidle0" }) + }) + afterEach(async () => { + await saveScreenshot(page) + await shutdownCurrentNotebook(page) + await page.close() + page = null + }) + afterAll(async () => { + await browser.close() + browser = null + }) + + const expect_safe_preview = async (/** @type {puppeteer.Page} */ page) => { + await waitForPlutoToCalmDown(page) + expect(await page.evaluate(() => window.I_DID_SOMETHING_DANGEROUS)).toBeUndefined() + expect(await page.evaluate(() => [...document.body.classList])).toContain("process_waiting_for_permission") + expect(await page.evaluate(() => document.querySelector("a#restart-process-button"))).not.toBeNull() + expect(await page.evaluate(() => document.querySelector(".safe-preview-info"))).not.toBeNull() + } + + it("Pasting notebook contents should open in safe preview", async () => { + await Promise.all([ + page.waitForNavigation(), + paste( + page, + `### A Pluto.jl notebook ### +# v0.14.0 + +using Markdown +using InteractiveUtils + +# ╔═╡ b2d786ec-7f73-11ea-1a0c-f38d7b6bbc1e +md""" +Hello +""" + +# ╔═╡ b2d79330-7f73-11ea-0d1c-a9aad1efaae1 +1 + 2 + +# ╔═╡ Cell order: +# ╟─b2d786ec-7f73-11ea-1a0c-f38d7b6bbc1e +# ╠═b2d79330-7f73-11ea-0d1c-a9aad1efaae1 +` + ), + ]) + await expect_safe_preview(page) + }) + + it("Notebook from URL source", async () => { + const url = "https://raw.githubusercontent.com/fonsp/Pluto.jl/v0.14.5/sample/Basic.jl" + + await openPathOrURLNotebook(page, url, { permissionToRunCode: false }) + await expect_safe_preview(page) + + let expectWarningMessage = async () => { + await page.waitForSelector(`a#restart-process-button`) + const [dmsg, _] = await Promise.all([ + new Promise((res) => { + page.once("dialog", async (dialog) => { + let msg = dialog.message() + await dialog.dismiss() + res(msg) + }) + }), + page.click(`a#restart-process-button`), + ]) + + expect(dmsg).toContain(url) + expect(dmsg.toLowerCase()).toContain("danger") + expect(dmsg.toLowerCase()).toContain("are you sure") + + await page.waitForTimeout(1000) + await waitForPlutoToCalmDown(page) + await expect_safe_preview(page) + } + + await expectWarningMessage() + + // Make some edits + expect((await getAllCellOutputs(page))[0]).toContain("Basel problem") + + let sel = `pluto-cell[id="${(await getCellIds(page))[0]}"]` + await page.click(`${sel} .foldcode`) + + await clearPlutoInput(page, sel) + await writeSingleLineInPlutoInput(page, sel, "1 + 1") + await runAllChanged(page) + + expect((await getAllCellOutputs(page))[0]).toBe("Code not executed in Safe preview") + await expect_safe_preview(page) + + ////////////////////////// + // Let's shut it down + // @ts-ignore + let path = await page.evaluate(() => window.editor_state.notebook.path.replaceAll("\\", "\\\\")) + let shutdown = async () => { + await shutdownCurrentNotebook(page) + await page.goto(getPlutoUrl(), { waitUntil: "networkidle0" }) + // Wait for it to be shut down + await page.waitForSelector(`li.recent a[title="${path}"]`) + } + await shutdown() + + // Run it again + await clickAndWaitForNavigation(page, `a[title="${path}"]`) + await page.waitForTimeout(1000) + await waitForPlutoToCalmDown(page) + + await expect_safe_preview(page) + await expectWarningMessage() + + //////////////////// + await shutdown() + + // Now let's try to run the notebook in the background. This should start it in safe mode because of the risky source + await page.evaluate((path) => { + let a = document.querySelector(`a[title="${path}"]`) + let btn = a.previousElementSibling + btn.click() + }, path) + + await page.waitForSelector(`li.running a[title="${path}"]`) + await clickAndWaitForNavigation(page, `a[title="${path}"]`) + + await expect_safe_preview(page) + await expectWarningMessage() + + // Let's run it + await Promise.all([ + new Promise((res) => { + page.once("dialog", (dialog) => { + res(dialog.accept()) + }) + }), + page.click(`a#restart-process-button`), + ]) + await page.waitForTimeout(1000) + await waitForPlutoToCalmDown(page) + + // Nice + expect((await getAllCellOutputs(page))[0]).toBe("2") + + //////////////////// + await shutdown() + + await clickAndWaitForNavigation(page, `a[title="${path}"]`) + + await expect_safe_preview(page) + expect((await getAllCellOutputs(page))[0]).toBe("Code not executed in Safe preview") + + // Since we ran the notebook once, there should be no warning message: + await page.waitForSelector(`a#restart-process-button`) + await page.click(`a#restart-process-button`) + + // If there was a dialog, we would stall right now and the test would fail. + await page.waitForTimeout(1000) + await waitForPlutoToCalmDown(page) + expect((await getAllCellOutputs(page))[0]).toBe("2") + }) + + it("Importing notebook should open in safe preview", async () => { + await importNotebook(page, "safe_preview.jl", { permissionToRunCode: false }) + await expect_safe_preview(page) + + await waitForPlutoToCalmDown(page) + + let cell_contents = await getAllCellOutputs(page) + + expect(cell_contents[0]).toBe("one") + expect(cell_contents[1]).toBe("Scripts and styles not rendered in Safe preview\ni should not be red\ntwo\nsafe") + expect(cell_contents[2]).toBe("three") + expect(cell_contents[3]).toBe("Code not executed in Safe preview") + expect(cell_contents[4]).toBe("Code not executed in Safe preview") + expect(cell_contents[5]).toContain("yntax") + expect(cell_contents[6]).toBe("") + + expect(await page.evaluate(() => getComputedStyle(document.querySelector(`.zo`)).color)).not.toBe("rgb(255, 0, 0)") + + // Modifying should not execute code + const cellids = await getCellIds(page) + let sel = `pluto-cell[id="${cellids[0]}"] pluto-input` + + let expectNewOutput = async (contents) => { + await clearPlutoInput(page, sel) + await writeSingleLineInPlutoInput(page, sel, contents) + await runAllChanged(page) + return expect((await getAllCellOutputs(page))[0]) + } + + ;(await expectNewOutput(`md"een"`)).toBe("een") + ;(await expectNewOutput(`un`)).toBe("Code not executed in Safe preview") + ;(await expectNewOutput(`md"one"`)).toBe("one") + ;(await expectNewOutput(`a b c function`)).toContain("yntax") + ;(await expectNewOutput(`md"one"`)).toBe("one") + ;(await expectNewOutput(``)).toBe("") + ;(await expectNewOutput(`md"one"`)).toBe("one") + + await restartProcess(page) + await waitForPlutoToCalmDown(page) + + cell_contents = await getAllCellOutputs(page) + + expect(cell_contents[0]).toBe("one") + expect(cell_contents[1]).toBe("i should not be red\ntwo\nsafe\nDANGER") + expect(cell_contents[2]).toBe("three") + expect(cell_contents[3]).toBe("123") + expect(cell_contents[4]).toBe("") + expect(cell_contents[5]).toContain("yntax") + expect(cell_contents[6]).toBe("") + + expect(await page.evaluate(() => document.querySelector(`pluto-log-dot`).innerText)).toBe("four\nDANGER") + + expect(await page.evaluate(() => getComputedStyle(document.querySelector(`.zo`)).color)).toBe("rgb(255, 0, 0)") + }) +}) diff --git a/test/frontend/__tests__/slide_controls.js b/test/frontend/__tests__/slide_controls.js index 4817278313..15a50e07ea 100644 --- a/test/frontend/__tests__/slide_controls.js +++ b/test/frontend/__tests__/slide_controls.js @@ -1,8 +1,26 @@ +import puppeteer from "puppeteer" import { saveScreenshot, createPage, waitForContent } from "../helpers/common" -import { createNewNotebook, getPlutoUrl, manuallyEnterCells, setupPlutoBrowser, shutdownCurrentNotebook, waitForPlutoToCalmDown } from "../helpers/pluto" +import { + createNewNotebook, + getCellIds, + getPlutoUrl, + importNotebook, + manuallyEnterCells, + runAllChanged, + setupPlutoBrowser, + shutdownCurrentNotebook, + waitForPlutoToCalmDown, +} from "../helpers/pluto" describe("slideControls", () => { + /** + * Launch a shared browser instance for all tests. + * I don't use jest-puppeteer because it takes away a lot of control and works buggy for me, + * so I need to manually create the shared browser. + * @type {puppeteer.Browser} + */ let browser = null + /** @type {puppeteer.Page} */ let page = null beforeAll(async () => { @@ -11,7 +29,6 @@ describe("slideControls", () => { beforeEach(async () => { page = await createPage(browser) await page.goto(getPlutoUrl(), { waitUntil: "networkidle0" }) - await createNewNotebook(page) }) afterEach(async () => { await saveScreenshot(page) @@ -25,11 +42,8 @@ describe("slideControls", () => { }) it("should create titles", async () => { - const cells = ['md"# Slide 1"', 'md"# Slide 2"'] - const plutoCellIds = await manuallyEnterCells(page, cells) - await page.waitForSelector(".runallchanged", { visible: true, polling: 200, timeout: 0 }) - await page.click(".runallchanged") - await waitForPlutoToCalmDown(page, { polling: 100 }) + await importNotebook(page, "slides.jl", { permissionToRunCode: false }) + const plutoCellIds = await getCellIds(page) const content = await waitForContent(page, `pluto-cell[id="${plutoCellIds[1]}"] pluto-output`) expect(content).toBe("Slide 2") @@ -39,8 +53,10 @@ describe("slideControls", () => { expect(await slide_2_title.isIntersectingViewport()).toBe(true) expect(await slide_1_title.isIntersectingViewport()).toBe(true) - /* @ts-ignore */ - await page.evaluate(() => window.present()) + await page.click(`.toggle_export[title="Export..."]`) + await page.waitForTimeout(500) + await page.waitForSelector(".toggle_presentation", { visible: true }) + await page.click(".toggle_presentation") await page.click(".changeslide.next") expect(await slide_1_title.isIntersectingViewport()).toBe(true) diff --git a/test/frontend/fixtures/safe_preview.jl b/test/frontend/fixtures/safe_preview.jl new file mode 100644 index 0000000000..7fb6014e80 --- /dev/null +++ b/test/frontend/fixtures/safe_preview.jl @@ -0,0 +1,65 @@ +### A Pluto.jl notebook ### +# v0.19.29 + +using Markdown +using InteractiveUtils + +# ╔═╡ e28131d9-9877-4b44-8213-9e6c041b5da5 +md""" +one +""" + +# ╔═╡ ef63b97e-700d-11ee-2997-7bf929019c2d +html""" +
    +i should not be red +
    + +two + +
    safe
    + + + + + + +""" + +# ╔═╡ 99e2bfea-4e5d-4d94-bd96-77be7b04811d +html"three" + +# ╔═╡ 76e68adf-16ab-4e88-a601-3177f34db6ec +122 + 1 + +# ╔═╡ 873d58c2-8590-4bb3-bf9c-596b1cdbe402 +let + stuff = html""" +four +""" + @info stuff +end + +# ╔═╡ 55c74b79-41a6-461e-99c4-a61994673824 +modify me to make me safe + +# ╔═╡ f5209e95-761d-4861-a00d-b7e33a1b3d69 + + +# ╔═╡ Cell order: +# ╠═e28131d9-9877-4b44-8213-9e6c041b5da5 +# ╠═ef63b97e-700d-11ee-2997-7bf929019c2d +# ╠═99e2bfea-4e5d-4d94-bd96-77be7b04811d +# ╠═76e68adf-16ab-4e88-a601-3177f34db6ec +# ╠═873d58c2-8590-4bb3-bf9c-596b1cdbe402 +# ╠═55c74b79-41a6-461e-99c4-a61994673824 +# ╠═f5209e95-761d-4861-a00d-b7e33a1b3d69 diff --git a/test/frontend/fixtures/slides.jl b/test/frontend/fixtures/slides.jl new file mode 100644 index 0000000000..d903c7dce1 --- /dev/null +++ b/test/frontend/fixtures/slides.jl @@ -0,0 +1,23 @@ +### A Pluto.jl notebook ### +# v0.11.14 + +using Markdown +using InteractiveUtils + +# ╔═╡ cbcf36de-f360-11ea-0c7f-719e93324b27 +md"# Slide 1" + +# ╔═╡ d71c5ee2-f360-11ea-2753-a132fa41871a +md"# Slide 2" + +# ╔═╡ d8f5a4f6-f360-11ea-043d-47667f6a7e76 + + +# ╔═╡ dcd9ebb8-f360-11ea-2050-fd2e11d27c6d + + +# ╔═╡ Cell order: +# ╠═cbcf36de-f360-11ea-0c7f-719e93324b27 +# ╠═d71c5ee2-f360-11ea-2753-a132fa41871a +# ╠═d8f5a4f6-f360-11ea-043d-47667f6a7e76 +# ╠═dcd9ebb8-f360-11ea-2050-fd2e11d27c6d diff --git a/test/frontend/helpers/common.js b/test/frontend/helpers/common.js index 572d03e33b..42c7a73766 100644 --- a/test/frontend/helpers/common.js +++ b/test/frontend/helpers/common.js @@ -1,3 +1,4 @@ +import puppeteer from "puppeteer" import path from "path"; import mkdirp from "mkdirp"; import * as process from "process"; @@ -187,6 +188,7 @@ const blocked_domains = ["cdn.jsdelivr.net", "unpkg.com", "cdn.skypack.dev", "es const hide_warning = url => url.includes("mathjax") export const createPage = async (browser) => { + /** @type {puppeteer.Page} */ const page = await browser.newPage() failOnError(page); diff --git a/test/frontend/helpers/pluto.js b/test/frontend/helpers/pluto.js index 26c7621aac..1ad04d9520 100644 --- a/test/frontend/helpers/pluto.js +++ b/test/frontend/helpers/pluto.js @@ -12,6 +12,7 @@ import { lastElement, createPage, getArtifactsDir, + waitForContentToBecome, } from "./common" import path from "path" @@ -88,17 +89,27 @@ export const createNewNotebook = async (page) => { * @param {Page} page * @param {string} notebookName` */ -export const importNotebook = async (page, notebookName) => { +export const importNotebook = async (page, notebookName, { permissionToRunCode = true } = {}) => { // Copy notebook before using it, so we don't mess it up with test changes const notebookPath = getFixtureNotebookPath(notebookName) const artifactsPath = getTemporaryNotebookPath() fs.copyFileSync(notebookPath, artifactsPath) + await openPathOrURLNotebook(page, artifactsPath, { permissionToRunCode }) +} + +/** + * @param {Page} page + * @param {string} path_or_url + */ +export const openPathOrURLNotebook = async (page, path_or_url, { permissionToRunCode = true } = {}) => { const openFileInputSelector = "pluto-filepicker" - await writeSingleLineInPlutoInput(page, openFileInputSelector, artifactsPath) + await writeSingleLineInPlutoInput(page, openFileInputSelector, path_or_url) // await writeSingleLineInPlutoInput(page, openFileInputSelector, notebookPath) const openFileButton = "pluto-filepicker button" await clickAndWaitForNavigation(page, openFileButton) + // Give permission to run code in this notebook + if (permissionToRunCode) await restartProcess(page) await page.waitForTimeout(1000) await waitForPlutoToCalmDown(page) } @@ -112,21 +123,37 @@ export const getCellIds = (page) => page.evaluate(() => Array.from(document.quer * @param {Page} page */ export const restartProcess = async (page) => { + await page.waitForSelector(`a#restart-process-button`) await page.click(`a#restart-process-button`) + // page.once("dialog", async (dialog) => { + // await dialog.accept() + // }) + await page.waitForFunction(() => document?.querySelector(`a#restart-process-button`) == null) + await page.waitForSelector(`#process-status-tab-button.something_is_happening`) } -export const waitForPlutoToCalmDown = async (/** @type {puppeteer.Page} */ page, /** @type {{ polling: string | number; timeout?: number; }} */ options) => { +const waitForPlutoBusy = async (page, iWantBusiness, options) => { + await page.waitForTimeout(1) await page.waitForFunction( - () => - //@ts-ignore - document?.body?._update_is_ongoing === false && - //@ts-ignore - document?.body?._js_init_set?.size === 0 && - document?.body?.classList?.contains("loading") === false && - document?.querySelector(`#process-status-tab-button.something_is_happening`) == null && - document?.querySelector(`pluto-cell.running, pluto-cell.queued`) === null, - options + (iWantBusiness) => { + let quiet = //@ts-ignore + document?.body?._update_is_ongoing === false && + //@ts-ignore + document?.body?._js_init_set?.size === 0 && + document?.body?.classList?.contains("loading") === false && + document?.querySelector(`#process-status-tab-button.something_is_happening`) == null && + document?.querySelector(`pluto-cell.running, pluto-cell.queued, pluto-cell.internal_test_queued`) == null + + return iWantBusiness ? !quiet : quiet + }, + options, + iWantBusiness ) + await page.waitForTimeout(1) +} + +export const waitForPlutoToCalmDown = async (/** @type {puppeteer.Page} */ page, /** @type {{ polling: string | number; timeout?: number; }} */ options) => { + await waitForPlutoBusy(page, false, options) } /** @@ -138,6 +165,11 @@ export const waitForCellOutput = (page, cellId) => { return waitForContent(page, cellOutputSelector) } +/** + * @param {Page} page + */ +export const getAllCellOutputs = (page) => page.evaluate(() => Array.from(document.querySelectorAll(`pluto-cell > pluto-output`)).map((c) => c.innerText)) + /** * @param {Page} page * @param {string} cellId @@ -158,6 +190,18 @@ export const waitForNoUpdateOngoing = async (page, options = {}) => { ) } +/** + * @param {Page} page + */ +export const runAllChanged = async (page) => { + await page.waitForSelector(`.runallchanged`, { + visible: true, + }) + await page.click(`.runallchanged`) + await waitForPlutoBusy(page, true) + await waitForPlutoBusy(page, false) +} + /** * @param {Page} page * @param {string} plutoInputSelector @@ -197,6 +241,26 @@ export const keyboardPressInPlutoInput = async (page, plutoInputSelector, key) = return waitForContentToChange(page, `${plutoInputSelector} .cm-line`, currentLineText) } +/** + * @param {Page} page + * @param {string} plutoInputSelector + */ +export const clearPlutoInput = async (page, plutoInputSelector) => { + await page.waitForSelector(`${plutoInputSelector} .cm-editor`) + if ((await page.$(`${plutoInputSelector} .cm-placeholder`)) == null) { + await page.focus(`${plutoInputSelector} .cm-content`) + await page.waitForTimeout(500) + // Move to end of the input + await page.keyboard.down(platform === "darwin" ? "Meta" : "Control") + await page.keyboard.press("KeyA") + await page.keyboard.up(platform === "darwin" ? "Meta" : "Control") + // Press the key we care about + await page.keyboard.press("Delete") + // Wait for CodeMirror to process the input and display the text + await page.waitForSelector(`${plutoInputSelector} .cm-placeholder`) + } +} + /** * @param {Page} page * @param {string[]} cells