diff --git a/.gitignore b/.gitignore index cfcbe42d1..f1525ea96 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ node_modules/ app/assets/javascripts/react /public/packs /node_modules + +localhost.crt +localhost.key diff --git a/Gemfile b/Gemfile index 0a9165d2e..3fe394d70 100644 --- a/Gemfile +++ b/Gemfile @@ -48,6 +48,7 @@ group :development do gem 'letter_opener' gem 'web-console' gem 'bullet' + gem 'awesome_print' end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem diff --git a/Gemfile.lock b/Gemfile.lock index 43db8a0c1..0037fc210 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -64,6 +64,7 @@ GEM arel (7.1.1) authority (3.2.0) activesupport (>= 3.0.0) + awesome_print (1.7.0) axiom-types (0.1.1) descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) @@ -376,6 +377,7 @@ DEPENDENCIES acts_as_list ahoy_matey authority + awesome_print bourbon bullet byebug diff --git a/Procfile.dev b/Procfile.dev index b0b3a7fcd..44a5680ff 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,2 @@ -web: bin/rails s -p3000 -b0.0.0.0 +web: [ -z "$LOCALHOST_SSL" ] && bin/rails s -p3000 -b0.0.0.0 || bundle exec puma -b 'ssl://0.0.0.0:3000?key=/vagrant/localhost.key&cert=/vagrant/localhost.crt' webpack: bin/webpack-dev-server diff --git a/README.markdown b/README.markdown index 720aa78cf..71085a38f 100644 --- a/README.markdown +++ b/README.markdown @@ -12,3 +12,13 @@ can `vagrant plugin install vagrant-fsnotify`), and then 2. In one terminal window or tmux pane: `vagrant ssh -c 'cd /vagrant && foreman start'`. 6. In another, `vagrant fsnotify` 6. Browse to http://localhost:3000/ + +## https://localhost + +When developing the LTI tool provider components of Gala, it is useful to be able to use https with the development server. This is how to set that up. + +1. Do this in the vagrant instance. Generate a self-signed certificate: `openssl req -new -newkey rsa:2048 -sha1 -days 365 -nodes -x509 -keyout localhost.key -out localhost.crt` +2. Trust the certificate in your vagrant instance: `sudo cp localhost.crt /etc/ssl/cert && sudo cp localhost.key /etc/ssl/private && sudo c_rehash` +3. Trust the certificate on your host machine using Keychain Access. Drag `localhost.crt` into the app, then Get Info and choose Always Trust. +4. Start the development servers with `LOCALHOST_SSL=true foreman start` +6. Browse to https://localhost:3000 (http will not work) diff --git a/app/controllers/authentication_strategies/config_controller.rb b/app/controllers/authentication_strategies/config_controller.rb new file mode 100644 index 000000000..0d9799df8 --- /dev/null +++ b/app/controllers/authentication_strategies/config_controller.rb @@ -0,0 +1,6 @@ +class AuthenticationStrategies::ConfigController < ApplicationController + + def lti + end + +end diff --git a/app/controllers/authentication_strategies/omniauth_callbacks_controller.rb b/app/controllers/authentication_strategies/omniauth_callbacks_controller.rb index 7ff552f2d..e3c58e5fb 100644 --- a/app/controllers/authentication_strategies/omniauth_callbacks_controller.rb +++ b/app/controllers/authentication_strategies/omniauth_callbacks_controller.rb @@ -1,9 +1,12 @@ class AuthenticationStrategies::OmniauthCallbacksController < Devise::OmniauthCallbacksController before_action :set_authentication_strategy, except: [:failure] + before_action :set_reader, except: [:failure] + before_action :set_case, only: [:lti] + before_action :set_group, only: [:lti] def google if @authentication_strategy.persisted? - sign_in_and_redirect @authentication_strategy.reader, event: :authentication + sign_in_and_redirect @reader, event: :authentication else session["devise.google_data"] = request.env["omniauth.auth"].except(:extra) render 'devise/registrations/new', layout: "window" @@ -12,7 +15,10 @@ def google def lti if @authentication_strategy.persisted? - sign_in_and_redirect @authentication_strategy.reader, event: :authentication + sign_in @reader + add_reader_to_group + enroll_reader_in_case if @case + redirect_to redirect_url else session["devise.lti_data"] = request.env["omniauth.auth"] render 'devise/registrations/new', layout: "window" @@ -28,4 +34,40 @@ def set_authentication_strategy @authentication_strategy = AuthenticationStrategy.from_omniauth(request.env["omniauth.auth"]) end + def set_reader + @reader = @authentication_strategy.reader + end + + def set_case + @case = Case.find_by_slug params[:case_slug] + end + + def set_group + begin + @group = Group.upsert context_id: params[:context_id], name: params[:context_title] + rescue + retry + end + end + + def add_reader_to_group + unless @reader.group_memberships.exists? group: @group + @reader.group_memberships.create group: @group + end + end + + def enroll_reader_in_case + Enrollment.upsert reader_id: @reader.id, + case_id: @case.id, + status: Enrollment.status_from_lti_role(params[:ext_roles]) + end + + def redirect_url + if @case + case_url @case + else + root_path + end + end + end diff --git a/app/controllers/cases_controller.rb b/app/controllers/cases_controller.rb index 99bf5221b..96ee4c8b8 100644 --- a/app/controllers/cases_controller.rb +++ b/app/controllers/cases_controller.rb @@ -16,7 +16,7 @@ def show authenticate_reader! unless @case.published authorize_action_for @case - render layout: 'application' + render layout: 'with_header' end def new diff --git a/app/controllers/catalog_controller.rb b/app/controllers/catalog_controller.rb index 164bfebaa..b64c6d52b 100644 --- a/app/controllers/catalog_controller.rb +++ b/app/controllers/catalog_controller.rb @@ -9,4 +9,13 @@ def home @index = cases_in_catalog.select(&:in_index?).sort_by &:kicker render layout: "window" end + + # LTI Assignment Selection wants to POST a ContentItemSelectionRequest + def content_items + I18n.locale = params[:launch_presentation_locale] + @items = Case.where(published: true).sort_by(&:kicker) + @return_url = params[:content_item_return_url] + @data = params[:data] + render layout: "embed" + end end diff --git a/app/javascript/content_items/index.jsx b/app/javascript/content_items/index.jsx new file mode 100644 index 000000000..762ae7cda --- /dev/null +++ b/app/javascript/content_items/index.jsx @@ -0,0 +1,79 @@ +// @flow + +import React from 'react' +import { chooseContentItem } from 'shared/lti' + +type ContentItem = {| + kicker: string, + title: string, + dek: string, + coverUrl: string, + url: string, +|} + +type ContentItemsProps = {| + items: ContentItem[], + returnUrl: string, + returnData: string, +|} + +const ContentItems = ({ items, returnUrl, returnData }: ContentItemsProps) => { + const handleChooseContentItem = chooseContentItem.bind( + undefined, + returnUrl, + returnData + ) + return ( +
+
+ {items.map((item: ContentItem, i: number) => ( + + ))} +
+
+ ) +} + +export default ContentItems + +type ContentItemProps = ContentItem & {| + handleChooseContentItem: (string) => void, +|} +const ContentItemLink = ( + { + kicker, + title, + dek, + coverUrl, + url, + handleChooseContentItem, + }: ContentItemProps +) => { + const handleClick = handleChooseContentItem.bind(undefined, url) + return ( + +
+

+ {kicker} + {title} +

+

+ {dek} +

+
+
+ ) +} diff --git a/app/javascript/packs/content_items.entry.jsx b/app/javascript/packs/content_items.entry.jsx new file mode 100644 index 000000000..de1e5b8eb --- /dev/null +++ b/app/javascript/packs/content_items.entry.jsx @@ -0,0 +1,23 @@ +// @flow + +import React from 'react' +import ReactDOM from 'react-dom' + +import ContentItems from 'content_items' + +function render (Component: React$Component) { + const container = document.getElementById('content-items-app') + + if (container == null) return + + ReactDOM.render( + , + container + ) +} + +render(ContentItems) diff --git a/app/javascript/shared/lti.js b/app/javascript/shared/lti.js new file mode 100644 index 000000000..664d0d9fb --- /dev/null +++ b/app/javascript/shared/lti.js @@ -0,0 +1,57 @@ +// @flow + +export function chooseContentItem ( + returnUrl: string, + returnData: string, + itemUrl: string +): void { + submitForm(returnUrl, contentItemSelectionMessageData(returnData, itemUrl)) +} + +function contentItemSelectionMessageData ( + returnData: string, + itemUrl: string +): { [string]: string } { + return { + lti_message_type: 'ContentItemSelection', + lti_version: 'LTI-1p0', + data: returnData, + content_items: JSON.stringify({ + '@context': 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem', + '@graph': [ + { + '@type': 'LtiLinkItem', + url: itemUrl, + mediaType: 'application/vnd.ims.lti.v1.ltilink', + placementAdvice: { + presentationDocumentTarget: 'window', + }, + }, + ], + }), + } +} + +// Form Submission + +function submitForm (action: string, data: { [string]: string }): void { + const form = document.createElement('form') + form.action = action + form.method = 'POST' + + for (const field in data) { + form.appendChild(buildFormInput(field, data[field])) + } + + document.body && document.body.appendChild(form) + + form.submit() +} + +function buildFormInput (name: string, value: string): HTMLInputElement { + const el = document.createElement('input') + el.type = 'hidden' + el.name = name + el.value = value + return el +} diff --git a/app/models/enrollment.rb b/app/models/enrollment.rb index 18f322368..81c5a15aa 100644 --- a/app/models/enrollment.rb +++ b/app/models/enrollment.rb @@ -6,13 +6,21 @@ class Enrollment < ApplicationRecord enum status: %i(student instructor treatment) - def upsert - enrollment = Self.find_or_initialize_by( case_id: case_id, reader_id: reader_id ) + def self.upsert case_id:, reader_id:, status: student + enrollment = find_or_initialize_by( case_id: case_id, reader_id: reader_id ) enrollment.status = status - enrollment.save! unless enrollment.persisted? + enrollment.save! if enrollment.changed? enrollment end + def self.status_from_lti_role ext_roles + if ext_roles.match 'urn:lti:role:ims/lis/Instructor' + :instructor + else + :student + end + end + def as_json(options = {}) super(options.merge({include: [reader: { only: %i(id image_url initials name) }]})) end diff --git a/app/models/group.rb b/app/models/group.rb index f6d94cd4e..3fa949a91 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -4,4 +4,13 @@ class Group < ApplicationRecord has_many :readers, through: :group_memberships translates :name + + validates :context_id, uniqueness: true, if: -> () { context_id.present? } + + def self.upsert context_id:, name: + group = find_or_initialize_by context_id: context_id + group.name = name + group.save! if group.changed? + group + end end diff --git a/app/views/authentication_strategies/config/lti.xml.erb b/app/views/authentication_strategies/config/lti.xml.erb new file mode 100644 index 000000000..4f82f9a60 --- /dev/null +++ b/app/views/authentication_strategies/config/lti.xml.erb @@ -0,0 +1,27 @@ + + + Michigan Sustainability Cases + Description of MSC + <%= authentication_strategy_lti_omniauth_callback_url %> + + true + public + www.learnmsc.org + + true + + + ContentItemSelectionRequest + <%= catalog_content_items_url %> + + + diff --git a/app/views/catalog/content_items.html.haml b/app/views/catalog/content_items.html.haml new file mode 100644 index 000000000..adbfea444 --- /dev/null +++ b/app/views/catalog/content_items.html.haml @@ -0,0 +1,5 @@ +#content-items-app{data: {return_data: @data, + return_url: @return_url, + items: render(template: 'catalog/content_items', formats: [:json])}} + += javascript_pack_tag 'content_items' diff --git a/app/views/catalog/content_items.json.jbuilder b/app/views/catalog/content_items.json.jbuilder new file mode 100644 index 000000000..aabf787d2 --- /dev/null +++ b/app/views/catalog/content_items.json.jbuilder @@ -0,0 +1,6 @@ +json.key_format! camelize: :lower +json.array! @items do |kase| + json.extract! kase, *%i(kicker title dek) + json.cover_url ix_cover_image(kase, :small) + json.url authentication_strategy_lti_omniauth_callback_url case_slug: kase.slug +end diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 98534931a..a2e4edc3e 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -8,4 +8,4 @@ <%= yield %> -<% parent_layout 'application' %> +<% parent_layout 'with_header' %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 88d84bdc7..c40a54885 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -18,36 +18,6 @@ -
- <%= link_to root_path, id: "logo" do %> - <%= inline_svg 'msc-logo.svg' %> - <% end %> - <% if reader_signed_in? %> -
- - -
> - <%= current_reader.initials if current_reader.image_url.blank? %> -
- -
- <% else %> -
- <%=link_to t('devise.sessions.new.sign_in'), new_reader_session_path %> -
- <% end %> -
- - <% if flash[:notice] %> -
<%= flash[:notice] %>
- <% end %> - <% if flash[:alert] %> -
<%= flash[:alert] %>
- <% end %> - <%= yield %> diff --git a/app/views/layouts/devise.html.erb b/app/views/layouts/devise.html.erb index 667f60566..4d6aa1d60 100644 --- a/app/views/layouts/devise.html.erb +++ b/app/views/layouts/devise.html.erb @@ -6,4 +6,4 @@ -<% parent_layout 'application' %> +<% parent_layout 'with_header' %> diff --git a/app/views/layouts/embed.html.erb b/app/views/layouts/embed.html.erb new file mode 100644 index 000000000..19e65585c --- /dev/null +++ b/app/views/layouts/embed.html.erb @@ -0,0 +1,11 @@ +
+ <%= link_to root_path, id: "logo" do %> + <%= inline_svg 'msc-logo.svg' %> + <% end %> +
+ +
+ <%= yield %> +
+ +<% parent_layout 'application' %> diff --git a/app/views/layouts/window.html.erb b/app/views/layouts/window.html.erb index c8c37f892..aead78fbd 100644 --- a/app/views/layouts/window.html.erb +++ b/app/views/layouts/window.html.erb @@ -2,4 +2,4 @@ <%= yield %> -<% parent_layout 'application' %> +<% parent_layout 'with_header' %> diff --git a/app/views/layouts/with_header.html.erb b/app/views/layouts/with_header.html.erb new file mode 100644 index 000000000..b633577cb --- /dev/null +++ b/app/views/layouts/with_header.html.erb @@ -0,0 +1,33 @@ +
+ <%= link_to root_path, id: "logo" do %> + <%= inline_svg 'msc-logo.svg' %> + <% end %> + <% if reader_signed_in? %> +
+ + +
> + <%= current_reader.initials if current_reader.image_url.blank? %> +
+ +
+ <% else %> +
+ <%=link_to t('devise.sessions.new.sign_in'), new_reader_session_path %> +
+ <% end %> +
+ +<% if flash[:notice] %> +
<%= flash[:notice] %>
+<% end %> +<% if flash[:alert] %> +
<%= flash[:alert] %>
+<% end %> + +<%= yield %> + +<% parent_layout 'application' %> diff --git a/bin/webpack-dev-server b/bin/webpack-dev-server index eae99732d..47d9dfa0f 100755 --- a/bin/webpack-dev-server +++ b/bin/webpack-dev-server @@ -27,7 +27,10 @@ rescue Errno::ENOENT, NoMethodError exit! end +HTTPS_ARGS = '--https --cert /vagrant/localhost.crt --key /vagrant/localhost.key' + Dir.chdir(APP_PATH) do exec "NODE_PATH=#{NODE_MODULES_PATH} #{WEBPACK_BIN} --progress --color " \ - "--config #{DEV_SERVER_CONFIG}" + "--config #{DEV_SERVER_CONFIG} " \ + "#{ENV['LOCALHOST_SSL'] ? HTTPS_ARGS : ''}" end diff --git a/config/application.rb b/config/application.rb index ccb5ade60..760fb4375 100644 --- a/config/application.rb +++ b/config/application.rb @@ -15,5 +15,7 @@ class Application < Rails::Application config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}')] config.i18n.available_locales = %i(en fr ja zh-CN zh-TW am) + config.action_dispatch.default_headers = { "X-Frame-Options" => "ALLOWALL" } + end end diff --git a/config/environments/development.rb b/config/environments/development.rb index f956faa68..55a8f1f75 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -59,4 +59,10 @@ Bullet.console = true end + if ENV["LOCALHOST_SSL"].present? + logger = ActiveSupport::Logger.new(STDOUT) + logger.formatter = config.log_formatter + config.logger = ActiveSupport::TaggedLogging.new(logger) + end + end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 1e854db78..0d2c7d706 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -243,9 +243,9 @@ # up on your models and hooks. config.omniauth :facebook, ENV["FACEBOOK_CLIENT_ID"], ENV["FACEBOOK_CLIENT_SECRET"] config.omniauth :google_oauth2, ENV["GOOGLE_CLIENT_ID"], ENV["GOOGLE_CLIENT_SECRET"], {name: "google"} - config.omniauth :lti, oauth_credentials: {test: "secret"} + config.omniauth :lti, oauth_credentials: Hash[ENV["LTI_KEY"], ENV["LTI_SECRET"]] - unless Rails.env.production? + if Rails.env.test? OmniAuth.config.test_mode = true OmniAuth.config.mock_auth[:google] = OmniAuth::AuthHash.new({ provider: 'google', diff --git a/config/routes.rb b/config/routes.rb index 00aeaba8a..54bfa106c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -65,6 +65,17 @@ end end + namespace 'authentication_strategies' do + namespace 'config' do + get :lti + end + end + + namespace 'catalog' do + get :home + match :content_items, via: [:get, :post] + end + devise_for :authentication_strategies, only: :omniauth_callbacks, controllers: { omniauth_callbacks: 'authentication_strategies/omniauth_callbacks', } diff --git a/config/webpack/configuration.js b/config/webpack/configuration.js index 2c7702c23..b9d65a9f9 100644 --- a/config/webpack/configuration.js +++ b/config/webpack/configuration.js @@ -13,8 +13,13 @@ const paths = safeLoad(readFileSync(join(configPath, 'paths.yml'), 'utf8'))[ const devServer = safeLoad( readFileSync(join(configPath, 'development.server.yml'), 'utf8') )[env.NODE_ENV] + +const devPublicPath = env.LOCALHOST_SSL + ? `https://localhost:${devServer.port}/` + : `http://${devServer.host}:${devServer.port}/` + const publicPath = env.NODE_ENV !== 'production' && devServer.enabled - ? `http://${devServer.host}:${devServer.port}/` + ? devPublicPath : `/${paths.entry}/` const devServerEntries = env.NODE_ENV !== 'production' && devServer.enabled diff --git a/db/migrate/20170419172037_add_context_id_to_group.rb b/db/migrate/20170419172037_add_context_id_to_group.rb new file mode 100644 index 000000000..5070b7bc2 --- /dev/null +++ b/db/migrate/20170419172037_add_context_id_to_group.rb @@ -0,0 +1,6 @@ +class AddContextIdToGroup < ActiveRecord::Migration[5.0] + def change + add_column :groups, :context_id, :string + add_index :groups, :context_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 737e08b4c..d43a8e5dd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170412175954) do +ActiveRecord::Schema.define(version: 20170419172037) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -177,6 +177,8 @@ t.hstore "name_i18n" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "context_id" + t.index ["context_id"], name: "index_groups_on_context_id", using: :btree end create_table "notifications", force: :cascade do |t|