forked from External/greenlight
commit
fdf6f0e2c4
|
@ -14,4 +14,5 @@
|
||||||
//= require jquery-ui
|
//= require jquery-ui
|
||||||
//= require bootstrap-sprockets
|
//= require bootstrap-sprockets
|
||||||
//= require turbolinks
|
//= require turbolinks
|
||||||
|
//= require_self
|
||||||
//= require_tree .
|
//= require_tree .
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
var initRooms = function() {
|
||||||
|
App.messages = App.cable.subscriptions.create({
|
||||||
|
channel: 'ModeratorJoinsChannel',
|
||||||
|
username: window.location.pathname.split('/').pop()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
received: function(data) {
|
||||||
|
if (!Meeting.getInstance().getModJoined()) {
|
||||||
|
Meeting.getInstance().setModJoined(true);
|
||||||
|
if (Meeting.getInstance().getWaitingForMod()) {
|
||||||
|
loopJoin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$(document).on("turbolinks:load", function() {
|
||||||
|
if ($("body[data-controller=landing]").get(0)) {
|
||||||
|
if ($("body[data-action=rooms]").get(0)) {
|
||||||
|
initRooms();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).call(this);
|
|
@ -1,21 +1,35 @@
|
||||||
(function() {
|
(function() {
|
||||||
|
var waitForModerator = function(url) {
|
||||||
|
$.get(url + "/wait", function(html) {
|
||||||
|
$(".center-panel-wrapper").html(html);
|
||||||
|
});
|
||||||
|
if (!Meeting.getInstance().getWaitingForMod()) {
|
||||||
|
Meeting.getInstance().setWaitingForMod(true);
|
||||||
|
if (Meeting.getInstance().getModJoined()) {
|
||||||
|
loopJoin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
var init = function() {
|
var init = function() {
|
||||||
|
|
||||||
$('.meeting-join').click (function (event) {
|
$('.meeting-join').click (function (event) {
|
||||||
var url = $('.meeting-url').val();
|
var url = $('.meeting-url').val();
|
||||||
var name = $('.meeting-user-name').val();
|
var name = $('.meeting-user-name').val();
|
||||||
$.ajax({
|
Meeting.getInstance().setURL(url);
|
||||||
url : url + "/join?name=" + name,
|
Meeting.getInstance().setName(name);
|
||||||
dataType : "json",
|
var jqxhr = Meeting.getInstance().getjoinMeetingURL();
|
||||||
type : 'GET',
|
|
||||||
success : function(data) {
|
jqxhr.done(function(data) {
|
||||||
|
if (data.messageKey === 'wait_for_moderator') {
|
||||||
|
waitForModerator(url);
|
||||||
|
} else {
|
||||||
$(location).attr("href", data.response.join_url);
|
$(location).attr("href", data.response.join_url);
|
||||||
},
|
|
||||||
error : function(xhr, status, error) {
|
|
||||||
},
|
|
||||||
complete : function(xhr, status) {
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
jqxhr.fail(function(xhr, status, error) {
|
||||||
|
console.info("meeting join failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$('.meeting-url-copy').click (function (e) {
|
$('.meeting-url-copy').click (function (e) {
|
||||||
|
@ -33,16 +47,22 @@
|
||||||
var link = window.location.protocol +
|
var link = window.location.protocol +
|
||||||
'//' +
|
'//' +
|
||||||
window.location.hostname +
|
window.location.hostname +
|
||||||
'/' +
|
'/meetings/' +
|
||||||
'meetings/' +
|
|
||||||
Math.trunc(Math.random() * 1000000000);
|
Math.trunc(Math.random() * 1000000000);
|
||||||
|
|
||||||
$('.meeting-url').val(link);
|
$('.meeting-url').val(link);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (meetingId = $('.meeting-url').data('meetingId')) {
|
||||||
$('.meeting-url').val('');
|
var link = window.location.protocol +
|
||||||
|
'//' +
|
||||||
|
window.location.hostname +
|
||||||
|
'/meetings/' +
|
||||||
|
meetingId;
|
||||||
|
$('.meeting-url').val(link)
|
||||||
|
} else {
|
||||||
$('.generate-link').click();
|
$('.generate-link').click();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var initRooms = function() {
|
var initRooms = function() {
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
var meetingInstance = null;
|
||||||
|
class Meeting {
|
||||||
|
constructor(url, name) {
|
||||||
|
this.url = url;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance() {
|
||||||
|
if (meetingInstance) {
|
||||||
|
return meetingInstance;
|
||||||
|
}
|
||||||
|
var url = $('.meeting-url').val();
|
||||||
|
var name = $('.meeting-user-name').val();
|
||||||
|
meetingInstance = new Meeting(url, name);
|
||||||
|
return meetingInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
getjoinMeetingURL() {
|
||||||
|
return $.get(this.url + "/join?name=" + this.name, function() {
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setURL(url) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
setName(name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
setModJoined(modJoined) {
|
||||||
|
this.modJoined = modJoined;
|
||||||
|
}
|
||||||
|
getModJoined() {
|
||||||
|
return this.modJoined;
|
||||||
|
}
|
||||||
|
setWaitingForMod(wMod) {
|
||||||
|
this.waitingForMod = wMod;
|
||||||
|
}
|
||||||
|
getWaitingForMod() {
|
||||||
|
return this.waitingForMod;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var loopJoin = function() {
|
||||||
|
var jqxhr = Meeting.getInstance().getjoinMeetingURL();
|
||||||
|
jqxhr.done(function(data) {
|
||||||
|
if (data.messageKey === 'wait_for_moderator') {
|
||||||
|
setTimeout(loopJoin, 5000);
|
||||||
|
} else {
|
||||||
|
$(location).attr("href", data.response.join_url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
jqxhr.fail(function(xhr, status, error) {
|
||||||
|
console.info("meeting join failed");
|
||||||
|
});
|
||||||
|
}
|
|
@ -27,7 +27,7 @@ html, body {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-box {
|
.center-panel {
|
||||||
.center-block {
|
.center-block {
|
||||||
float: none;
|
float: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
class ModeratorJoinsChannel < ApplicationCable::Channel
|
||||||
|
def subscribed
|
||||||
|
stream_from "moderator_#{params[:username]}_join_channel"
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,33 +1,46 @@
|
||||||
class BbbController < ApplicationController
|
class BbbController < ApplicationController
|
||||||
|
|
||||||
# GET /join
|
# GET /:resource/:id/join
|
||||||
# GET /join.json
|
|
||||||
def join
|
def join
|
||||||
if ( !params.has_key?(:id) )
|
if ( params[:id].blank? )
|
||||||
render_response("missing_parameter", "meeting token was not included", :bad_request)
|
render_response("missing_parameter", "meeting token was not included", :bad_request)
|
||||||
elsif ( !params.has_key?(:name) )
|
elsif ( params[:name].blank? )
|
||||||
render_response("missing_parameter", "user name was not included", :bad_request)
|
render_response("missing_parameter", "user name was not included", :bad_request)
|
||||||
else
|
else
|
||||||
bbb_join_url = helpers.bbb_join_url(params[:id], false, params[:name], false, "#{request.base_url}/#{params[:resource]}/#{params[:id]}")
|
user = User.find_by username: params[:id]
|
||||||
if bbb_join_url[:returncode]
|
|
||||||
logger.info "#Execute the redirect"
|
options = if user
|
||||||
render_response("ok", "execute the redirect", :ok, {:join_url => bbb_join_url[:join_url]})
|
{
|
||||||
|
wait_for_moderator: true,
|
||||||
|
user_is_moderator: current_user == user
|
||||||
|
}
|
||||||
else
|
else
|
||||||
render_response("bigbluebutton_error", "join url could not be created", :internal_server_error)
|
{}
|
||||||
end
|
end
|
||||||
|
options[:meeting_logout_url] = "#{request.base_url}/#{params[:resource]}/#{params[:id]}"
|
||||||
|
|
||||||
|
bbb_res = helpers.bbb_join_url(
|
||||||
|
params[:id],
|
||||||
|
params[:name],
|
||||||
|
options
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if bbb_res[:returncode] && current_user && current_user == user
|
||||||
|
ActionCable.server.broadcast "moderator_#{user.username}_join_channel",
|
||||||
|
moderator: "joined"
|
||||||
|
end
|
||||||
|
|
||||||
|
render_response bbb_res[:messageKey], bbb_res[:message], bbb_res[:status], bbb_res[:response]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def render_response(messageKey, message, status, response={})
|
def render_response(messageKey, message, status, response={})
|
||||||
respond_to do |format|
|
@messageKey = messageKey
|
||||||
if (status == :ok)
|
@message = message
|
||||||
format.html { render :template => "bbb/join" }
|
@status = status
|
||||||
format.json { render :json => { :messageKey => messageKey, :message => message, :status => status, :response => response }, :status => status }
|
@response = response
|
||||||
else
|
render status: @status
|
||||||
format.html { render :template => "errors/error" }
|
|
||||||
format.json { render :json => { :messageKey => messageKey, :message => message, :status => status, :response => response }, :status => status }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -10,6 +10,10 @@ class LandingController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def wait_for_moderator
|
||||||
|
render layout: false
|
||||||
|
end
|
||||||
|
|
||||||
def admin?
|
def admin?
|
||||||
@user == current_user
|
@user == current_user
|
||||||
end
|
end
|
||||||
|
@ -18,8 +22,9 @@ class LandingController < ApplicationController
|
||||||
private
|
private
|
||||||
|
|
||||||
def render_meeting
|
def render_meeting
|
||||||
|
@meeting_id = params[:id]
|
||||||
params[:action] = 'meetings'
|
params[:action] = 'meetings'
|
||||||
render :action => 'meeting'
|
render :action => 'meetings'
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_room
|
def render_room
|
||||||
|
@ -29,7 +34,7 @@ class LandingController < ApplicationController
|
||||||
redirect_to root_path
|
redirect_to root_path
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
render :action => 'room'
|
render :action => 'rooms'
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,10 +13,15 @@ module BbbHelper
|
||||||
return password
|
return password
|
||||||
end
|
end
|
||||||
|
|
||||||
def bbb_join_url(meeting_token, meeting_recorded=false, user_fullname='User', user_is_moderator=false, meeting_logout_url=nil)
|
def bbb_join_url(meeting_token, full_name, options={})
|
||||||
|
options[:meeting_recorded] ||= false
|
||||||
|
options[:user_is_moderator] ||= false
|
||||||
|
options[:wait_for_moderator] ||= false
|
||||||
|
options[:meeting_logout_url] ||= nil
|
||||||
|
|
||||||
bbb ||= BigBlueButton::BigBlueButtonApi.new(bbb_endpoint + "api", bbb_secret, "0.8", true)
|
bbb ||= BigBlueButton::BigBlueButtonApi.new(bbb_endpoint + "api", bbb_secret, "0.8", true)
|
||||||
if !bbb
|
if !bbb
|
||||||
return { :returncode => false, :messageKey => "BBBAPICallInvalid", :message => "BBB API call invalid." }
|
return call_invalid_res
|
||||||
else
|
else
|
||||||
meeting_id = (Digest::SHA1.hexdigest(Rails.application.secrets[:secret_key_base]+meeting_token)).to_s
|
meeting_id = (Digest::SHA1.hexdigest(Rails.application.secrets[:secret_key_base]+meeting_token)).to_s
|
||||||
|
|
||||||
|
@ -25,13 +30,18 @@ module BbbHelper
|
||||||
bbb_meeting_info = bbb.get_meeting_info( meeting_id, nil )
|
bbb_meeting_info = bbb.get_meeting_info( meeting_id, nil )
|
||||||
rescue BigBlueButton::BigBlueButtonException => exc
|
rescue BigBlueButton::BigBlueButtonException => exc
|
||||||
# This means that is not created
|
# This means that is not created
|
||||||
|
|
||||||
|
if options[:wait_for_moderator] && !options[:user_is_moderator]
|
||||||
|
return wait_moderator_res
|
||||||
|
end
|
||||||
|
|
||||||
logger.info "Message for the log file #{exc.key}: #{exc.message}"
|
logger.info "Message for the log file #{exc.key}: #{exc.message}"
|
||||||
|
|
||||||
# Prepare parameters for create
|
# Prepare parameters for create
|
||||||
logout_url = meeting_logout_url || logout_url = "#{request.base_url}"
|
logout_url = options[:meeting_logout_url] || "#{request.base_url}"
|
||||||
moderator_password = random_password(12)
|
moderator_password = random_password(12)
|
||||||
viewer_password = random_password(12)
|
viewer_password = random_password(12)
|
||||||
meeting_options = {:record => meeting_recorded.to_s, :logoutURL => logout_url, :moderatorPW => moderator_password, :attendeePW => viewer_password }
|
meeting_options = {:record => options[:meeting_recorded].to_s, :logoutURL => logout_url, :moderatorPW => moderator_password, :attendeePW => viewer_password }
|
||||||
|
|
||||||
# Create the meeting
|
# Create the meeting
|
||||||
bbb.create_meeting(meeting_token, meeting_id, meeting_options)
|
bbb.create_meeting(meeting_token, meeting_id, meeting_options)
|
||||||
|
@ -40,14 +50,48 @@ module BbbHelper
|
||||||
bbb_meeting_info = bbb.get_meeting_info( meeting_id, nil )
|
bbb_meeting_info = bbb.get_meeting_info( meeting_id, nil )
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if options[:wait_for_moderator] && !options[:user_is_moderator] && bbb_meeting_info[:moderatorCount] <= 0
|
||||||
|
return wait_moderator_res
|
||||||
|
end
|
||||||
|
|
||||||
# Get the join url
|
# Get the join url
|
||||||
if (user_is_moderator)
|
if (options[:user_is_moderator])
|
||||||
password = bbb_meeting_info[:moderatorPW]
|
password = bbb_meeting_info[:moderatorPW]
|
||||||
else
|
else
|
||||||
password = bbb_meeting_info[:attendeePW]
|
password = bbb_meeting_info[:attendeePW]
|
||||||
end
|
end
|
||||||
join_url = bbb.join_meeting_url(meeting_id, user_fullname, password )
|
join_url = bbb.join_meeting_url(meeting_id, full_name, password )
|
||||||
return { :returncode => true, :join_url => join_url, :messageKey => "", :message => "" }
|
return success_res(join_url)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def success_res(join_url)
|
||||||
|
{
|
||||||
|
returncode: true,
|
||||||
|
messageKey: "ok",
|
||||||
|
message: "Execute the redirect",
|
||||||
|
status: :ok,
|
||||||
|
response: {
|
||||||
|
join_url: join_url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_moderator_res
|
||||||
|
{
|
||||||
|
returncode: false,
|
||||||
|
messageKey: "wait_for_moderator",
|
||||||
|
message: "Waiting for moderator",
|
||||||
|
status: :ok
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def call_invalid_res
|
||||||
|
{
|
||||||
|
returncode: false,
|
||||||
|
messageKey: "BBB_API_call_invalid",
|
||||||
|
message: "BBB API call invalid.",
|
||||||
|
status: :internal_server_error
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
<h1>Bbb#end</h1>
|
|
||||||
<p>Find me in app/views/bbb/end.html.erb</p>
|
|
|
@ -1,2 +0,0 @@
|
||||||
<h1>Bbb#join</h1>
|
|
||||||
<p>Find me in app/views/bbb/join.html.erb</p>
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
json.messageKey @messageKey
|
||||||
|
json.message @message
|
||||||
|
json.status @status
|
||||||
|
if @response
|
||||||
|
json.response do
|
||||||
|
json.join_url(@response[:join_url]) if @response[:join_url]
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
<% content_for :title do %>
|
<% content_for :title do %>
|
||||||
<div class="title">
|
<div class="title">
|
||||||
Hi Everyone
|
Hi Everyone
|
|
@ -21,6 +21,7 @@
|
||||||
|
|
||||||
<%= render 'shared/title', title: page_title %>
|
<%= render 'shared/title', title: page_title %>
|
||||||
|
|
||||||
|
<div class="center-panel-wrapper">
|
||||||
<%= render layout: 'shared/center_panel' do %>
|
<%= render layout: 'shared/center_panel' do %>
|
||||||
<div class="center-block col-sm-8">
|
<div class="center-block col-sm-8">
|
||||||
<% if current_user == @user %>
|
<% if current_user == @user %>
|
||||||
|
@ -31,6 +32,7 @@
|
||||||
<%= render 'shared/join_form', user: current_user %>
|
<%= render 'shared/join_form', user: current_user %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -0,0 +1,11 @@
|
||||||
|
<% content_for :title do %>
|
||||||
|
<div class="title">
|
||||||
|
Looks like you're the first one here...
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
You will automatically join when the meeting starts
|
||||||
|
</small>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render layout: 'shared/center_panel' do %>
|
||||||
|
<% end %>
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<title>Greenlight</title>
|
<title>Greenlight</title>
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
|
<%= action_cable_meta_tag %>
|
||||||
|
|
||||||
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
|
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
|
||||||
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
|
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<div class="content-box">
|
<div class="center-panel">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="center-block col-sm-4">
|
<div class="center-block col-sm-4">
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<div <%= "hidden" if hidden %> class="meetin-url-wrapper">
|
<div <%= "hidden" if hidden %> class="meeting-url-wrapper">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" readonly="readonly" class="form-control meeting-url" data-path="<%= @user.room_url if @user %>"/>
|
<input type="text" readonly="readonly" class="form-control meeting-url" data-meeting-id="<%= @meeting_id %>" data-path="<%= @user.room_url if @user %>"/>
|
||||||
<span class="input-group-btn">
|
<span class="input-group-btn">
|
||||||
<button type="button" class="btn btn-default meeting-url-copy">
|
<button type="button" class="btn btn-default meeting-url-copy">
|
||||||
<i class="fa fa-paperclip" aria-hidden="true"></i>
|
<i class="fa fa-paperclip" aria-hidden="true"></i>
|
||||||
|
|
|
@ -31,6 +31,12 @@ Rails.application.configure do
|
||||||
|
|
||||||
config.action_mailer.perform_caching = false
|
config.action_mailer.perform_caching = false
|
||||||
|
|
||||||
|
# action cable socket URI
|
||||||
|
config.action_cable.url = "ws://localhost/cable"
|
||||||
|
|
||||||
|
# allowed action cable origins
|
||||||
|
Rails.application.config.action_cable.allowed_request_origins = ['http://localhost']
|
||||||
|
|
||||||
# Print deprecation notices to the Rails logger.
|
# Print deprecation notices to the Rails logger.
|
||||||
config.active_support.deprecation = :log
|
config.active_support.deprecation = :log
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
|
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
|
||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
|
mount ActionCable.server => '/cable'
|
||||||
|
|
||||||
resources :users, only: [:edit, :update]
|
resources :users, only: [:edit, :update]
|
||||||
get '/users/logout', to: 'sessions#destroy', as: :user_logout
|
get '/users/logout', to: 'sessions#destroy', as: :user_logout
|
||||||
|
|
||||||
|
@ -7,9 +10,9 @@ Rails.application.routes.draw do
|
||||||
# There are two resources [meetings|rooms]
|
# There are two resources [meetings|rooms]
|
||||||
# meetings offer a landing page for NON authenticated users to create and join session in BigBlueButton
|
# meetings offer a landing page for NON authenticated users to create and join session in BigBlueButton
|
||||||
# rooms offer a customized landing page for authenticated users to create and join session in BigBlueButton
|
# rooms offer a customized landing page for authenticated users to create and join session in BigBlueButton
|
||||||
get '/:resource(/:id)', to: 'landing#index', as: :resource
|
get '/:resource/:id', to: 'landing#index', as: :resource
|
||||||
get '/:resource/:id/join', to: 'bbb#join', as: :bbb_join
|
get '/:resource/:id/join', to: 'bbb#join', as: :bbb_join, defaults: { :format => 'json' }
|
||||||
|
get '/:resource/:id/wait', to: 'landing#wait_for_moderator'
|
||||||
|
|
||||||
root to: 'landing#index', :resource => "meetings"
|
root to: 'landing#index', :resource => "meetings"
|
||||||
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
|
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue