forked from treehouse/mastodon
Adding OAuth access scopes, fixing OAuth authorization UI, adding rate limiting
to the APIrebase/4.0.0rc2
parent
17122df80d
commit
a9e40a3d80
54
.rubocop.yml
54
.rubocop.yml
|
@ -1,14 +1,60 @@
|
||||||
Rails:
|
Rails:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
Metrics/LineLength:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
Style/PerlBackrefs:
|
Style/PerlBackrefs:
|
||||||
AutoCorrect: false
|
AutoCorrect: false
|
||||||
|
|
||||||
Style/ClassAndModuleChildren:
|
Style/ClassAndModuleChildren:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Documentation:
|
Metrics/BlockNesting:
|
||||||
|
Max: 2
|
||||||
|
|
||||||
|
Metrics/LineLength:
|
||||||
|
AllowURI: true
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Metrics/MethodLength:
|
||||||
|
CountComments: false
|
||||||
|
Max: 10
|
||||||
|
|
||||||
|
Metrics/ModuleLength:
|
||||||
|
Max: 100
|
||||||
|
|
||||||
|
Metrics/ParameterLists:
|
||||||
|
Max: 4
|
||||||
|
CountKeywordArgs: true
|
||||||
|
|
||||||
|
Style/AccessModifierIndentation:
|
||||||
|
EnforcedStyle: indent
|
||||||
|
|
||||||
|
Style/CollectionMethods:
|
||||||
|
Enabled: true
|
||||||
|
PreferredMethods:
|
||||||
|
find_all: 'select'
|
||||||
|
|
||||||
|
Style/Documentation:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/DoubleNegation:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/FrozenStringLiteralComment:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/SpaceInsideHashLiteralBraces:
|
||||||
|
EnforcedStyle: space
|
||||||
|
|
||||||
|
Style/TrailingCommaInLiteral:
|
||||||
|
EnforcedStyleForMultiline: 'comma'
|
||||||
|
|
||||||
|
Style/RegexpLiteral:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
AllCops:
|
||||||
|
TargetRubyVersion: 2.2
|
||||||
|
Exclude:
|
||||||
|
- 'spec/**/*'
|
||||||
|
- 'db/**/*'
|
||||||
|
- 'app/views/**/*'
|
||||||
|
- 'config/**/*'
|
||||||
|
|
|
@ -85,18 +85,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt {
|
code {
|
||||||
font-size: 16px;
|
|
||||||
color: #9baec8;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.prompt-highlight {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
code.copypasteable {
|
|
||||||
display: block;
|
display: block;
|
||||||
font-family: 'Roboto Mono', monospace;
|
font-family: 'Roboto Mono', monospace;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
@ -110,42 +99,42 @@
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #2b90d9;
|
background: #2b90d9;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: lighten(#2b90d9, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active, &:focus {
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
background-color: darken(#2b90d9, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.negative {
|
||||||
|
background: #df405a;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: lighten(#2b90d9, 5%);
|
background-color: lighten(#df405a, 5%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active, &:focus {
|
&:active, &:focus {
|
||||||
position: relative;
|
background-color: darken(#df405a, 5%);
|
||||||
top: 1px;
|
|
||||||
background-color: darken(#2b90d9, 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.negative {
|
|
||||||
background: #df405a;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: lighten(#df405a, 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active, &:focus {
|
|
||||||
background-color: darken(#df405a, 5%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -180,3 +169,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.oauth-prompt {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: #9baec8;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #d9e1e8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
|
# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
|
||||||
class PublicChannel < ApplicationCable::Channel
|
class PublicChannel < ApplicationCable::Channel
|
||||||
def subscribed
|
def subscribed
|
||||||
stream_from 'timeline:public', -> (encoded_message) do
|
stream_from 'timeline:public', lambda do |encoded_message|
|
||||||
message = ActiveSupport::JSON.decode(encoded_message)
|
message = ActiveSupport::JSON.decode(encoded_message)
|
||||||
|
|
||||||
status = Status.find_by(id: message['id'])
|
status = Status.find_by(id: message['id'])
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
class Api::V1::AccountsController < ApiController
|
class Api::V1::AccountsController < ApiController
|
||||||
before_action :doorkeeper_authorize!
|
before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock]
|
||||||
|
before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock]
|
||||||
|
|
||||||
before_action :set_account, except: [:verify_credentials, :suggestions]
|
before_action :set_account, except: [:verify_credentials, :suggestions]
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class Api::V1::FollowsController < ApiController
|
class Api::V1::FollowsController < ApiController
|
||||||
before_action :doorkeeper_authorize!
|
before_action -> { doorkeeper_authorize! :follow }
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class Api::V1::MediaController < ApiController
|
class Api::V1::MediaController < ApiController
|
||||||
before_action :doorkeeper_authorize!
|
before_action -> { doorkeeper_authorize! :write }
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
class Api::V1::StatusesController < ApiController
|
class Api::V1::StatusesController < ApiController
|
||||||
before_action :doorkeeper_authorize!
|
before_action -> { doorkeeper_authorize! :read }, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
|
||||||
|
before_action -> { doorkeeper_authorize! :write }, only: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
|
||||||
|
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
class ApiController < ApplicationController
|
class ApiController < ApplicationController
|
||||||
protect_from_forgery with: :null_session
|
protect_from_forgery with: :null_session
|
||||||
|
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
|
|
||||||
|
before_action :set_rate_limit_headers
|
||||||
|
|
||||||
rescue_from ActiveRecord::RecordInvalid do |e|
|
rescue_from ActiveRecord::RecordInvalid do |e|
|
||||||
render json: { error: e.to_s }, status: 422
|
render json: { error: e.to_s }, status: 422
|
||||||
end
|
end
|
||||||
|
@ -22,8 +25,27 @@ class ApiController < ApplicationController
|
||||||
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
|
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def doorkeeper_unauthorized_render_options(*)
|
||||||
|
{ json: { error: 'Not authorized' } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def doorkeeper_forbidden_render_options(*)
|
||||||
|
{ json: { error: 'This action is outside the authorized scopes' } }
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
|
def set_rate_limit_headers
|
||||||
|
return if request.env['rack.attack.throttle_data'].nil?
|
||||||
|
|
||||||
|
now = Time.now.utc
|
||||||
|
match_data = request.env['rack.attack.throttle_data']['api']
|
||||||
|
|
||||||
|
response.headers['X-RateLimit-Limit'] = match_data[:limit].to_s
|
||||||
|
response.headers['X-RateLimit-Remaining'] = (match_data[:limit] - match_data[:count]).to_s
|
||||||
|
response.headers['X-RateLimit-Reset'] = (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
|
||||||
|
end
|
||||||
|
|
||||||
def current_resource_owner
|
def current_resource_owner
|
||||||
User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
|
User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,6 +15,6 @@ class HomeController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_or_create_access_token
|
def find_or_create_access_token
|
||||||
Doorkeeper::AccessToken.find_or_create_for(Doorkeeper::Application.where(superapp: true).first, current_user.id, nil, Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled?)
|
Doorkeeper::AccessToken.find_or_create_for(Doorkeeper::Application.where(superapp: true).first, current_user.id, 'read write follow', Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled?)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||||
|
before_action :store_current_location
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def store_current_location
|
||||||
|
store_location_for(:user, request.url)
|
||||||
|
end
|
||||||
|
end
|
|
@ -7,7 +7,7 @@ class Feed
|
||||||
def get(limit, max_id = nil, since_id = nil)
|
def get(limit, max_id = nil, since_id = nil)
|
||||||
max_id = '+inf' if max_id.blank?
|
max_id = '+inf' if max_id.blank?
|
||||||
since_id = '-inf' if since_id.blank?
|
since_id = '-inf' if since_id.blank?
|
||||||
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).collect(&:last).map(&:to_i)
|
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i)
|
||||||
|
|
||||||
# If we're after most recent items and none are there, we need to precompute the feed
|
# If we're after most recent items and none are there, we need to precompute the feed
|
||||||
if unhydrated.empty? && max_id == '+inf' && since_id == '-inf'
|
if unhydrated.empty? && max_id == '+inf' && since_id == '-inf'
|
||||||
|
|
|
@ -34,7 +34,8 @@ class MediaAttachment < ApplicationRecord
|
||||||
image? ? 'image' : 'video'
|
image? ? 'image' : 'video'
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def self.file_styles(f)
|
def self.file_styles(f)
|
||||||
if f.instance.image?
|
if f.instance.image?
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
.prompt= t('doorkeeper.authorizations.error.title')
|
|
||||||
|
|
||||||
#error_explanation
|
|
||||||
= @pre_auth.error_response.body[:error_description]
|
|
|
@ -1,26 +0,0 @@
|
||||||
.prompt= raw t('.prompt', client_name: "<strong class=\"prompt-highlight\">#{ @pre_auth.client.name }</strong>")
|
|
||||||
|
|
||||||
/- if @pre_auth.scopes.count > 0
|
|
||||||
/ .scope-permission-prompt
|
|
||||||
/ %p= t('.able_to')
|
|
||||||
|
|
||||||
/ %ul.scope-permissions
|
|
||||||
/ - @pre_auth.scopes.each do |scope|
|
|
||||||
/ %li= t scope, scope: [:doorkeeper, :scopes]
|
|
||||||
|
|
||||||
.actions
|
|
||||||
= form_tag oauth_authorization_path, method: :post do
|
|
||||||
= hidden_field_tag :client_id, @pre_auth.client.uid
|
|
||||||
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
|
|
||||||
= hidden_field_tag :state, @pre_auth.state
|
|
||||||
= hidden_field_tag :response_type, @pre_auth.response_type
|
|
||||||
= hidden_field_tag :scope, @pre_auth.scope
|
|
||||||
= button_tag t('doorkeeper.authorizations.buttons.authorize'), type: :submit
|
|
||||||
|
|
||||||
= form_tag oauth_authorization_path, method: :delete do
|
|
||||||
= hidden_field_tag :client_id, @pre_auth.client.uid
|
|
||||||
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
|
|
||||||
= hidden_field_tag :state, @pre_auth.state
|
|
||||||
= hidden_field_tag :response_type, @pre_auth.response_type
|
|
||||||
= hidden_field_tag :scope, @pre_auth.scope
|
|
||||||
= button_tag t('doorkeeper.authorizations.buttons.deny'), type: :submit, class: 'negative'
|
|
|
@ -1,2 +0,0 @@
|
||||||
.prompt= t('.title')
|
|
||||||
%code.copypasteable= params[:code]
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
.flash-message#error_explanation
|
||||||
|
= @pre_auth.error_response.body[:error_description]
|
|
@ -0,0 +1,25 @@
|
||||||
|
.oauth-prompt
|
||||||
|
%h2
|
||||||
|
Application
|
||||||
|
%strong=@pre_auth.client.name
|
||||||
|
requests access to your account
|
||||||
|
|
||||||
|
%p
|
||||||
|
It will be able to
|
||||||
|
= @pre_auth.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.map { |s| "<strong>#{s}</strong>"}.to_sentence.html_safe
|
||||||
|
|
||||||
|
= form_tag oauth_authorization_path, method: :post, class: 'simple_form' do
|
||||||
|
= hidden_field_tag :client_id, @pre_auth.client.uid
|
||||||
|
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
|
||||||
|
= hidden_field_tag :state, @pre_auth.state
|
||||||
|
= hidden_field_tag :response_type, @pre_auth.response_type
|
||||||
|
= hidden_field_tag :scope, @pre_auth.scope
|
||||||
|
= button_tag t('doorkeeper.authorizations.buttons.authorize'), type: :submit
|
||||||
|
|
||||||
|
= form_tag oauth_authorization_path, method: :delete, class: 'simple_form' do
|
||||||
|
= hidden_field_tag :client_id, @pre_auth.client.uid
|
||||||
|
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
|
||||||
|
= hidden_field_tag :state, @pre_auth.state
|
||||||
|
= hidden_field_tag :response_type, @pre_auth.response_type
|
||||||
|
= hidden_field_tag :scope, @pre_auth.scope
|
||||||
|
= button_tag t('doorkeeper.authorizations.buttons.deny'), type: :submit, class: 'negative'
|
|
@ -0,0 +1 @@
|
||||||
|
%code= params[:code]
|
|
@ -12,7 +12,7 @@ Rails.application.configure do
|
||||||
|
|
||||||
# Full error reports are disabled and caching is turned on.
|
# Full error reports are disabled and caching is turned on.
|
||||||
config.consider_all_requests_local = false
|
config.consider_all_requests_local = false
|
||||||
config.action_controller.perform_caching = false
|
config.action_controller.perform_caching = true
|
||||||
|
|
||||||
# Disable serving static files from the `/public` folder by default since
|
# Disable serving static files from the `/public` folder by default since
|
||||||
# Apache or NGINX already handles this.
|
# Apache or NGINX already handles this.
|
||||||
|
|
|
@ -50,8 +50,8 @@ Doorkeeper.configure do
|
||||||
# Define access token scopes for your provider
|
# Define access token scopes for your provider
|
||||||
# For more information go to
|
# For more information go to
|
||||||
# https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes
|
# https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes
|
||||||
# default_scopes :public
|
default_scopes :read
|
||||||
# optional_scopes :write, :follow
|
optional_scopes :write, :follow
|
||||||
|
|
||||||
# Change the way client credentials are retrieved from the request object.
|
# Change the way client credentials are retrieved from the request object.
|
||||||
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
|
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
Rabl.configure do |config|
|
Rabl.configure do |config|
|
||||||
config.cache_all_output = true
|
config.cache_all_output = false
|
||||||
config.cache_sources = !!Rails.env.production?
|
config.cache_sources = !!Rails.env.production?
|
||||||
config.include_json_root = false
|
config.include_json_root = false
|
||||||
config.view_paths = [Rails.root.join('app/views')]
|
config.view_paths = [Rails.root.join('app/views')]
|
||||||
|
|
|
@ -1,9 +1,19 @@
|
||||||
class Rack::Attack
|
class Rack::Attack
|
||||||
throttle('get-req/ip', limit: 300, period: 5.minutes) do |req|
|
# Rate limits for the API
|
||||||
req.ip if req.get?
|
throttle('api', limit: 150, period: 5.minutes) do |req|
|
||||||
|
req.ip if req.path.match(/\A\/api\//)
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('post-req/ip', limit: 100, period: 5.minutes) do |req|
|
self.throttled_response = lambda do |env|
|
||||||
req.ip if req.post?
|
now = Time.now.utc
|
||||||
|
match_data = env['rack.attack.match_data']
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'X-RateLimit-Limit' => match_data[:limit].to_s,
|
||||||
|
'X-RateLimit-Remaining' => '0',
|
||||||
|
'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
|
||||||
|
}
|
||||||
|
|
||||||
|
[429, headers, [{ error: 'Throttled' }.to_json]]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,6 +15,10 @@ en:
|
||||||
secured_uri: 'must be an HTTPS/SSL URI.'
|
secured_uri: 'must be an HTTPS/SSL URI.'
|
||||||
|
|
||||||
doorkeeper:
|
doorkeeper:
|
||||||
|
scopes:
|
||||||
|
read: read your account's data
|
||||||
|
write: post on your behalf
|
||||||
|
follow: follow, block, unblock and unfollow accounts
|
||||||
applications:
|
applications:
|
||||||
confirmations:
|
confirmations:
|
||||||
destroy: 'Are you sure?'
|
destroy: 'Are you sure?'
|
||||||
|
|
|
@ -7,7 +7,9 @@ Rails.application.routes.draw do
|
||||||
mount Sidekiq::Web => '/sidekiq'
|
mount Sidekiq::Web => '/sidekiq'
|
||||||
end
|
end
|
||||||
|
|
||||||
use_doorkeeper
|
use_doorkeeper do
|
||||||
|
controllers authorizations: 'oauth/authorizations'
|
||||||
|
end
|
||||||
|
|
||||||
get '.well-known/host-meta', to: 'xrd#host_meta', as: :host_meta
|
get '.well-known/host-meta', to: 'xrd#host_meta', as: :host_meta
|
||||||
get '.well-known/webfinger', to: 'xrd#webfinger', as: :webfinger
|
get '.well-known/webfinger', to: 'xrd#webfinger', as: :webfinger
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
web_app = Doorkeeper::Application.new(name: 'Web', superapp: true, redirect_uri: Doorkeeper.configuration.native_redirect_uri)
|
web_app = Doorkeeper::Application.new(name: 'Web', superapp: true, redirect_uri: Doorkeeper.configuration.native_redirect_uri, scopes: 'read write follow')
|
||||||
web_app.save!
|
web_app.save!
|
||||||
|
|
|
@ -2,9 +2,7 @@ namespace :mastodon do
|
||||||
namespace :media do
|
namespace :media do
|
||||||
desc 'Removes media attachments that have not been assigned to any status for longer than a day'
|
desc 'Removes media attachments that have not been assigned to any status for longer than a day'
|
||||||
task clear: :environment do
|
task clear: :environment do
|
||||||
MediaAttachment.where(status_id: nil).where('created_at < ?', 1.day.ago).find_each do |m|
|
MediaAttachment.where(status_id: nil).where('created_at < ?', 1.day.ago).find_each(&:destroy)
|
||||||
m.destroy
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue