23
loading...
This website collects cookies to deliver better user experience
Gemfile
needs sinatra
and ferrum
, but I also got some extra packages to make JSON parsing and returning more automatic. They don't really save any lines of code for this trivial app, but it's one less thing to think about.source "https://rubygems.org"
gem "sinatra"
gem "sinatra-contrib"
gem "rack-contrib"
gem "ferrum"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Ferrum Sinatra Terminal App</title>
<link href="app.css" rel="stylesheet" type="text/css" />
</head>
<body>
<h1>Ferrum Sinatra Terminal App</h1>
<div id="terminal">
<div id="history">
</div>
<div class="input-line">
<span class="prompt">$</span>
<form>
<input type="text" autofocus />
</form>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
body {
background-color: #444;
color: #fff;
}
h1 {
font-family: monospace;
}
#terminal {
font-family: monospace;
}
.input-line {
display: flex;
}
.input-line > * {
flex: 1;
}
.input-line > .prompt {
flex: 0;
padding-right: 0.5rem;
}
.output {
padding-bottom: 0.5rem;
}
.input {
color: #ffa;
}
.output {
color: #afa;
white-space: pre;
}
form {
display: flex;
}
input {
flex: 1;
font-family: monospace;
background-color: #444;
color: #fff;
border: none;
}
let form = document.querySelector("form")
let input = document.querySelector("input")
let terminalHistory = document.querySelector("#history")
function createInputLine(command) {
let inputLine = document.createElement("div")
inputLine.className = "input-line"
let promptSpan = document.createElement("span")
promptSpan.className = "prompt"
promptSpan.append("$")
let inputSpan = document.createElement("span")
inputSpan.className = "input"
inputSpan.append(command)
inputLine.append(promptSpan)
inputLine.append(inputSpan)
return inputLine
}
function createTerminalHistoryEntry(command, commandOutput) {
let inputLine = createInputLine(command)
let output = document.createElement("div")
output.className = "output"
output.append(commandOutput)
terminalHistory.append(inputLine)
terminalHistory.append(output)
}
async function runCommand(command) {
let response = await fetch(
"http://localhost:4567/execute",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({command}),
},
)
if (!response.ok) {
throw `HTTP error ${response.status}`
}
return await response.json()
}
form.addEventListener("submit", async (e) => {
e.preventDefault()
let command = input.value
let {output} = await runCommand(command)
createTerminalHistoryEntry(command, output)
input.value = ""
input.scrollIntoView()
})
runCommand
function. Mostly to demonstrate "why you should use axios
" point I mentioned before. This is a fetch
code with correct wrappers for checking HTTP status, dealing with JSON input and output and so on. All this functionality would be provided by axios
code, so if we used axios
it would be a one-liner.def wait_for_url(url, response)
loop do
begin
sleep 1
break if URI.open(url).read == response
rescue
end
end
end
APP_URL = "http://localhost:4567"
SECRET_TOKEN = Random.alphanumeric(64)
Thread.new do
wait_for_url "#{APP_URL}/ping", "pong"
$browser = Ferrum::Browser.new(
headless: false,
browser_options: {
"app" => "#{APP_URL}/start?token=#{SECRET_TOKEN}",
},
)
puts "#{APP_URL}/start?token=#{SECRET_TOKEN}"
end
$browser
global variable. We don't do anything with the frontend except start it, but in principle we have full control over the frontend through it if we wanted.get "/ping" do
"pong"
end
/index.html
. For whichever reason Sinatra won't treat /
as /index.html
as same request, so redirect "/"
would need some extra code telling it that these mean the same thing:enable :sessions
get "/start" do
raise "Invalid token" unless params["token"] == SECRET_TOKEN
session["token"] = params["token"]
redirect "/index.html"
end
/execute
endpoint:use Rack::JSONBodyParser
post "/execute" do
raise "Invalid token" unless session["token"] == SECRET_TOKEN
command = params["command"]
# \n to force Ruby to go through shell even when it thinks it doesn't need to
output, status = Open3.capture2e("\n"+command)
json output: output
end
sinatra-contrib
and rack-contrib
we don't need to JSON.parse
and .to_json
ourselves.nonexistent_command
will raise exception instead of printing shell message we want. We can force it to use Shell with the \n
trick - it's a special character so it always triggers shell, but shell then ignores it. Really there should be shell: true
optional keyword argument.#!/usr/bin/env ruby
require "ferrum"
require "sinatra"
require "open-uri"
require "open3"
require "sinatra/json"
require "rack/contrib"
APP_URL = "http://localhost:4567"
SECRET_TOKEN = Random.alphanumeric(64)
enable :sessions
use Rack::JSONBodyParser
get "/ping" do
"pong"
end
get "/start" do
raise "Invalid token" unless params["token"] == SECRET_TOKEN
session["token"] = params["token"]
redirect "/index.html"
end
post "/execute" do
raise "Invalid token" unless session["token"] == SECRET_TOKEN
command = params["command"]
# \n to force Ruby to go through shell even when it thinks it doesn't need to
output, status = Open3.capture2e("\n"+command)
json output: output
end
def wait_for_url(url, response)
loop do
begin
sleep 1
break if URI.open(url).read == response
rescue
end
end
end
Thread.new do
wait_for_url "#{APP_URL}/ping", "pong"
$browser = Ferrum::Browser.new(
headless: false,
browser_options: {
"app" => "#{APP_URL}/start?token=#{SECRET_TOKEN}",
},
)
puts "#{APP_URL}/start?token=#{SECRET_TOKEN}"
end