diff --git a/app/controllers/api/projects_controller.rb b/app/controllers/api/projects_controller.rb index 1ebb350f9..97cba898d 100644 --- a/app/controllers/api/projects_controller.rb +++ b/app/controllers/api/projects_controller.rb @@ -4,12 +4,15 @@ module Api class ProjectsController < ApiController + include ActionController::Cookies + before_action :authorize_user, only: %i[create update index destroy] before_action :load_project, only: %i[show update destroy show_context] before_action :load_projects, only: %i[index] load_and_authorize_resource before_action :verify_lesson_belongs_to_school, only: :create after_action :pagination_link_header, only: %i[index] + before_action :set_auth_cookie_for_scratch, only: %i[show] def index @paginated_projects = @projects.page(params[:page]) @@ -59,6 +62,18 @@ def show_context private + def set_auth_cookie_for_scratch + return unless @project.project_type == Project::Types::CODE_EDITOR_SCRATCH + return unless Flipper.enabled?(:cat_mode, school) + + cookies[:scratch_auth] = { + value: request.headers['Authorization'], + secure: Rails.env.production?, + same_site: :strict, + http_only: true + } + end + def verify_lesson_belongs_to_school return if base_params[:lesson_id].blank? return if school&.lessons&.pluck(:id)&.include?(base_params[:lesson_id]) diff --git a/app/controllers/api/scratch/assets_controller.rb b/app/controllers/api/scratch/assets_controller.rb new file mode 100644 index 000000000..08e6cf2c5 --- /dev/null +++ b/app/controllers/api/scratch/assets_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Api + module Scratch + class AssetsController < ScratchController + skip_before_action :authorize_user, only: [:show] + skip_before_action :check_scratch_feature, only: [:show] + + def show + render :show, formats: [:svg] + end + + def create + render json: { status: 'ok', 'content-name': params[:id] }, status: :created + end + end + end +end diff --git a/app/controllers/api/scratch/projects_controller.rb b/app/controllers/api/scratch/projects_controller.rb new file mode 100644 index 000000000..4904724d3 --- /dev/null +++ b/app/controllers/api/scratch/projects_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Api + module Scratch + class ProjectsController < ScratchController + skip_before_action :authorize_user, only: [:show] + skip_before_action :check_scratch_feature, only: [:show] + + def show + render :show, formats: [:json] + end + + def update + render json: { status: 'ok' }, status: :ok + end + end + end +end diff --git a/app/controllers/api/scratch/scratch_controller.rb b/app/controllers/api/scratch/scratch_controller.rb new file mode 100644 index 000000000..2500b8495 --- /dev/null +++ b/app/controllers/api/scratch/scratch_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Api + module Scratch + class ScratchController < ApiController + include IdentifiableByCookie + + before_action :authorize_user + before_action :check_scratch_feature + + def check_scratch_feature + return if current_user.nil? + + school = current_user&.schools&.first + return if Flipper.enabled?(:cat_mode, school) + + raise ActiveRecord::RecordNotFound, 'Not Found' + end + end + end +end diff --git a/app/controllers/concerns/identifiable_by_cookie.rb b/app/controllers/concerns/identifiable_by_cookie.rb new file mode 100644 index 000000000..6ce693598 --- /dev/null +++ b/app/controllers/concerns/identifiable_by_cookie.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module IdentifiableByCookie + extend ActiveSupport::Concern + include ActionController::Cookies + + def identify_user + token = cookies[:scratch_auth] + User.from_token(token:) if token + end + + def current_user + @current_user ||= identify_user + end +end diff --git a/app/models/project.rb b/app/models/project.rb index dc692aa2d..f74dbcdb4 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -5,6 +5,7 @@ module Types PYTHON = 'python' HTML = 'html' SCRATCH = 'scratch' + CODE_EDITOR_SCRATCH = 'code_editor_scratch' end belongs_to :school, optional: true diff --git a/app/views/api/scratch/assets/show.svg b/app/views/api/scratch/assets/show.svg new file mode 100644 index 000000000..65f2112db --- /dev/null +++ b/app/views/api/scratch/assets/show.svg @@ -0,0 +1,20 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/views/api/scratch/projects/show.json b/app/views/api/scratch/projects/show.json new file mode 100644 index 000000000..501c70955 --- /dev/null +++ b/app/views/api/scratch/projects/show.json @@ -0,0 +1,264 @@ +{ + "targets": [ + { + "isStage": true, + "name": "Stage", + "variables": { + "`jEk@4|i[#Fk?(8x)AV.-my variable": [ + "my variable", + "1" + ] + }, + "lists": {}, + "broadcasts": {}, + "blocks": { + "Q*IGJj0mdm@]wJ,v%1M5": { + "opcode": "event_whenflagclicked", + "next": "]ML!StPuGDgh@B`^[v0d", + "parent": null, + "inputs": {}, + "fields": {}, + "shadow": false, + "topLevel": true, + "x": 188, + "y": 132 + }, + "]ML!StPuGDgh@B`^[v0d": { + "opcode": "data_setvariableto", + "next": null, + "parent": "Q*IGJj0mdm@]wJ,v%1M5", + "inputs": { + "VALUE": [ + 1, + [ + 10, + "1" + ] + ] + }, + "fields": { + "VARIABLE": [ + "my variable", + "`jEk@4|i[#Fk?(8x)AV.-my variable" + ] + }, + "shadow": false, + "topLevel": false + } + }, + "comments": {}, + "currentCostume": 0, + "costumes": [ + { + "name": "backdrop1", + "dataFormat": "svg", + "assetId": "cd21514d0531fdffb22204e0ec5ed84a", + "md5ext": "cd21514d0531fdffb22204e0ec5ed84a.svg", + "rotationCenterX": 240, + "rotationCenterY": 180 + } + ], + "sounds": [], + "volume": 100, + "layerOrder": 0, + "tempo": 60, + "videoTransparency": 50, + "videoState": "on", + "textToSpeechLanguage": null + }, + { + "isStage": false, + "name": "teapot", + "variables": {}, + "lists": {}, + "broadcasts": {}, + "blocks": { + "+7D@27U/UaREbT1:$D2p": { + "opcode": "motion_goto_menu", + "next": null, + "parent": "WQem=5FI$H@eF8Oy8=%m", + "inputs": {}, + "fields": { + "TO": [ + "_random_" + ] + }, + "shadow": true, + "topLevel": false + }, + ":Cul13Fz-$kW#uI)b3Ih": { + "opcode": "motion_turnright", + "next": "`HzTVWK}[NHS}n(wXj(~", + "parent": "o6dY$%i#z$_:%J%y.1Uv", + "inputs": { + "DEGREES": [ + 1, + [ + 4, + "15" + ] + ] + }, + "fields": {}, + "shadow": false, + "topLevel": false + }, + "El0`*m+.st%JF!*L54E,": { + "opcode": "control_repeat", + "next": null, + "parent": "JP;xPsfOSUqBfPdVA)C9", + "inputs": { + "TIMES": [ + 1, + [ + 6, + "5" + ] + ], + "SUBSTACK": [ + 2, + "WQem=5FI$H@eF8Oy8=%m" + ] + }, + "fields": {}, + "shadow": false, + "topLevel": false + }, + "JP;xPsfOSUqBfPdVA)C9": { + "opcode": "event_whenflagclicked", + "next": "El0`*m+.st%JF!*L54E,", + "parent": null, + "inputs": {}, + "fields": {}, + "shadow": false, + "topLevel": true, + "x": 5, + "y": -1196 + }, + "Lf3w#r641AVS(Z;,+4y3": { + "opcode": "event_whenthisspriteclicked", + "next": "o6dY$%i#z$_:%J%y.1Uv", + "parent": null, + "inputs": {}, + "fields": {}, + "shadow": false, + "topLevel": true, + "x": 277, + "y": -1199 + }, + "TFT|@Y(or^}uOFGDC|FB": { + "opcode": "looks_setsizeto", + "next": null, + "parent": "`HzTVWK}[NHS}n(wXj(~", + "inputs": { + "SIZE": [ + 1, + [ + 4, + "100" + ] + ] + }, + "fields": {}, + "shadow": false, + "topLevel": false + }, + "WQem=5FI$H@eF8Oy8=%m": { + "opcode": "motion_goto", + "next": "k`v/#:wkfU{|QkjM)6G|", + "parent": "El0`*m+.st%JF!*L54E,", + "inputs": { + "TO": [ + 1, + "+7D@27U/UaREbT1:$D2p" + ] + }, + "fields": {}, + "shadow": false, + "topLevel": false + }, + "`HzTVWK}[NHS}n(wXj(~": { + "opcode": "control_wait", + "next": "TFT|@Y(or^}uOFGDC|FB", + "parent": ":Cul13Fz-$kW#uI)b3Ih", + "inputs": { + "DURATION": [ + 1, + [ + 5, + "1" + ] + ] + }, + "fields": {}, + "shadow": false, + "topLevel": false + }, + "k`v/#:wkfU{|QkjM)6G|": { + "opcode": "control_wait", + "next": null, + "parent": "WQem=5FI$H@eF8Oy8=%m", + "inputs": { + "DURATION": [ + 1, + [ + 5, + "0.1" + ] + ] + }, + "fields": {}, + "shadow": false, + "topLevel": false + }, + "o6dY$%i#z$_:%J%y.1Uv": { + "opcode": "looks_setsizeto", + "next": ":Cul13Fz-$kW#uI)b3Ih", + "parent": "Lf3w#r641AVS(Z;,+4y3", + "inputs": { + "SIZE": [ + 1, + [ + 4, + "200" + ] + ] + }, + "fields": {}, + "shadow": false, + "topLevel": false + } + }, + "comments": {}, + "currentCostume": 0, + "costumes": [ + { + "name": "teapot", + "bitmapResolution": 1, + "dataFormat": "svg", + "assetId": "8a9dadf4eea61892ec6908b1c99e4961", + "md5ext": "8a9dadf4eea61892ec6908b1c99e4961.svg", + "rotationCenterX": 23.171110153198242, + "rotationCenterY": 22.78113555908203 + } + ], + "sounds": [], + "volume": 100, + "layerOrder": 1, + "visible": true, + "x": 0, + "y": 6, + "size": 100, + "direction": 105, + "draggable": false, + "rotationStyle": "all around" + } + ], + "monitors": [], + "extensions": [], + "meta": { + "semver": "3.0.0", + "vm": "12.2.2", + "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" + } +} \ No newline at end of file diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 2fa210159..3dc4dd85c 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -23,5 +23,7 @@ end def standard_cors_options + resource '/api/scratch/*', headers: :any, methods: %i[get post put], credentials: true, expose: ['Link'] + resource '/api/projects/*', headers: :any, methods: %i[get post patch put delete], credentials: true, expose: ['Link'] resource '*', headers: :any, methods: %i[get post patch put delete], expose: ['Link'] end diff --git a/config/routes.rb b/config/routes.rb index 500644cb6..91e498688 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,12 @@ mount GraphiQL::Rails::Engine, at: '/graphql', graphql_path: '/graphql#execute' unless Rails.env.production? namespace :api do + namespace :scratch do + resources :projects, only: %i[show update] + get '/assets/internalapi/asset/:id(.:format)/get/' => 'assets#show' + post '/assets/:id' => 'assets#create' + end + resource :default_project, only: %i[show] do get '/html', to: 'default_projects#html' get '/python', to: 'default_projects#python' diff --git a/spec/features/scratch/creating_a_scratch_asset_spec.rb b/spec/features/scratch/creating_a_scratch_asset_spec.rb new file mode 100644 index 000000000..5d1ce8679 --- /dev/null +++ b/spec/features/scratch/creating_a_scratch_asset_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating a Scratch asset', type: :request do + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:) } + let(:cookie_headers) { { 'Cookie' => "scratch_auth=#{UserProfileMock::TOKEN}" } } + + before do + Flipper.disable :cat_mode + Flipper.disable_actor :cat_mode, school + end + + it 'responds 401 Unauthorized when no cookie is provided' do + post '/api/scratch/assets/example.svg' + + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 404 Not Found when cat_mode is not enabled' do + authenticated_in_hydra_as(teacher) + + post '/api/scratch/assets/example.svg', headers: cookie_headers + + expect(response).to have_http_status(:not_found) + end + + it 'creates an asset when cat_mode is enabled and a cookie is provided' do + authenticated_in_hydra_as(teacher) + Flipper.enable_actor :cat_mode, school + + post '/api/scratch/assets/example.svg', headers: cookie_headers + + expect(response).to have_http_status(:created) + + data = JSON.parse(response.body, symbolize_names: true) + expect(data[:status]).to eq('ok') + expect(data[:'content-name']).to eq('example') + end +end diff --git a/spec/features/scratch/showing_a_scratch_asset_spec.rb b/spec/features/scratch/showing_a_scratch_asset_spec.rb new file mode 100644 index 000000000..3afbc8993 --- /dev/null +++ b/spec/features/scratch/showing_a_scratch_asset_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Showing a Scratch asset', type: :request do + it 'returns scratch asset SVG' do + get '/api/scratch/assets/internalapi/asset/example.svg/get/' + + expect(response).to have_http_status(:ok) + expect(response.body).to include(' "scratch_auth=#{UserProfileMock::TOKEN}" } } + + before do + Flipper.disable :cat_mode + Flipper.disable_actor :cat_mode, school + end + + it 'responds 401 Unauthorized when no cookie is provided' do + put '/api/scratch/projects/any-identifier', params: { project: { targets: [] } } + + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 404 Not Found when cat_mode is not enabled' do + authenticated_in_hydra_as(teacher) + + put '/api/scratch/projects/any-identifier', params: { project: { targets: [] } }, headers: cookie_headers + + expect(response).to have_http_status(:not_found) + end + + it 'updates a project when cat_mode is enabled and a cookie is provided' do + authenticated_in_hydra_as(teacher) + Flipper.enable_actor :cat_mode, school + + put '/api/scratch/projects/any-identifier', params: { project: { targets: [] } }, headers: cookie_headers + + expect(response).to have_http_status(:ok) + + data = JSON.parse(response.body, symbolize_names: true) + expect(data[:status]).to eq('ok') + end +end diff --git a/spec/requests/projects/show_spec.rb b/spec/requests/projects/show_spec.rb index d14a09c26..7d83dedd6 100644 --- a/spec/requests/projects/show_spec.rb +++ b/spec/requests/projects/show_spec.rb @@ -54,6 +54,43 @@ end end + context 'when setting scratch auth cookie' do + let(:project_type) { Project::Types::PYTHON } + let!(:project) { create(:project, school:, user_id: teacher.id, locale: nil, project_type:) } + + before do + Flipper.disable :cat_mode + Flipper.disable_actor :cat_mode, school + end + + it 'does not set auth cookie when project is not scratch' do + get("/api/projects/#{project.identifier}", headers:) + + expect(response).to have_http_status(:ok) + expect(response.cookies['scratch_auth']).to be_nil + end + + context 'when project is code editor scratch' do + let(:project_type) { Project::Types::CODE_EDITOR_SCRATCH } + + it 'does not set auth cookie when cat_mode is not enabled' do + get("/api/projects/#{project.identifier}", headers:) + + expect(response).to have_http_status(:ok) + expect(response.cookies['scratch_auth']).to be_nil + end + + it 'sets auth cookie to auth header' do + Flipper.enable_actor :cat_mode, school + + get("/api/projects/#{project.identifier}", headers:) + + expect(response).to have_http_status(:ok) + expect(cookies['scratch_auth']).to eq(UserProfileMock::TOKEN) + end + end + end + context 'when loading a student\'s project' do let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id, visibility: 'students') }