Shell: Add file system browser to retrieve arbitrary files

This commit is contained in:
Sebastian Serth
2022-10-04 15:21:06 +02:00
committed by Sebastian Serth
parent 60078701f5
commit 58548555a5
13 changed files with 240 additions and 38 deletions

View File

@ -2,17 +2,17 @@ $(document).on('turbolinks:load', function () {
const ENTER_KEY_CODE = 13; const ENTER_KEY_CODE = 13;
const clearOutput = function () { const clearOutput = function () {
$('#output').html(''); log.html('');
}; };
const executeCommand = function (command) { const executeCommand = function (command) {
$.ajax({ $.ajax({
data: { data: {
command: command, command: command,
sudo: $('#sudo').is(':checked') sudo: sudo.is(':checked')
}, },
method: 'POST', method: 'POST',
url: $('#shell').data('url') url: Routes.execute_command_execution_environment_path(id)
}).done(handleResponse); }).done(handleResponse);
}; };
@ -46,7 +46,7 @@ $(document).on('turbolinks:load', function () {
em.text(command); em.text(command);
const p = $('<p>'); const p = $('<p>');
p.append(em) p.append(em)
$('#output').append(p); log.append(p);
}; };
const printOutput = function (output) { const printOutput = function (output) {
@ -55,22 +55,21 @@ $(document).on('turbolinks:load', function () {
const element = $('<p>'); const element = $('<p>');
element.addClass('text-success'); element.addClass('text-success');
element.text(output.stdout); element.text(output.stdout);
$('#output').append(element); log.append(element);
} }
if (output.stderr) { if (output.stderr) {
const element = $('<p>'); const element = $('<p>');
element.addClass('text-warning'); element.addClass('text-warning');
element.text(output.stderr); element.text(output.stderr);
$('#output').append(element); log.append(element);
} }
if (!output.stdout && !output.stderr) { if (!output.stdout && !output.stderr) {
const element = $('<p>'); const element = $('<p>');
element.addClass('text-muted'); element.addClass('text-muted');
const output = $('#output'); element.text(log.data('message-no-output'));
element.text(output.data('message-no-output')); log.append(element);
output.append(element);
} }
} }
}; };
@ -79,26 +78,99 @@ $(document).on('turbolinks:load', function () {
const element = $('<p>'); const element = $('<p>');
element.addClass('text-danger'); element.addClass('text-danger');
element.text($('#shell').data('message-timeout')); element.text($('#shell').data('message-timeout'));
$('#output').append(element); log.append(element);
}; };
const printOutOfMemory = function (output) { const printOutOfMemory = function (output) {
const element = $('<p>'); const element = $('<p>');
element.addClass('text-danger'); element.addClass('text-danger');
element.text($('#shell').data('message-out-of-memory')); element.text($('#shell').data('message-out-of-memory'));
$('#output').append(element); log.append(element);
}; };
if ($('#shell').isPresent()) { const retrieveFiles = function () {
const command = $('#command') let fileTree = $('#download-file-tree');
command.focus();
command.on('keypress', handleKeyPress);
const sudo = $('#sudo'); // Get current instance of the jstree if available and refresh the existing one.
sudo.on('change', function () { // Otherwise, initialize a new one.
sudo.parent().toggleClass('text-muted') if (fileTree.jstree(true)) {
command.focus(); return fileTree.jstree('refresh');
}); } else {
fileTree.removeClass('my-3 justify-content-center');
fileTree.jstree({
'core': {
'data': {
'url': function (node) {
const params = {sudo: sudo.is(':checked')};
return Routes.list_files_in_execution_environment_path(id, params);
},
'data': function (node) {
return {'path': getPath(fileTree.jstree(), node)|| '/'};
}
}
}
});
fileTree.on('select_node.jstree', function (node, selected, _event) {
// We never want a node to be selected permanently, so we deselect it immediately.
selected.instance.deselect_all();
const path = getPath(selected.instance, selected.node)
const params = {sudo: sudo.is(':checked')};
const downloadPath = Routes.download_file_from_execution_environment_path(id, path, params);
// Now we download the file if allowed.
if (selected.node.original.icon.split(" ").some(function (icon) {
return ['fa-lock', 'fa-folder'].includes(icon);
})) {
$.flash.danger({
icon: ['fa-solid', 'fa-shield-halved'],
text: I18n.t('execution_environments.shell.file_tree.permission_denied')
});
} else {
window.location = downloadPath;
}
}.bind(this));
}
} }
const getPath = function (jstree, node) {
if (node.id === '#') {
// Root node
return '/'
}
// We build the path to the file by concatenating the paths of all parent nodes.
let file_path = node.parents.reverse().map(function (id) {
return jstree.get_text(id);
}).filter(function (text) {
return text !== false;
}).join('/');
return `${node.parent !== '#' ? '/' : ''}${file_path}${node.original.path}`;
}
const shell = $('#shell');
if (!shell.isPresent()) {
return;
}
const command = $('#command')
command.focus();
command.on('keypress', handleKeyPress);
const id = shell.data('id');
const log = $('#output');
const sudo = $('#sudo');
sudo.on('change', function () {
sudo.parent().toggleClass('text-muted')
command.focus();
});
$('#reload-files').on('click', function () {
new bootstrap.Collapse('#collapse_files', 'show');
retrieveFiles();
});
$('#reload-now-link').on('click', retrieveFiles);
}) })
; ;

