Files
codeocean/app/controllers/submissions_controller.rb
Sebastian Serth a0d8b30ef2 Implement support for some basic embed options for work sheets via LTI
This commit also fixes an issue with the flash messages being positioned too high and displayed for too long
2018-12-11 14:29:36 +01:00

420 lines
15 KiB
Ruby

class SubmissionsController < ApplicationController
include ActionController::Live
include CommonBehavior
include Lti
include SubmissionParameters
include SubmissionScoring
include Tubesock::Hijack
before_action :set_submission, only: [:download, :download_file, :render_file, :run, :score, :extract_errors, :show, :statistics, :stop, :test]
before_action :set_docker_client, only: [:run, :test]
before_action :set_files, only: [:download, :download_file, :render_file, :show]
before_action :set_file, only: [:download_file, :render_file]
before_action :set_mime_type, only: [:download_file, :render_file]
skip_before_action :verify_authenticity_token, only: [:download_file, :render_file]
def max_run_output_buffer_size
if(@submission.cause == 'requestComments')
5000
else
500
end
end
def authorize!
authorize(@submission || @submissions)
end
private :authorize!
def create
@submission = Submission.new(submission_params)
authorize!
copy_comments
create_and_respond(object: @submission)
end
def command_substitutions(filename)
{class_name: File.basename(filename, File.extname(filename)).camelize, filename: filename, module_name: File.basename(filename, File.extname(filename)).underscore}
end
private :command_substitutions
def copy_comments
# copy each annotation and set the target_file.id
unless(params[:annotations_arr].nil?)
params[:annotations_arr].each do | annotation |
#comment = Comment.new(annotation[1].permit(:user_id, :file_id, :user_type, :row, :column, :text, :created_at, :updated_at))
comment = Comment.new(:user_id => annotation[1][:user_id], :file_id => annotation[1][:file_id], :user_type => current_user.class.name, :row => annotation[1][:row], :column => annotation[1][:column], :text => annotation[1][:text])
source_file = CodeOcean::File.find(annotation[1][:file_id])
# retrieve target file
target_file = @submission.files.detect do |file|
# file_id has to be that of a the former iteration OR of the initial file (if this is the first run)
file.file_id == source_file.file_id || file.file_id == source_file.id #seems to be needed here: (check this): || file.file_id == source_file.id ; yes this is needed, for comments on templates as well as comments on files added by users.
end
#save to assign an id
target_file.save!
comment.file_id = target_file.id
comment.save!
end
end
end
def download
# files = @submission.files.map{ }
# zipline( files, 'submission.zip')
# send_data(@file.content, filename: @file.name_with_extension)
id_file = create_remote_evaluation_mapping
require 'zip'
stringio = Zip::OutputStream.write_buffer do |zio|
@files.each do |file|
zio.put_next_entry(file.path.to_s == '' ? file.name_with_extension : File.join(file.path, file.name_with_extension))
zio.write(file.content)
end
# zip exercise description
zio.put_next_entry(t('activerecord.models.exercise.one') + '.txt')
zio.write(@submission.exercise.title + "\r\n======================\r\n")
zio.write(@submission.exercise.description)
# zip .co file
zio.put_next_entry(".co")
zio.write(File.read id_file)
File.delete(id_file) if File.exist?(id_file)
# zip client scripts
scripts_path = 'app/assets/remote_scripts'
Dir.foreach(scripts_path) do |file|
next if file == '.' or file == '..'
zio.put_next_entry(File.join('.scripts', File.basename(file)))
zio.write(File.read File.join(scripts_path, file))
end
end
send_data(stringio.string, filename: @submission.exercise.title.tr(" ", "_") + ".zip")
end
def download_file
if @file.native_file?
send_file(@file.native_file.path)
else
send_data(@file.content, filename: @file.name_with_extension)
end
end
def index
@search = Submission.search(params[:q])
@submissions = @search.result.includes(:exercise, :user).paginate(page: params[:page])
authorize!
end
def render_file
if @file.native_file?
send_file(@file.native_file.path, disposition: 'inline')
else
render(plain: @file.content)
end
end
def run
# TODO reimplement SSEs with websocket commands
# with_server_sent_events do |server_sent_event|
# output = @docker_client.execute_run_command(@submission, params[:filename])
# server_sent_event.write({stdout: output[:stdout]}, event: 'output') if output[:stdout]
# server_sent_event.write({stderr: output[:stderr]}, event: 'output') if output[:stderr]
# end
hijack do |tubesock|
if @embed_options[:disable_run]
kill_socket(tubesock)
return
end
# probably add:
# ensure
# #guarantee that the thread is releasing the DB connection after it is done
# ApplicationRecord.connectionpool.releaseconnection
# end
Thread.new { EventMachine.run } unless EventMachine.reactor_running? && EventMachine.reactor_thread.alive?
# socket is the socket into the container, tubesock is the socket to the client
# give the docker_client the tubesock object, so that it can send messages (timeout)
@docker_client.tubesock = tubesock
result = @docker_client.execute_run_command(@submission, params[:filename])
tubesock.send_data JSON.dump({'cmd' => 'status', 'status' => result[:status]})
if result[:status] == :container_running
socket = result[:socket]
command = result[:command]
socket.on :message do |event|
Rails.logger.info( Time.now.getutc.to_s + ": Docker sending: " + event.data)
handle_message(event.data, tubesock, result[:container])
end
socket.on :close do |event|
kill_socket(tubesock)
end
tubesock.onmessage do |data|
Rails.logger.info(Time.now.getutc.to_s + ": Client sending: " + data)
# Check whether the client send a JSON command and kill container
# if the command is 'client_kill', send it to docker otherwise.
begin
parsed = JSON.parse(data)
if parsed.class == Hash && parsed['cmd'] == 'client_kill'
Rails.logger.debug("Client exited container.")
@docker_client.kill_container(result[:container])
else
socket.send data
Rails.logger.debug('Sent the received client data to docker:' + data)
end
rescue JSON::ParserError
socket.send data
Rails.logger.debug('Rescued parsing error, sent the received client data to docker:' + data)
end
end
# Send command after all listeners are attached.
# Newline required to flush
socket.send command + "\n"
Rails.logger.info('Sent command: ' + command.to_s)
else
kill_socket(tubesock)
end
end
end
def kill_socket(tubesock)
# search for errors and save them as StructuredError (for scoring runs see submission_scoring.rb)
errors = extract_errors
send_hints(tubesock, errors)
# save the output of this "run" as a "testrun" (scoring runs are saved in submission_scoring.rb)
save_run_output
# Hijacked connection needs to be notified correctly
tubesock.send_data JSON.dump({'cmd' => 'exit'})
tubesock.close
end
def handle_message(message, tubesock, container)
@raw_output ||= ''
@run_output ||= ''
# Handle special commands first
if /^#exit/.match(message)
# Just call exit_container on the docker_client.
# Do not call kill_socket for the websocket to the client here.
# @docker_client.exit_container closes the socket to the container,
# kill_socket is called in the "on close handler" of the websocket to the container
@docker_client.exit_container(container)
elsif /^#timeout/.match(message)
@run_output = 'timeout: ' + @run_output # add information that this run timed out to the buffer
else
# Filter out information about run_command, test_command, user or working directory
run_command = @submission.execution_environment.run_command % command_substitutions(params[:filename])
test_command = @submission.execution_environment.test_command % command_substitutions(params[:filename])
unless /root|workspace|#{run_command}|#{test_command}/.match(message)
parse_message(message, 'stdout', tubesock)
end
end
end
def parse_message(message, output_stream, socket, recursive = true)
parsed = ''
begin
parsed = JSON.parse(message)
if parsed.class == Hash and parsed.key?('cmd')
socket.send_data message
Rails.logger.info('parse_message sent: ' + message)
else
parsed = {'cmd'=>'write','stream'=>output_stream,'data'=>message}
socket.send_data JSON.dump(parsed)
Rails.logger.info('parse_message sent: ' + JSON.dump(parsed))
end
rescue JSON::ParserError => e
# Check wether the message contains multiple lines, if true try to parse each line
if recursive and message.include? "\n"
for part in message.split("\n")
self.parse_message(part,output_stream,socket,false)
end
elsif message.include? '<img'
#Rails.logger.info('img foung')
@buffering = true
@buffer = ''
@buffer += message
#Rails.logger.info('Starting to buffer')
elsif @buffering and message.include? '/>'
@buffer += message
parsed = {'cmd'=>'write','stream'=>output_stream,'data'=>@buffer}
socket.send_data JSON.dump(parsed)
#socket.send_data @buffer
@buffering = false
#Rails.logger.info('Sent complete buffer')
elsif @buffering
@buffer += message
#Rails.logger.info('Appending to buffer')
else
#Rails.logger.info('else')
parsed = {'cmd'=>'write','stream'=>output_stream,'data'=>message}
socket.send_data JSON.dump(parsed)
Rails.logger.info('parse_message sent: ' + JSON.dump(parsed))
end
ensure
@raw_output += parsed['data'] if parsed.class == Hash and parsed.key? 'data'
# save the data that was send to the run_output if there is enough space left. this will be persisted as a testrun with cause "run"
@run_output += JSON.dump(parsed) if @run_output.size <= max_run_output_buffer_size
end
end
def save_run_output
unless @run_output.blank?
@run_output = @run_output[(0..max_run_output_buffer_size-1)] # trim the string to max_message_buffer_size chars
Testrun.create(file: @file, cause: 'run', submission: @submission, output: @run_output)
end
end
def extract_errors
results = []
unless @raw_output.blank?
@submission.exercise.execution_environment.error_templates.each do |template|
pattern = Regexp.new(template.signature).freeze
if pattern.match(@raw_output)
results << StructuredError.create_from_template(template, @raw_output, @submission)
end
end
end
results
end
def score
hijack do |tubesock|
if @embed_options[:disable_score]
kill_socket(tubesock)
return
end
Thread.new { EventMachine.run } unless EventMachine.reactor_running? && EventMachine.reactor_thread.alive?
# tubesock is the socket to the client
# the score_submission call will end up calling docker exec, which is blocking.
# to ensure responsiveness, we therefore open a thread here.
Thread.new {
tubesock.send_data JSON.dump(score_submission(@submission))
# To enable hints when scoring a submission, uncomment the next line:
#send_hints(tubesock, StructuredError.where(submission: @submission))
tubesock.send_data JSON.dump({'cmd' => 'exit'})
}
end
end
def send_hints(tubesock, errors)
return if @embed_options[:disable_hints]
errors = errors.to_a.uniq { |e| e.hint}
errors.each do | error |
tubesock.send_data JSON.dump({cmd: 'hint', hint: error.hint, description: error.error_template.description})
end
end
def set_docker_client
@docker_client = DockerClient.new(execution_environment: @submission.execution_environment)
end
private :set_docker_client
def set_file
@file = @files.detect { |file| file.name_with_extension == params[:filename] }
head :not_found unless @file
end
private :set_file
def set_files
@files = @submission.collect_files.select(&:visible)
end
private :set_files
def set_mime_type
@mime_type = Mime::Type.lookup_by_extension(@file.file_type.file_extension.gsub(/^\./, ''))
response.headers['Content-Type'] = @mime_type.to_s
end
private :set_mime_type
def set_submission
@submission = Submission.find(params[:id])
authorize!
end
private :set_submission
def show
end
def statistics
end
def stop
Rails.logger.debug('stopping submission ' + @submission.id.to_s)
container = Docker::Container.get(params[:container_id])
DockerClient.destroy_container(container)
rescue Docker::Error::NotFoundError
ensure
head :ok
end
def test
hijack do |tubesock|
Thread.new { EventMachine.run } unless EventMachine.reactor_running? && EventMachine.reactor_thread.alive?
output = @docker_client.execute_test_command(@submission, params[:filename])
# tubesock is the socket to the client
tubesock.send_data JSON.dump(output)
tubesock.send_data JSON.dump('cmd' => 'exit')
end
end
def with_server_sent_events
response.headers['Content-Type'] = 'text/event-stream'
server_sent_event = SSE.new(response.stream)
server_sent_event.write(nil, event: 'start')
yield(server_sent_event) if block_given?
server_sent_event.write({code: 200}, event: 'close')
rescue => exception
logger.error(exception.message)
logger.error(exception.backtrace.join("\n"))
server_sent_event.write({code: 500}, event: 'close')
ensure
server_sent_event.close
end
private :with_server_sent_events
def create_remote_evaluation_mapping
user = @submission.user
exercise_id = @submission.exercise_id
remote_evaluation_mapping = RemoteEvaluationMapping.create(user: user, exercise_id: exercise_id)
# create .co file
path = "tmp/" + user.id.to_s + ".co"
# parse validation token
content = "#{remote_evaluation_mapping.validation_token}\n"
# parse remote request url
content += "#{request.base_url}/evaluate\n"
@submission.files.each do |file|
file_path = file.path.to_s == '' ? file.name_with_extension : File.join(file.path, file.name_with_extension)
content += "#{file_path}=#{file.file_id.to_s}\n"
end
File.open(path, "w+") do |f|
f.write(content)
end
path
end
end