diff --git a/core/assets/js/app.js b/core/assets/js/app.js index c66003216e..740b920846 100644 --- a/core/assets/js/app.js +++ b/core/assets/js/app.js @@ -32,6 +32,7 @@ import { PythonUploader } from "./python_uploader"; import { Clipboard } from "./clipboard"; import { DataDonationHook } from "./data_donation_hook"; import { Port } from "./port"; +import { FeldsparApp } from "./feldspar_app"; window.registerAPNSDeviceToken = registerAPNSDeviceToken; @@ -105,6 +106,7 @@ let Hooks = { PythonUploader, DataDonationHook, Port, + FeldsparApp, }; let liveSocket = new LiveSocket("/live", Socket, { diff --git a/core/assets/js/feldspar_app.js b/core/assets/js/feldspar_app.js new file mode 100644 index 0000000000..88ca67428d --- /dev/null +++ b/core/assets/js/feldspar_app.js @@ -0,0 +1,27 @@ +export const FeldsparApp = { + mounted() { + const iframe = this.getIframe(); + if (iframe.contentDocument.readyState === "complete") { + this.onFrameLoaded(); + } + else { + iframe.contentDocument.addEventListener("load", ()=>{ this.onFrameLoaded() }); + } + }, + + getIframe() { + return this.el.querySelector("iframe"); + }, + + onFrameLoaded() { + this.channel = new MessageChannel(); + this.channel.port1.onmessage = (e) => { + this.handleMessage(e); + }; + this.getIframe().contentWindow.postMessage("init", "*", [this.channel.port2]); + }, + + handleMessage(e) { + this.pushEvent("app_event", e.data); + } +}; diff --git a/core/config/dev.exs b/core/config/dev.exs index dd7d818537..0107ccb1f4 100644 --- a/core/config/dev.exs +++ b/core/config/dev.exs @@ -71,13 +71,14 @@ config :core, Systems.Email.Mailer, config :core, :apns_backend, Core.APNS.LoggingBackend -config :core, - :static_path, +static_path = File.cwd!() |> Path.join("tmp") |> Path.join("uploads") |> tap(&File.mkdir_p!/1) +config :core, :static_path, static_path + config :core, :admins, ["e.vanderveen@eyra.co"] @@ -96,6 +97,10 @@ config :ex_aws, access_key_id: "my_access_key", secret_access_key: "a_super_secret" +config :core, :feldspar, + backend: Systems.Feldspar.LocalFS, + local_fs_root_path: static_path + try do import_config "dev.secret.exs" rescue diff --git a/core/config/test.exs b/core/config/test.exs index adf1a82d41..dbdb03dd2d 100644 --- a/core/config/test.exs +++ b/core/config/test.exs @@ -54,3 +54,7 @@ config :core, Core.SurfConext, oidc_module: Core.SurfConext.FakeOIDC config :core, :bundle, :next config :core, :banking_backend, Systems.Banking.Dummy + +config :core, :feldspar, + backend: Systems.Feldspar.LocalFS, + local_fs_root_path: "/tmp" diff --git a/core/lib/core/authorization.ex b/core/lib/core/authorization.ex index 6083e29796..a0d6100340 100644 --- a/core/lib/core/authorization.ex +++ b/core/lib/core/authorization.ex @@ -61,6 +61,7 @@ defmodule Core.Authorization do grant_access(Systems.Project.ItemContentPage, [:researcher, :owner]) grant_access(Systems.Benchmark.ToolPage, [:owner]) grant_access(Systems.Benchmark.LeaderboardPage, [:visitor, :member]) + grant_access(Systems.Feldspar.AppPage, [:visitor, :member]) grant_access(CoreWeb.User.Signin, [:visitor]) grant_access(CoreWeb.User.Signup, [:visitor]) diff --git a/core/lib/core_web/endpoint.ex b/core/lib/core_web/endpoint.ex index 80779e6b90..2c7a050f83 100644 --- a/core/lib/core_web/endpoint.ex +++ b/core/lib/core_web/endpoint.ex @@ -1,5 +1,6 @@ defmodule CoreWeb.Endpoint do use Phoenix.Endpoint, otp_app: :core + require Systems.Feldspar.Plug # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. @@ -33,6 +34,8 @@ defmodule CoreWeb.Endpoint do ) end + Systems.Feldspar.Plug.setup() + # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phx.digest diff --git a/core/systems/feldspar/_public.ex b/core/systems/feldspar/_public.ex new file mode 100644 index 0000000000..c87cc867a0 --- /dev/null +++ b/core/systems/feldspar/_public.ex @@ -0,0 +1,15 @@ +defmodule Systems.Feldspar.Public do + import Systems.Feldspar.Internal, only: [get_backend: 0] + + def store(zip_file) do + get_backend().store(zip_file) + end + + def get_public_url(id) do + get_backend().get_public_url(id) + end + + def remove(id) do + get_backend().remove(id) + end +end diff --git a/core/systems/feldspar/_routes.ex b/core/systems/feldspar/_routes.ex new file mode 100644 index 0000000000..e2136d1d15 --- /dev/null +++ b/core/systems/feldspar/_routes.ex @@ -0,0 +1,10 @@ +defmodule Systems.Feldspar.Routes do + defmacro routes() do + quote do + scope "/apps", Systems.Feldspar do + pipe_through([:browser]) + live("/:id", AppPage) + end + end + end +end diff --git a/core/systems/feldspar/app_page.ex b/core/systems/feldspar/app_page.ex new file mode 100644 index 0000000000..444a593b5c --- /dev/null +++ b/core/systems/feldspar/app_page.ex @@ -0,0 +1,37 @@ +defmodule Systems.Feldspar.AppPage do + use CoreWeb, :live_view + alias CoreWeb.Endpoint + + @impl true + def mount(%{"id" => app_id}, _session, socket) do + app_url = Endpoint.static_url() <> Endpoint.static_path("/images/" <> app_id <> "/index.html") + + {:ok, assign(socket, app_url: app_url, error: nil)} + end + + @impl true + def handle_uri(socket), do: socket + + @impl true + def render(assigns) do + ~H""" +
+ <%!-- Ensure that updates don't alter the hierarchy in front of the iframe. + Changing the preceding siblings of the iframe would result in a reload of the iframe + due to Morphdom (https://github.com/patrick-steele-idem/morphdom/issues/200). + --%> +
+
<%= @error %>
+
+
+ +
+
+ """ + end + + @impl true + def handle_event("app_event", params, socket) do + {:noreply, assign(socket, :error, "Unsupported message: #{inspect(params)}")} + end +end diff --git a/core/systems/feldspar/internal.ex b/core/systems/feldspar/internal.ex new file mode 100644 index 0000000000..edaf4f6a36 --- /dev/null +++ b/core/systems/feldspar/internal.ex @@ -0,0 +1,7 @@ +defmodule Systems.Feldspar.Internal do + def get_backend do + :core + |> Application.fetch_env!(:feldspar) + |> Access.fetch!(:backend) + end +end diff --git a/core/systems/feldspar/local_fs.ex b/core/systems/feldspar/local_fs.ex new file mode 100644 index 0000000000..0957fff3af --- /dev/null +++ b/core/systems/feldspar/local_fs.ex @@ -0,0 +1,34 @@ +defmodule Systems.Feldspar.LocalFS do + def store(zip_file) do + id = Ecto.UUID.generate() + path = get_path(id) + File.mkdir!(path) + :zip.unzip(to_charlist(zip_file), cwd: to_charlist(path)) + id + end + + def storage_path(id) do + get_path(id) + end + + def get_public_url(id) do + base_url = Application.get_env(__MODULE__, :base_url, "https://example.org") + "#{base_url}/#{id}" + end + + def remove(id) do + with {:ok, _} <- File.rm_rf(get_path(id)) do + :ok + end + end + + defp get_path(id) do + Path.join(get_root_path(), id) + end + + def get_root_path do + :core + |> Application.get_env(:feldspar, []) + |> Access.fetch!(:local_fs_root_path) + end +end diff --git a/core/systems/feldspar/plugs.ex b/core/systems/feldspar/plugs.ex new file mode 100644 index 0000000000..2abfa04d02 --- /dev/null +++ b/core/systems/feldspar/plugs.ex @@ -0,0 +1,36 @@ +defmodule Systems.Feldspar.Plug do + @behaviour Plug + + defmacro setup() do + quote do + plug(Systems.Feldspar.Plug, at: "/web_apps") + end + end + + @impl true + def init(opts) do + opts + # Ensure that init works, from will be set dynamically later on + |> Keyword.put(:from, {nil, nil}) + |> Plug.Static.init() + end + + @impl true + def call( + conn, + options + ) do + call(Systems.Feldspar.Internal.get_backend(), conn, options) + end + + def call(Systems.Feldspar.LocalFS, conn, options) do + root_path = Systems.Feldspar.LocalFS.get_root_path() + options = Map.put(options, :from, root_path) + Plug.Static.call(conn, options) + end + + def call(_, conn, _options) do + # Other backends (they generate URL's to external systems) + conn + end +end diff --git a/core/systems/routes.ex b/core/systems/routes.ex index a7b2cb9443..5bc308306c 100644 --- a/core/systems/routes.ex +++ b/core/systems/routes.ex @@ -15,7 +15,8 @@ defmodule Systems.Routes do :lab, :data_donation, :benchmark, - :budget + :budget, + :feldspar ] end end diff --git a/core/test/systems/feldspar/app_page_test.exs b/core/test/systems/feldspar/app_page_test.exs new file mode 100644 index 0000000000..4b9a30e4ea --- /dev/null +++ b/core/test/systems/feldspar/app_page_test.exs @@ -0,0 +1,19 @@ +defmodule Systems.Feldspar.AppPageTest do + use CoreWeb.ConnCase + import Phoenix.ConnTest + import Phoenix.LiveViewTest + + describe "render an app page" do + test "renders page with iframe", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/apps/test") + assert html =~ " + Application.put_env(:core, :feldspar, conf) + end) + + folder_name = "temp_#{:crypto.strong_rand_bytes(16) |> Base.encode16()}" + + tmp_dir = + System.tmp_dir() + |> Path.join(folder_name) + + File.mkdir!(tmp_dir) + + on_exit(fn -> + File.rm_rf!(tmp_dir) + end) + + conf = + conf + |> Keyword.put(:backend, Systems.Feldspar.LocalFS) + |> Keyword.put(:local_fs_root_path, tmp_dir) + + Application.put_env( + :core, + :feldspar, + conf + ) + + {:ok, tmp_dir: tmp_dir, app_conf: conf} + end + + test "call with LocalFS backend serves static content", %{tmp_dir: tmp_dir} do + tmp_dir + |> Path.join("plug_test.txt") + |> File.write("hello world!") + + opts = Plug.init(at: "/web_apps") + conn = Plug.call(conn(:get, "/web_apps/plug_test.txt"), opts) + assert "hello world!" == conn.resp_body + end + + test "call with other backends doesn't serve static content", %{app_conf: conf} do + Application.put_env( + :core, + :feldspar, + Keyword.put(conf, :backend, Systems.Feldspar.FakeBackend) + ) + + opts = Plug.init(at: "/web_apps") + conn = Plug.call(conn(:get, "/web_apps/plug_test.txt"), opts) + assert nil == conn.resp_body + end +end