View File

@ -40,11 +40,11 @@ input[type='file'] {
} }
} }
[data-bs-toggle="collapse"] .fa-solid:before { [data-bs-toggle="collapse"] .fa-solid:first-child:before {
content: "\f139"; content: "\f139";
} }
[data-bs-toggle="collapse"].collapsed .fa-solid:before { [data-bs-toggle="collapse"].collapsed .fa-solid:first-child:before {
content: "\f13a"; content: "\f13a";
} }

View File

@ -2,9 +2,10 @@
class ExecutionEnvironmentsController < ApplicationController class ExecutionEnvironmentsController < ApplicationController
include CommonBehavior include CommonBehavior
include FileConversion
before_action :set_docker_images, only: %i[create edit new update] before_action :set_docker_images, only: %i[create edit new update]
before_action :set_execution_environment, only: MEMBER_ACTIONS + %i[execute_command shell statistics sync_to_runner_management] before_action :set_execution_environment, only: MEMBER_ACTIONS + %i[execute_command shell list_files statistics sync_to_runner_management]
before_action :set_testing_framework_adapters, only: %i[create edit new update] before_action :set_testing_framework_adapters, only: %i[create edit new update]
def authorize! def authorize!
@ -30,11 +31,24 @@ class ExecutionEnvironmentsController < ApplicationController
def execute_command def execute_command
runner = Runner.for(current_user, @execution_environment) runner = Runner.for(current_user, @execution_environment)
sudo = ActiveModel::Type::Boolean.new.cast(params[:sudo]) @privileged_execution = ActiveModel::Type::Boolean.new.cast(params[:sudo]) || @execution_environment.privileged_execution
output = runner.execute_command(params[:command], privileged_execution: sudo, raise_exception: false) output = runner.execute_command(params[:command], privileged_execution: @privileged_execution, raise_exception: false)
render json: output.except(:messages) render json: output.except(:messages)
end end
def list_files
runner = Runner.for(current_user, @execution_environment)
@privileged_execution = ActiveModel::Type::Boolean.new.cast(params[:sudo]) || @execution_environment.privileged_execution
begin
files = runner.retrieve_files(path: params[:path], recursive: false, privileged_execution: @privileged_execution)
downloadable_files, additional_directories = convert_files_json_to_files files
js_tree = FileTree.new(downloadable_files, additional_directories, force_closed: true).to_js_tree
render json: js_tree[:core][:data]
rescue Runner::Error::WorkspaceError
render json: []
end
end
def working_time_query def working_time_query
" "
SELECT exercise_id, avg(working_time) as average_time, stddev_samp(extract('epoch' from working_time)) * interval '1 second' as stddev_time SELECT exercise_id, avg(working_time) as average_time, stddev_samp(extract('epoch' from working_time)) * interval '1 second' as stddev_time
@ -225,4 +239,14 @@ class ExecutionEnvironmentsController < ApplicationController
redirect_to ExecutionEnvironment, alert: t('execution_environments.index.synchronize_all.failure') redirect_to ExecutionEnvironment, alert: t('execution_environments.index.synchronize_all.failure')
end end
end end
def augment_files_for_download(files)
files.map do |file|
# Downloadable files get an indicator whether we performed a privileged execution.
# The download path is added dynamically in the frontend.
file.privileged_execution = @privileged_execution
file
end
end
private :augment_files_for_download
end end

