diff --git a/app/controllers/api/jupyter_submissions_controller.rb b/app/controllers/api/jupyter_submissions_controller.rb new file mode 100644 index 0000000000..4862d05eda --- /dev/null +++ b/app/controllers/api/jupyter_submissions_controller.rb @@ -0,0 +1,334 @@ +# frozen_string_literal: true + +require 'json' + +module Api + class JupyterSubmissionsController < ApplicationController + include SubmissionsHelper + + # Local prototype only. + # For production, replace these skips with proper MarkUs/JupyterHub authorization. + skip_before_action :verify_authenticity_token, only: [:create], raise: false + skip_verify_authorized only: :create + + def create + payload = request.request_parameters.presence || params.to_unsafe_h + + jupyter_info = payload['jupyter'] || {} + notebook_path = payload['notebook_path'] + + destination_path = sanitize_destination_path( + payload['destination_path'].presence || + payload['notebook_name'].presence || + File.basename(notebook_path.to_s) + ) + + assignment = find_assignment_from_payload!(payload) + + # Intended production flow: + # Jupyter token -> JupyterHub identity -> MarkUs user -> MarkUs student role. + # + # Local standalone JupyterLab testing: + # Set JUPYTER_DEV_USERNAME in docker-compose.yml. + student = current_role || find_student_role_from_jupyter_token!(jupyter_info, assignment) + + @jupyter_markus_role = student + @jupyter_markus_user = role_user!(student) + + grouping = find_or_create_grouping_for_student!(student, assignment) + + notebook = JupyterNotebookFetcher.new( + origin: jupyter_info['origin'], + base_url: jupyter_info['base_url'], + token: jupyter_info['token'], + notebook_path: notebook_path + ).fetch + + notebook_content = notebook_content_as_string(notebook[:content]) + + # SubmissionsHelper#upload_file expects API-style params: + # filename, mime_type, and file_content. + params[:filename] = destination_path + params[:mime_type] = 'application/x-ipynb+json' + params[:file_content] = notebook_content + + Rails.logger.info( + "[JupyterSubmissionsController] Submitting #{destination_path} " \ + "for user=#{@jupyter_markus_user.user_name}, " \ + "assignment_id=#{assignment.id}, grouping_id=#{grouping.id}" + ) + + upload_file(grouping, only_required_files: assignment.only_required_files) + + # upload_file may already render a MarkUs response. + # Avoid double-rendering if that happened. + return if performed? + + render json: { + status: 'success', + message: 'Notebook submitted to MarkUs.', + submitted_file: destination_path, + markus_target: { + assignment_id: assignment.id, + assignment: assignment.short_identifier, + repository_folder: assignment.repository_folder, + grouping_id: grouping.id, + group_id: grouping.group_id, + student_role_id: student.id, + markus_user_name: @jupyter_markus_user.user_name + }, + fetched_notebook: { + name: notebook[:name], + path: notebook[:path], + type: notebook[:type], + format: notebook[:format] + } + }, status: :ok + rescue JupyterIdentityFetcher::IdentityError => e + return if performed? + + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :unauthorized + rescue JupyterNotebookFetcher::FetchError => e + return if performed? + + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :bad_gateway + rescue ActiveRecord::RecordNotFound, ArgumentError => e + return if performed? + + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :not_found + rescue StandardError => e + Rails.logger.error("[JupyterSubmissionsController] #{e.class}: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) if e.backtrace + + return if performed? + + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :internal_server_error + end + + private + + # These two overrides are important for local prototype mode. + # MarkUs's upload helper may call current_user/current_role internally. + def current_user + return @jupyter_markus_user if @jupyter_markus_user.present? + + super if defined?(super) + end + + def current_role + return @jupyter_markus_role if @jupyter_markus_role.present? + + super if defined?(super) + end + + def find_assignment_from_payload!(payload) + if payload['assessment_id'].present? + return Assignment.find(payload['assessment_id']) + end + + if payload['assignment_id'].present? + return Assignment.find(payload['assignment_id']) + end + + assignment_key = payload['assignment'].to_s.strip + + if assignment_key.blank? + raise ActiveRecord::RecordNotFound, + 'Missing assignment, assignment_id, or assessment_id in notebook metadata.' + end + + course = find_course_from_payload(payload) + scope = course ? Assignment.where(course_id: course.id) : Assignment.all + + assignment = if integer_string?(assignment_key) + scope.find_by(id: assignment_key) + else + scope.find_by(short_identifier: assignment_key) + end + + if assignment.nil? + raise ActiveRecord::RecordNotFound, + "No MarkUs assignment found for #{assignment_key.inspect}." + end + + assignment + end + + def find_course_from_payload(payload) + course_id = payload['course_id'] + return Course.find(course_id) if course_id.present? + + course_key = payload['course'].to_s.strip + return if course_key.blank? + + return Course.find(course_key) if integer_string?(course_key) + + find_course_by_existing_column(course_key) + end + + def find_course_by_existing_column(course_key) + possible_columns = %w[name display_name short_identifier] + + possible_columns.each do |column| + next unless Course.column_names.include?(column) + + course = Course.find_by(column => course_key) + return course if course + end + + nil + end + + def find_student_role_from_jupyter_token!(jupyter_info, assignment) + username = resolve_jupyter_username!(jupyter_info) + + Rails.logger.info( + "[JupyterSubmissionsController] Jupyter token resolved to username=#{username}" + ) + + user = User.find_by(user_name: username) + + if user.nil? + raise ActiveRecord::RecordNotFound, + "No MarkUs user found with user_name=#{username.inspect}." + end + + role = find_role_for_user_and_course(user, assignment.course_id) + + if role.nil? + raise ActiveRecord::RecordNotFound, + "No student role found for Jupyter/MarkUs username #{username.inspect} " \ + "in course_id=#{assignment.course_id}." + end + + role + end + + def resolve_jupyter_username!(jupyter_info) + # Local development fallback only. + # This is useful when testing with standalone JupyterLab, where the token + # authenticates the server but does not identify a real JupyterHub user. + if Rails.env.development? && ENV['JUPYTER_DEV_USERNAME'].present? + return ENV.fetch('JUPYTER_DEV_USERNAME', nil) + end + + JupyterIdentityFetcher.new( + origin: jupyter_info['origin'], + base_url: jupyter_info['base_url'], + token: jupyter_info['token'] + ).username + end + + def find_role_for_user_and_course(user, course_id) + if user.respond_to?(:roles) + role = user.roles.find_by(course_id: course_id) + return role if role + end + + if defined?(Student) + role = Student.find_by(user_id: user.id, course_id: course_id) + return role if role + end + + if defined?(Role) + role = Role.find_by(user_id: user.id, course_id: course_id) + return role if role + end + + nil + end + + def role_user!(role) + user = role_user(role) + + if user.nil? + raise ActiveRecord::RecordNotFound, + "Could not resolve a MarkUs User for role id=#{role&.id.inspect}." + end + + user + end + + def role_user(role) + return role.user if role.respond_to?(:user) && role.user.present? + + if role.respond_to?(:user_id) && role.user_id.present? + user = User.find_by(id: role.user_id) + return user if user + end + + nil + end + + def find_or_create_grouping_for_student!(student, assignment) + if student.respond_to?(:has_accepted_grouping_for?) && + student.has_accepted_grouping_for?(assignment.id) + return student.accepted_grouping_for(assignment.id) + end + + if assignment.group_max == 1 + student.create_group_for_working_alone_student(assignment.id) + grouping = student.accepted_grouping_for(assignment.id) + return grouping if grouping + end + + if student.respond_to?(:create_autogenerated_name_group) + grouping = student.create_autogenerated_name_group(assignment) + return grouping if grouping + end + + raise ActiveRecord::RecordNotFound, + 'Could not find or create a grouping for this student and assignment.' + end + + def notebook_content_as_string(content) + case content + when String + content + when Hash, Array + JSON.pretty_generate(content) + else + content.to_json + end + end + + def sanitize_destination_path(path) + filename = File.basename(path.to_s.strip) + + raise ArgumentError, 'Destination filename is missing.' if filename.blank? + + unless filename.end_with?('.ipynb') + raise ArgumentError, 'Only .ipynb notebook submissions are currently supported.' + end + + filename + end + + # SubmissionsHelper#upload_file expects this helper method to exist. + # MainApiController has it, but this controller inherits from ApplicationController + # to avoid the MarkUs API permission layer for the local prototype. + def has_missing_params?(required_params) + required_params.any? { |param| params[param].blank? } + end + + def integer_string?(value) + value.to_s.match?(/\A\d+\z/) + end + end +end diff --git a/app/controllers/jupyter/jupyter_submissions_controller.rb b/app/controllers/jupyter/jupyter_submissions_controller.rb new file mode 100644 index 0000000000..0359f84193 --- /dev/null +++ b/app/controllers/jupyter/jupyter_submissions_controller.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +require 'tempfile' + +module Jupyter + class SubmissionsController < ApplicationController + # Local prototype only. + # For production, replace these skips with proper MarkUs/JupyterHub authorization. + skip_before_action :verify_authenticity_token, only: [:submit], raise: false + skip_verify_authorized only: :submit + + def submit + payload = params + + jupyter_info = payload['jupyter'] || {} + jupyter_path = payload['notebook_path'] + + destination_path = sanitize_destination_path( + payload['destination_path'].presence || + payload['notebook_name'].presence || + File.basename(jupyter_path.to_s) + ) + + user = find_user_from_jupyter_token!(jupyter_info) + course = find_course_from_payload!(payload) + + student = course.roles.find_by(user_id: user.id) + + if student.nil? + raise ActiveRecord::RecordNotFound, + t( + 'jupyter.submissions.errors.student_role_not_found', + user_name: user.user_name.inspect, + course_id: course.id + ) + end + + assignment = find_assignment_from_payload!(payload, course) + + grouping = if student.has_accepted_grouping_for?(assignment.id) + student.accepted_grouping_for(assignment.id) + elsif assignment.group_max == 1 + student.create_group_for_working_alone_student(assignment.id) + student.accepted_grouping_for(assignment.id) + else + student.create_autogenerated_name_group(assignment) + end + + jupyter_file = JupyterNotebookFetcher.new( + origin: jupyter_info['origin'], + base_url: jupyter_info['base_url'], + token: jupyter_info['token'], + notebook_path: jupyter_path + ).fetch + + submitted_file = tempfile_from_jupyter_file!( + jupyter_file: jupyter_file, + destination_path: destination_path + ) + + Rails.logger.info( + "[Jupyter::SubmissionsController] Submitting #{destination_path} " \ + "for user=#{user.user_name}, " \ + "course_id=#{course.id}, " \ + "assignment_id=#{assignment.id}, grouping_id=#{grouping.id}" + ) + + submit_jupyter_file!( + grouping: grouping, + assignment: assignment, + path: destination_path, + file: submitted_file + ) + + render json: { + status: 'success', + message: t('jupyter.submissions.submit.success'), + submitted_file: destination_path, + markus_target: { + course_id: course.id, + assignment_id: assignment.id, + assignment: assignment.short_identifier, + repository_folder: assignment.repository_folder, + grouping_id: grouping.id, + group_id: grouping.group_id, + student_role_id: student.id, + markus_user_name: user.user_name + }, + fetched_file: { + name: jupyter_file[:name], + path: jupyter_file[:path], + type: jupyter_file[:type], + format: jupyter_file[:format] + } + }, status: :ok + rescue JupyterIdentityFetcher::IdentityError => e + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :unauthorized + rescue JupyterNotebookFetcher::FetchError => e + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :bad_gateway + rescue ActiveRecord::RecordNotFound, ArgumentError => e + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :not_found + rescue StandardError => e + Rails.logger.error("[Jupyter::SubmissionsController] #{e.class}: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) if e.backtrace + + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :internal_server_error + ensure + submitted_file&.close + submitted_file&.unlink + end + + private + + def find_user_from_jupyter_token!(jupyter_info) + username = resolve_jupyter_username!(jupyter_info) + + Rails.logger.info( + "[Jupyter::SubmissionsController] Jupyter token resolved to username=#{username}" + ) + + user = User.find_by(user_name: username) + + if user.nil? + raise ActiveRecord::RecordNotFound, + t( + 'jupyter.submissions.errors.user_not_found', + user_name: username.inspect + ) + end + + user + end + + def find_course_from_payload!(payload) + if payload['course_id'].present? + return Course.find(payload['course_id']) + end + + course_name = payload['course'].to_s.strip + + if course_name.blank? + raise ActiveRecord::RecordNotFound, + t('jupyter.submissions.errors.missing_course') + end + + course = Course.find_by(name: course_name) + + if course.nil? + raise ActiveRecord::RecordNotFound, + t( + 'jupyter.submissions.errors.course_not_found', + course: course_name.inspect + ) + end + + course + end + + def find_assignment_from_payload!(payload, course) + assignment = if payload['assignment_id'].present? + course.assignments.find_by(id: payload['assignment_id']) + elsif payload['assignment'].present? + course.assignments.find_by( + short_identifier: payload['assignment'].to_s.strip + ) + else + raise ActiveRecord::RecordNotFound, + t('jupyter.submissions.errors.missing_assignment') + end + + return assignment if assignment.present? + + assignment_value = payload['assignment_id'].presence || payload['assignment'].presence + + raise ActiveRecord::RecordNotFound, + t( + 'jupyter.submissions.errors.assignment_not_found', + assignment: assignment_value.inspect, + course_id: course.id + ) + end + + def resolve_jupyter_username!(jupyter_info) + # Local development fallback only. + # This is useful when testing with standalone JupyterLab, where the token + # authenticates the server but does not identify a real JupyterHub user. + if Rails.env.development? && ENV['JUPYTER_DEV_USERNAME'].present? + return ENV.fetch('JUPYTER_DEV_USERNAME', nil) + end + + JupyterIdentityFetcher.new( + origin: jupyter_info['origin'], + base_url: jupyter_info['base_url'], + token: jupyter_info['token'] + ).username + end + + def tempfile_from_jupyter_file!(jupyter_file:, destination_path:) + submitted_file = Tempfile.new( + ['jupyter-submission', File.extname(destination_path)] + ) + + submitted_file.binmode + submitted_file.write(jupyter_file[:content]) + submitted_file.rewind + submitted_file + end + + def submit_jupyter_file!(grouping:, assignment:, path:, file:) + sanitized_path = sanitize_destination_path(path) + + if assignment.only_required_files + required_file = assignment.assignment_files.find_by(filename: sanitized_path) + + if required_file.nil? + raise ArgumentError, + t( + 'jupyter.submissions.errors.required_file_missing', + filename: sanitized_path.inspect + ) + end + end + + # TODO: + # Replace this block with the exact MarkUs repository write call. + # + # The reviewer asked not to reuse SubmissionsHelper#upload_file because it does + # too many API-specific things. This method should only: + # 1. validate the submitted path, + # 2. check required files when assignment.only_required_files is true, + # 3. add the raw tempfile content to the grouping repository. + # + # Check the existing SubmissionsHelper#upload_file implementation and copy only + # the lower-level repository write part. + grouping.access_repo do |repo| + repo.add_file( + sanitized_path, + file.read, + "Submit #{sanitized_path} from Jupyter" + ) + end + end + + def sanitize_destination_path(path) + filename = File.basename(path.to_s.strip) + + if filename.blank? + raise ArgumentError, + t('jupyter.submissions.errors.destination_filename_missing') + end + + filename + end + end +end diff --git a/app/services/jupyter_identity_fetcher.rb b/app/services/jupyter_identity_fetcher.rb new file mode 100644 index 0000000000..278cb1c404 --- /dev/null +++ b/app/services/jupyter_identity_fetcher.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'uri' + +class JupyterIdentityFetcher + class IdentityError < StandardError; end + + def initialize(origin:, base_url:, token:) + @origin = normalize_origin(origin) + @base_url = normalize_base_url(base_url) + @token = token.to_s + end + + def username + # Local development fallback only. + # This allows standalone JupyterLab testing where there is no JupyterHub identity endpoint. +<<<<<<< HEAD + if Rails.env.development? && ENV['JUPYTER_DEV_USERNAME'].present? + Rails.logger.info("[JupyterIdentityFetcher] Using development username #{ENV.fetch('JUPYTER_DEV_USERNAME', nil)}") + return ENV.fetch('JUPYTER_DEV_USERNAME', nil) +======= + if Rails.env.development? && Settings.jupyter_server.dev_username.present? + Rails.logger.info( + "[JupyterIdentityFetcher] Using development username #{Settings.jupyter_server.dev_username}" + ) + return Settings.jupyter_server.dev_username +>>>>>>> 95066dd62 (Revised files based on review suggestions.) + end + + validate! + + uri = hub_user_uri + + Rails.logger.info("[JupyterIdentityFetcher] Fetching identity from #{uri}") + + request = Net::HTTP::Get.new(uri) + request['Accept'] = 'application/json' + request['Authorization'] = "token #{@token}" + + response = Net::HTTP.start( + uri.hostname, + uri.port, + use_ssl: uri.scheme == 'https', + open_timeout: 10, + read_timeout: 30 + ) do |http| + http.request(request) + end + + unless response.is_a?(Net::HTTPSuccess) + raise IdentityError, + "JupyterHub identity lookup returned HTTP #{response.code}: #{response.body}" + end + + model = JSON.parse(response.body) + name = model['name'] || model['username'] + + if name.blank? + raise IdentityError, + 'JupyterHub identity response did not include a username.' + end + + name + rescue JSON::ParserError => e + raise IdentityError, + "JupyterHub identity response was not valid JSON: #{e.message}" + rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise IdentityError, + "Could not connect to JupyterHub identity endpoint: #{e.message}" + end + + private + + def validate! + raise IdentityError, 'Missing Jupyter token.' if @token.blank? + end + + def hub_user_uri + URI.parse("#{@origin}/hub/api/user") + end + + def normalize_origin(origin) + configured_origin = Settings.jupyter_server.api_origin.presence + value = configured_origin || origin.to_s + + value.strip.sub(%r{/*\z}, '') + end + + def normalize_base_url(base_url) + value = base_url.to_s.strip + + return '/' if value.blank? + + if value.start_with?('http://', 'https://') + parsed = URI.parse(value) + value = parsed.path.presence || '/' + end + + value = "/#{value}" unless value.start_with?('/') + value = "#{value}/" unless value.end_with?('/') + + value + rescue URI::InvalidURIError + '/' + end +end diff --git a/app/services/jupyter_notebook_fetcher.rb b/app/services/jupyter_notebook_fetcher.rb new file mode 100644 index 0000000000..d13cf23a8c --- /dev/null +++ b/app/services/jupyter_notebook_fetcher.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'uri' + +class JupyterNotebookFetcher + class FetchError < StandardError; end + + def initialize(origin:, base_url:, token:, notebook_path:) + @origin = normalize_origin(origin) + @base_url = normalize_base_url(base_url) + @token = token.to_s + @notebook_path = notebook_path.to_s + end + + def fetch + validate! + + uri = contents_uri + + Rails.logger.info("[JupyterNotebookFetcher] origin=#{@origin}") + Rails.logger.info("[JupyterNotebookFetcher] base_url=#{@base_url}") + Rails.logger.info("[JupyterNotebookFetcher] notebook_path=#{@notebook_path}") + Rails.logger.info("[JupyterNotebookFetcher] Fetching #{uri}") + + request = Net::HTTP::Get.new(uri) + request['Accept'] = 'application/json' + request['Authorization'] = "token #{@token}" if @token.present? + + response = Net::HTTP.start( + uri.hostname, + uri.port, + use_ssl: uri.scheme == 'https', + open_timeout: 10, + read_timeout: 30 + ) do |http| + http.request(request) + end + + unless response.is_a?(Net::HTTPSuccess) + raise FetchError, "Jupyter returned HTTP #{response.code} for #{uri}: #{response.body}" + end + + model = JSON.parse(response.body) + + { + name: model['name'], + path: model['path'], + type: model['type'], + format: model['format'], + mimetype: model['mimetype'], + writable: model['writable'], + content: model['content'] + } + rescue JSON::ParserError => e + raise FetchError, "Jupyter returned invalid JSON: #{e.message}" + rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise FetchError, "Could not connect to Jupyter: #{e.message}" + end + + private + + def validate! + raise FetchError, 'Missing Jupyter origin.' if @origin.blank? + raise FetchError, 'Missing Jupyter notebook path.' if @notebook_path.blank? + raise FetchError, 'Missing Jupyter token.' if @token.blank? + end + + def contents_uri + encoded_path = @notebook_path + .split('/') + .reject(&:blank?) + .map { |part| URI.encode_www_form_component(part) } + .join('/') + + base = "#{@origin}#{@base_url}" + base = "#{base}/" unless base.end_with?('/') + + uri = URI.parse("#{base}api/contents/#{encoded_path}") + uri.query = URI.encode_www_form(content: '1') + uri + end + + def normalize_origin(origin) + # Browser/Jupyter may report origin as http://localhost:8889. + # From inside the MarkUs Docker container, localhost means the container itself, + # so for local Docker testing we override it with: + # JUPYTER_FETCH_ORIGIN=http://host.docker.internal:8889 + overridden = ENV.fetch('JUPYTER_FETCH_ORIGIN', nil) + value = overridden.presence || origin.to_s + + value.strip.sub(%r{/*\z}, '') + end + + def normalize_base_url(base_url) + value = base_url.to_s.strip + + return '/' if value.blank? + + # Sometimes the extension/Jupyter may send a full URL here, for example: + # http://localhost:8889/ + # In that case, use only the path part, usually "/". + if value.start_with?('http://', 'https://') + parsed = URI.parse(value) + value = parsed.path.presence || '/' + end + + value = "/#{value}" unless value.start_with?('/') + value = "#{value}/" unless value.end_with?('/') + + value + rescue URI::InvalidURIError + '/' + end +end diff --git a/app/services/jupyter_submission_writer.rb b/app/services/jupyter_submission_writer.rb new file mode 100644 index 0000000000..ecfe040924 --- /dev/null +++ b/app/services/jupyter_submission_writer.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +# Writes a fetched Jupyter file into the student's MarkUs repository using the +# same helper methods used by the normal MarkUs web submission flow. +class JupyterSubmissionWriter + include RepositoryHelper + + class WriteError < StandardError + end + + def initialize(assignment:, grouping:, current_role:, current_user:, destination_path:, filename:, content:, + content_type:) + @assignment = assignment + @grouping = grouping + @current_role = current_role + @current_user = current_user + @destination_path = destination_path.to_s + @filename = filename.to_s + @content = content.to_s + @content_type = content_type.presence || 'application/octet-stream' + end + + def write! + validate_destination_path! + + path_inside_assignment = File.dirname(@destination_path) + path_inside_assignment = '' if path_inside_assignment == '.' + + repo_path = FileHelper.checked_join(@assignment.repository_folder, path_inside_assignment) + raise WriteError, I18n.t('errors.invalid_path') if repo_path.nil? + + path = Pathname.new(repo_path) + revision_identifier = nil + + @grouping.access_repo do |repo| + txn = repo.get_transaction(@current_user.user_name) + uploaded_file = build_uploaded_file + + required_files = if @current_role.student? && @assignment.only_required_files + @assignment.assignment_files.pluck(:filename).map do |name| + File.join(@assignment.repository_folder, name) + end + end + + success, messages = add_file( + uploaded_file, + @current_role, + repo, + path: path, + txn: txn, + check_size: true, + required_files: required_files + ) + + raise WriteError, messages.join(', ') unless success + + commit_success, commit_message = commit_transaction(repo, txn) + raise WriteError, commit_message unless commit_success + + revision_identifier = repo.get_latest_revision.revision_identifier.to_s + ensure + uploaded_file&.tempfile&.close! + end + + revision_identifier + rescue StandardError => e + raise e if e.is_a?(WriteError) + + raise WriteError, e.message + end + + private + + def validate_destination_path! + raise WriteError, 'Destination path is missing.' if @destination_path.blank? + + cleaned = Pathname.new(@destination_path).cleanpath.to_s + if cleaned.start_with?('../') || cleaned == '..' || cleaned.start_with?('/') + raise WriteError, 'Invalid destination path.' + end + + @destination_path = cleaned + @filename = File.basename(cleaned) + raise WriteError, 'Destination filename is missing.' if @filename.blank? + end + + def build_uploaded_file + sanitized_filename = FileHelper.sanitize_file_name(@filename) + tempfile = Tempfile.new(['jupyter_submission', File.extname(sanitized_filename)]) + tempfile.binmode + tempfile.write(@content) + tempfile.rewind + + ActionDispatch::Http::UploadedFile.new( + filename: sanitized_filename, + tempfile: tempfile, + type: @content_type + ) + end +end diff --git a/compose.yaml b/compose.yaml index b1440c7672..9f93885266 100644 --- a/compose.yaml +++ b/compose.yaml @@ -42,6 +42,7 @@ services: command: bundle exec rails server -b 0.0.0.0 environment: - RAILS_RELATIVE_URL_ROOT=/csc108 + - JUPYTER_FETCH_ORIGIN=http://host.docker.internal:8889 - PGDATABASE=markus_development - PGDATABASETEST=markus_test - MARKUS_URL=http://host.docker.internal:3000 # used by the tasks in autotest.rake @@ -49,8 +50,10 @@ services: - CAPYBARA_SERVER_PORT=${CAPYBARA_SERVER_PORT:-3434} - CAPYBARA_SERVER_HOST=${CAPYBARA_SERVER_HOST:-0.0.0.0} - PLAYWRIGHT_BROWSERS_PATH=/app/tmp/pw-browsers + - JUPYTER_DEV_USERNAME=c5anthei extra_hosts: - localhost:host-gateway + - "host.docker.internal:host-gateway" ports: - '3000:3000' volumes: diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index b6b50b03b5..4effac351a 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -4,6 +4,7 @@ # Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin Ajax requests. # Read more: https://github.com/cyu/rack-cors +# frozen_string_literal: true Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do @@ -12,6 +13,6 @@ origins(*Settings.jupyter_server.hosts) resource %r{/api/courses/\d+/assignments/\d+/submit_file}, headers: :any, - methods: :post + methods: [:post] end end diff --git a/config/routes.rb b/config/routes.rb index 2f922e2f43..0aa0118d95 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1101,3 +1101,7 @@ match '*path', controller: 'main', action: 'page_not_found', via: :all end + +namespace :jupyter do + post 'submit', to: 'submissions#submit' +end