Skip to content

Commit

Permalink
Basic infrastructure to support Feldspar
Browse files Browse the repository at this point in the history
  • Loading branch information
vloothuis committed Sep 21, 2023
1 parent 9d585d2 commit 494aef6
Show file tree
Hide file tree
Showing 18 changed files with 302 additions and 3 deletions.
2 changes: 2 additions & 0 deletions core/assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -105,6 +106,7 @@ let Hooks = {
PythonUploader,
DataDonationHook,
Port,
FeldsparApp,
};

let liveSocket = new LiveSocket("/live", Socket, {
Expand Down
27 changes: 27 additions & 0 deletions core/assets/js/feldspar_app.js
Original file line number Diff line number Diff line change
@@ -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);
}
};
9 changes: 7 additions & 2 deletions core/config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
["[email protected]"]
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions core/config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions core/lib/core/authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
3 changes: 3 additions & 0 deletions core/lib/core_web/endpoint.ex
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions core/systems/feldspar/_public.ex
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions core/systems/feldspar/_routes.ex
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions core/systems/feldspar/app_page.ex
Original file line number Diff line number Diff line change
@@ -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"""
<div class="flex flex-col w-full h-screen">
<%!-- 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).
--%>
<div>
<div :if={@error} class="bg-[#ff00ff] text-white p-8 text-xl"><%= @error %></div>
</div>
<div phx-update="ignore" id="web-app-frame" phx-hook="FeldsparApp">
<iframe src={@app_url} class="grow"></iframe>
</div>
</div>
"""
end

@impl true
def handle_event("app_event", params, socket) do
{:noreply, assign(socket, :error, "Unsupported message: #{inspect(params)}")}
end
end
7 changes: 7 additions & 0 deletions core/systems/feldspar/internal.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule Systems.Feldspar.Internal do
def get_backend do
:core
|> Application.fetch_env!(:feldspar)
|> Access.fetch!(:backend)
end
end
34 changes: 34 additions & 0 deletions core/systems/feldspar/local_fs.ex
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions core/systems/feldspar/plugs.ex
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion core/systems/routes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ defmodule Systems.Routes do
:lab,
:data_donation,
:benchmark,
:budget
:budget,
:feldspar
]
end
end
Expand Down
19 changes: 19 additions & 0 deletions core/test/systems/feldspar/app_page_test.exs
Original file line number Diff line number Diff line change
@@ -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 =~ "<iframe"
end
end

describe "handle app_event" do
test "can receive random app_event data", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/apps/test")
assert render_hook(view, :app_event, %{unexpected_key: "some data"}) =~ "Unsupported message:"
end
end
end
Binary file added core/test/systems/feldspar/hello.zip
Binary file not shown.
1 change: 1 addition & 0 deletions core/test/systems/feldspar/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello World!
35 changes: 35 additions & 0 deletions core/test/systems/feldspar/local_fs_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule Systems.Feldspar.LocalFSTest do
use ExUnit.Case, async: true

alias Systems.Feldspar.LocalFS

describe "store/1" do
test "extracts zip and stores files on disk" do
id = LocalFS.store(Path.join(__DIR__, "hello.zip"))
path = LocalFS.storage_path(id)
assert ["index.html"] == File.ls!(path)
end

# TODO:
# filters files? .exe etc.?
# runs virus scanner? clamav
end

describe "get_public_url/1" do
test "returns URL" do
id = Ecto.UUID.generate()
url = LocalFS.get_public_url(id)
uri = URI.parse(url)
assert String.contains?(uri.path, id)
end
end

describe "remove/1" do
test "removes folder" do
id = LocalFS.store(Path.join(__DIR__, "hello.zip"))
path = LocalFS.storage_path(id)
assert :ok == LocalFS.remove(id)
refute File.exists?(path)
end
end
end
62 changes: 62 additions & 0 deletions core/test/systems/feldspar/plug_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
defmodule Systems.Feldspar.PlugTest do
use ExUnit.Case
use Plug.Test

require Systems.Feldspar.Plug
alias Systems.Feldspar.Plug

setup do
conf = Application.get_env(:core, :feldspar, [])

on_exit(fn ->
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

0 comments on commit 494aef6

Please sign in to comment.