View File

@ -21,12 +21,21 @@ class LiveStreamsController < ApplicationController
send_runner_file(runner, desired_file, fallback_location) send_runner_file(runner, desired_file, fallback_location)
end end
def download_arbitrary_file
@execution_environment = authorize ExecutionEnvironment.find(params[:id])
desired_file = params[:filename].to_s
runner = Runner.for(current_user, @execution_environment)
fallback_location = shell_execution_environment_path(@execution_environment)
privileged = params[:sudo] || @execution_environment.privileged_execution?
send_runner_file(runner, desired_file, fallback_location, privileged: privileged)
end
private private
def send_runner_file(runner, desired_file, redirect_fallback = root_path) def send_runner_file(runner, desired_file, redirect_fallback = root_path, privileged: false)
filename = File.basename(desired_file) filename = File.basename(desired_file)
send_stream(filename: filename, disposition: 'attachment') do |stream| send_stream(filename: filename, disposition: 'attachment') do |stream|
runner.download_file desired_file do |chunk, overall_size, content_type| runner.download_file desired_file, privileged_execution: privileged do |chunk, overall_size, content_type|
unless response.committed? unless response.committed?
# Disable Rack::ETag, which would otherwise cause the response to be cached # Disable Rack::ETag, which would otherwise cause the response to be cached
# See https://github.com/rack/rack/issues/1619#issuecomment-848460528 # See https://github.com/rack/rack/issues/1619#issuecomment-848460528

View File

@ -11,6 +11,8 @@ module CodeOcean
ROLES = %w[regular_file main_file reference_implementation executable_file teacher_defined_test user_defined_file ROLES = %w[regular_file main_file reference_implementation executable_file teacher_defined_test user_defined_file
user_defined_test teacher_defined_linter].freeze user_defined_test teacher_defined_linter].freeze
TEACHER_DEFINED_ROLES = ROLES - %w[user_defined_file] TEACHER_DEFINED_ROLES = ROLES - %w[user_defined_file]
OWNER_READ_PERMISSION = 0o400
OTHER_READ_PERMISSION = 0o004
after_initialize :set_default_values after_initialize :set_default_values
before_validation :clear_weight, unless: :teacher_defined_assessment? before_validation :clear_weight, unless: :teacher_defined_assessment?
@ -19,7 +21,8 @@ module CodeOcean
attr_writer :size attr_writer :size
# These attributes are mainly used when retrieving files from a runner # These attributes are mainly used when retrieving files from a runner
attr_accessor :download_path attr_accessor :download_path, :owner, :group, :privileged_execution
attr_reader :permissions
belongs_to :context, polymorphic: true belongs_to :context, polymorphic: true
belongs_to :file, class_name: 'CodeOcean::File', optional: true # This is only required for submissions and is validated below belongs_to :file, class_name: 'CodeOcean::File', optional: true # This is only required for submissions and is validated below
@ -152,5 +155,31 @@ module CodeOcean
content.size content.size
end end
end end
def permissions=(permission_string)
# We iterate through the permission string (e.g., `rwxrw-r--`) as received through Linux
# For each character in the string, we check for a corresponding permission (which is available if the character is not `-`)
# Then, we use a bit shift to move a `1` to the position of the given permission.
# First, it is moved within a group (e.g., `r` in `rwx` is moved twice to the left, `w` once, `x` not at all)
# Second, the bit is moved in accordance with the group (e.g., the `owner` is moved twice, the `group` once, the `other` group not at all)
# Finally, a sum is created, which technically could be an OR operation as well.
@permissions = permission_string.chars.map.with_index do |permission, index|
next 0 if permission == '-' # No permission
bit = 0b1 << ((2 - index) % 3) # Align bit in respective group
bit << ((2 - (index / 3)) * 3) # Align bit in bytes (for the group)
end.sum
end
def missing_read_permissions?
return false if permissions.blank?
# We use a bitwise AND with the permission bits and compare that to zero
if privileged_execution.present?
(permissions & OWNER_READ_PERMISSION).zero?
else
(permissions & OTHER_READ_PERMISSION).zero?
end
end
end end
end end

View File

@ -1,7 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class ExecutionEnvironmentPolicy < AdminOnlyPolicy class ExecutionEnvironmentPolicy < AdminOnlyPolicy
%i[execute_command? shell? statistics? show? sync_to_runner_management?].each do |action| # download_arbitrary_file? is used in the live_streams_controller.rb
%i[execute_command? shell? list_files? statistics? show? sync_to_runner_management? download_arbitrary_file?].each do |action|
define_method(action) { admin? || author? } define_method(action) { admin? || author? }
end end

View File

@ -1,11 +1,29 @@
h1 = @execution_environment h1 = @execution_environment
#shell data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @execution_environment.permitted_execution_time) data-message-out-of-memory=t('exercises.editor.out_of_memory', memory_limit: @execution_environment.memory_limit) data-url=execute_command_execution_environment_path(@execution_environment) #shell data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @execution_environment.permitted_execution_time) data-message-out-of-memory=t('exercises.editor.out_of_memory', memory_limit: @execution_environment.memory_limit) data-id=@execution_environment.id
label.form-label for='command' = t('execution_environments.shell.command') label.form-label for='command' = t('execution_environments.shell.command')
.input-group.mb-3 .input-group.mb-3
.input-group-text.form-switch.ps-5.text-muted .input-group-text.form-switch.ps-5.text-muted
input#sudo.form-check-input.mt-0 type='checkbox' input#sudo.form-check-input.mt-0 type='checkbox'
label.ms-2 for='sudo' = 'sudo' label.ms-2 for='sudo' = 'sudo'
input#command.form-control type='text' input#command.form-control type='text'
.card.mb-3
.card-header#download-files role="tab"
a.file-heading.collapsed.d-flex.justify-content-between.align-items-center data-bs-toggle="collapse" href="#collapse_files" aria-expanded="false"
div.clearfix role="button"
i.fa-solid aria-hidden="true"
span = t('execution_environments.shell.file_tree.headline')
div
= render('exercises/editor_button', classes: 'btn-default btn-sm', data: {:'data-bs-toggle' => 'tooltip', :'data-url' => list_files_in_execution_environment_path(@execution_environment)}, icon: 'fa-solid fa-arrows-rotate', id: 'reload-files', label: t('execution_environments.shell.file_tree.reload'), title: t('execution_environments.shell.file_tree.reload_tooltip'))
.card-collapse.collapse id="collapse_files" role="tabpanel"
.card-body.pt-0.pe-0.ps-1.pb-1
#download-file-tree.justify-content-center.d-flex.my-3
span.mx-1 = t('execution_environments.shell.file_tree.empty')
button#reload-now-link.btn.btn-link.p-0.m-0.border-0 = t('execution_environments.shell.file_tree.list_now')
.card-footer.justify-content-center.align-items-center.d-flex.text-muted
i.fa-solid.fa-info
span.ms-2 = t('execution_environments.shell.file_tree.root_notice')
pre#output data-message-no-output=t('exercises.implement.no_output', timestamp: l(Time.now, format: :short)) pre#output data-message-no-output=t('exercises.implement.no_output', timestamp: l(Time.now, format: :short))
p = t('exercises.implement.no_output_yet') p = t('exercises.implement.no_output_yet')

View File

@ -326,6 +326,14 @@ de:
shell: shell:
command: Befehl command: Befehl
headline: Shell headline: Shell
file_tree:
reload: Aktualisieren
reload_tooltip: Aktualisieren Sie die Liste der verfügbaren Dateien im Runners. Diese Aktion wird einige Sekunden in Anspruch nehmen.
list_now: Jetzt laden.
headline: Dateisystem
empty: Das Dateisystem wurde bisher noch nicht aufgelistet.
root_notice: Dateien werden standardmäßig mit einem nicht-priviligerten Nutzer abgerufen. Um Dateien als "root" abzurufen, müssen Sie den "sudo" Schalter neben der Befehlszeile aktivieren und anschließend das Dateisystem vor dem Herunterladen einer Datei aktualisieren.
permission_denied: Der Zugriff auf die angeforderte Datei wurde verweigert. Bitte überprüfen Sie, dass die Datei existiert, der aktuelle Benutzer Leseberechtigungen besitzt und versuchen Sie ggf. die Datei mit "root"-Rechten anzufordern. Dazu müssen Sie den "sudo"-Schalter neben der Befehlszeile aktivieren und anschließend das Dateisystem vor dem Herunterladen einer Datei aktualisieren.
statistics: statistics:
exercise: Übung exercise: Übung
users: Anzahl (externer) Nutzer users: Anzahl (externer) Nutzer

View File

@ -326,6 +326,14 @@ en:
shell: shell:
command: Command command: Command
headline: Shell headline: Shell
file_tree:
reload: Reload
reload_tooltip: Reload the file system of the runner. This action might take a few seconds.
list_now: List now.
headline: File System
empty: The file system has not been queried yet.
root_notice: Files are retrieved with a non-privileged user by default. To retrieve files as "root", you must enable the "sudo" switch shown next to the command input and then reload the file system before accessing any file.
permission_denied: Access to the requested file has been denied. Please verify that the file exists, the current user has read permissions, and try requesting the file with "root" privileges if necessary. To retrieve files as "root", you must enable the "sudo" switch shown next to the command input and then reload the file system before accessing any file.
statistics: statistics:
exercise: Exercise exercise: Exercise
users: (External) Users Count users: (External) Users Count

View File

@ -66,6 +66,8 @@ Rails.application.routes.draw do
member do member do
get :shell get :shell
post 'shell', as: :execute_command, action: :execute_command post 'shell', as: :execute_command, action: :execute_command
get :list_files, as: :list_files_in
get 'download/:filename', as: :download_file_from, constraints: {filename: FILENAME_REGEXP}, action: :download_arbitrary_file, controller: 'live_streams'
get :statistics get :statistics
post :sync_to_runner_management post :sync_to_runner_management
end end

View File

@ -2,6 +2,8 @@
class FileTree class FileTree
def file_icon(file) def file_icon(file)
return 'fa-solid fa-lock' if file.missing_read_permissions?
if file.file_type.audio? if file.file_type.audio?
'fa-regular fa-file-audio' 'fa-regular fa-file-audio'
elsif file.file_type.compressed? elsif file.file_type.compressed?
@ -35,9 +37,13 @@ class FileTree
end end
private :folder_icon private :folder_icon
def initialize(files = []) # @param [CodeOcean::File] files The files to be displayed in the tree.
# @param [String] directories Additional directories to be displayed in the tree
# @param [Boolean] force_closed Specify whether the tree should be closed by default
def initialize(files = [], directories = [], force_closed: false)
# Our tree needs a root node, but we won't display it. # Our tree needs a root node, but we won't display it.
@root = Tree::TreeNode.new('ROOT') @root = Tree::TreeNode.new('ROOT')
@force_closed = force_closed
files.uniq(&:filepath).each do |file| files.uniq(&:filepath).each do |file|
parent = @root parent = @root
@ -47,25 +53,34 @@ class FileTree
end end
parent.add(Tree::TreeNode.new(file.name_with_extension, file)) parent.add(Tree::TreeNode.new(file.name_with_extension, file))
end end
directories.uniq.each do |directory|
parent = @root
(directory || '').split('/').each do |segment|
node = parent.children.detect {|child| child.name == segment } || parent.add(Tree::TreeNode.new(segment))
parent = node
end
end
end end
def map_to_js_tree(node) def map_to_js_tree(node)
{ {
children: node.children.map {|child| map_to_js_tree(child) }, children: children(node),
icon: node_icon(node), icon: node_icon(node),
id: node.content.try(:ancestor_id), id: node.content.try(:ancestor_id),
state: { state: {
disabled: !node.leaf?, disabled: !(node.leaf? && node.content.is_a?(CodeOcean::File)),
opened: !node.leaf?, opened: !(node.leaf? || @force_closed),
}, },
text: name(node), text: name(node),
download_path: node.content.try(:download_path), download_path: node.content.try(:download_path),
path: node.content.try(:download_path) ? nil : path(node),
} }
end end
private :map_to_js_tree private :map_to_js_tree
def node_icon(node) def node_icon(node)
if node.leaf? && !node.root? if node.leaf? && !node.root? && node.content.is_a?(CodeOcean::File)
file_icon(node.content) file_icon(node.content)
else else
folder_icon folder_icon
@ -73,6 +88,17 @@ class FileTree
end end
private :node_icon private :node_icon
def children(node)
if node.children.present? || node.content.is_a?(CodeOcean::File)
node.children.sort_by {|n| n.name.downcase }.map {|child| map_to_js_tree(child) }
else
# Folders added manually should always be expandable and therefore might have children.
# This allows users to open the folder and get a refreshed view, even if it might be empty.
true
end
end
private :children
def name(node) def name(node)
# We just need any information that is only present in files retrieved from the runner's file system. # We just need any information that is only present in files retrieved from the runner's file system.
# In our case, that is the presence of the `privileged_execution` attribute. # In our case, that is the presence of the `privileged_execution` attribute.
@ -84,10 +110,15 @@ class FileTree
end end
private :name private :name
def path(node)
"#{node.parentage&.reverse&.drop(1)&.map(&:name)&.join('/')}/#{node.name}"
end
private :path
def to_js_tree def to_js_tree
{ {
core: { core: {
data: @root.children.map {|child| map_to_js_tree(child) }, data: @root.children.sort_by {|node| node.name.downcase }.map {|child| map_to_js_tree(child) },
}, },
} }
end end

View File

@ -161,7 +161,7 @@ describe FileTree do
end end
context 'with leaf nodes' do context 'with leaf nodes' do
let(:node) { root.add(Tree::TreeNode.new('')) } let(:node) { root.add(Tree::TreeNode.new('', CodeOcean::File.new)) }
it 'is a file icon' do it 'is a file icon' do
expect(file_tree).to receive(:file_icon) expect(file_tree).to receive(:file_icon)

View File

@ -12,6 +12,6 @@ describe 'execution_environments/shell.html.slim' do
it 'contains the required data attributes' do it 'contains the required data attributes' do
expect(rendered).to have_css('#shell[data-message-timeout]') expect(rendered).to have_css('#shell[data-message-timeout]')
expect(rendered).to have_css("#shell[data-url='#{execute_command_execution_environment_path(execution_environment)}']") expect(rendered).to have_css("#shell[data-id='#{execution_environment.id}']")
end end
end end