2017/11/29

Ruby - 使用多態關聯

使用 多態關聯 Polymorphic Associations

目的:在一個表格可能被多個表格參考時,不使用多個 references 去儲存參考,而是使用一個 reference + 一個 type 欄位去儲存。

建立:

class CreatePictures < ActiveRecord::Migration[5.0]
  def change
    create_table :pictures do |t|
      t.string :name
      t.references :imageable, polymorphic: true, index: true
      t.timestamps
    end
  end
end
class Picture < ApplicationRecord
  belongs_to :imageable, polymorphic: true
end

class Employee < ApplicationRecord
  has_many :pictures, as: :imageable
end

class Product < ApplicationRecord
  has_many :pictures, as: :imageable
end

使用:

Product.last.pictures
Employee.last.pictures

Ruby - 使用 Devise confirmable

Devise confirmable

當你想要認證註冊者的信箱時可以使用 confirmable

安裝方式請參考:https://github.com/plataformatec/devise/wiki/How-To:-Add-:confirmable-to-Users

以下說明一些實務上的可能會遇到的細節調整方式。

什麼時候會寄出信?

建立 user 時

在建立 user 時,會在呼叫 user.save 後寄信給 user。

user = User.create
user.save

user email 更新時

在編輯 user 時,若 email 有修改,會在呼叫 user.save 後寄信給 user。

user = User.find(params[:id])
user.email = 'QQ@QQ'
user.save

此時寫入的 email 會被保存到 unconfirmed_email,而原先的 email 欄位在 user 完成認證之前不會改變。

開發時測試寄信的方法

在開發時可能會希望不要真的寄出信件,此時可以使用 letter_opener,他會在需要寄信時,只將信件內容印在 console log 上。

設定方法是在 config/environments/development.rb 加入以下程式碼:

  config.action_mailer.delivery_method = :letter_opener
  config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

confirmable 自動測試

加入了 confirmable 之後可能會導致 test fail,因為 devise 嘗試 send mail 但是 test 環境下可能無法正確寄信。

避免寄出信件的方法

如果你希望在測試時不要寄信,如果你使用 FactoryBot 這個套件來生成 user,以下是跳過 email 驗證的方法:

FactoryBot.define do
  factory :user do
    after(:build)   { |u| u.skip_confirmation! }
  end
end

在不使用 FactoryBot 的情況下,跳過 email 驗證的方法:

user = User.new
user.skip_confirmation!
user.save

成功寄出信件的方法

如果你希望寄信,但是你沒有設定 host 值,那麼你會看見這個:

ActionView::Template::Error: Missing host to link to! Please provide the :host parameter, set default_url_options[:host], or set :only_path to true

此時你需要加入以下內容至 config/environments/test.rb:

  config.action_mailer.delivery_method = :test
  config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

測試寄件內容

使用 ActionMailer::Base.deliveries.last 可以取得最後一次寄出的信件內容:

user = User.new
user.save
mail = ActionMailer::Base.deliveries.last

mail.from
mail.to
mail.subject
mail.body.to_s

因此可以對信件內容做測試。

模擬使用者完成認證

用 code 完成認證的方法

user = User.find(params[:id])
user.confirm

同一個認證連結被點擊第二次會發生什麼事?

當 user 點擊第二次認證信連結時,預設是會顯示「 Email was already confirmed, please try signing in. 」字樣。可以透過自訂 controller 去修改預設行為

怎麼修改認證信內容?

改 template 改 template 路徑

自定義寄信路徑

class DeviseMailer < Devise::Mailer   
  helper :application # gives access to all helpers defined within `application_helper`.
  include Devise::Controllers::UrlHelpers # Optional. eg. `confirmation_url`

  def headers_for(action, opts)
    super.merge!({template_path: '/users/mailer'}) # this moves the Devise template path from /views/devise/mailer to /views/users/mailer
  end

  # def confirmation_instructions(record, token, opts={})
  #   headers["Custom-header"] = "Bar"
  #   opts[:from] = 'my_custom_from@domain.com'
  #   opts[:reply_to] = 'my_custom_from@domain.com'
  #   super
  # end
end

參考資料:

測試寄信的方法 http://guides.rubyonrails.org/testing.html#testing-your-mailers

阻止修改mail時的認證:https://coderwall.com/p/7_yh8q/skip-devise-email-confirmation-on-update

2017/11/28

Ruby - 使用 Pundit

Pundit 是一個用來做身分驗證的工具。對於每一個 Model 來說,目前登入者能不能對這個 Model 進行某種操作,可以被定義在 Policy 上。

Pundit 並不是一個複雜的 gem,但仍然很多人使用,我認為他存在的價值跟 rails 一樣,都是在提出一個收納的概念,教你如何存放你的 code 到正確的位置。

Pundit:我認為所有跟登入者權限相關的東西都應該被儲存在同一個 class(Policy),同一個資料夾下(Policy),我創造了一個架構,使得所有按照我架構的寫法的 code 可以少寫一些字,並且讓專案的 code 看起來更乾淨。

如果你想要檢查登入者(user) 有沒有辦法對資料(record) 進行某種操作 (update?),可以這樣寫:

class PostPolicy < ApplicationPolicy
  def update?
    user.admin? or not record.published?
  end
end

下面的 code 跟 上面的 code 是等價的

class PostPolicy
  attr_reader :user, :post

  def initialize(user, post)
    @user = user
    @post = post
  end

  def update?
    user.admin? or not post.published?
  end
end

當你在 Controller 的 action 裡面寫到 authorize model 時,Pundit 會幫你看有沒有跟 action 同名的 Policy。透過 currentuser 跟 model 這兩個值去做檢查。 注意:Devise 剛好會生成一個方法叫做是 currentuser。

舉例來說,這個 action:

def update
  @post = Post.find(params[:id])
  authorize @post
  if @post.update(post_params)
    redirect_to @post
  else
    render :edit
  end
end

執行 authorize @post 時,等於去執行以下程式:

  unless PostPolicy.new(current_user, @post).update?
    raise Pundit::NotAuthorizedError, "not allowed to update? this #{@post.inspect}"
  end

你也可以自己指定要執行的 policy 方法名稱

authorize @post :update?

如果 policy 不需要 record 變數就可以做,則 record 可以不傳,改傳 record 的 Class

# in controller
def admin_list
  authorize Post # we don't have a particular post to authorize
  # Rest of controller action
end

# in policy
class PostPolicy < ApplicationPolicy
  def admin_list?
    user.admin?
  end
end

authorize 會回傳傳入的參數,所以可以在 authorize 的時候一邊指定要儲存到哪個變數。

# in controller
def show
  @user = authorize User.find(params[:id])
end

policy method 可以讓你取得 policy 物件

policy(@post)

等於

PostPolicy.new(current_user, @post)

Pundit 不只對 model 可以加 policy,也可以對 symbol 加 policy。

# in policy
class DashboardPolicy < Struct.new(:user, :dashboard)
  # ...
end

#in controller
authorize :dashboard, :show?

# In views
<% if policy(:dashboard).show? %>

Pundit 對於 Scope 也有處理:

class PostPolicy < ApplicationPolicy
  class Scope
    attr_reader :user, :scope

    def initialize(user, scope)
      @user  = user
      @scope = scope
    end

    def resolve
      if user.admin?
        scope.all
      else
        scope.where(published: true)
      end
    end
  end

  def update?
    user.admin? or not post.published?
  end
end

可以簡寫為

class PostPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      if user.admin?
        scope.all
      else
        scope.where(published: true)
      end
    end
  end

  def update?
    user.admin? or not post.published?
  end
end

當你定義好 scope 之後,可以這樣去使用它:

def index
  @posts = policy_scope(Post)
end

policy_scope 傳入的參數會是一個可以下 query 的物件,然後會執行相當於以下程式:

def index
  @posts = PostPolicy::Scope.new(current_user, Post).resolve
end

如果你不想忘記寫 authorize,那你可以在 ApplicationController 加入下面的程式:

after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index

這樣的話就會在 controller action 中沒有呼叫 authorize 或 policyscope 的時候跳出錯誤。 如果真的不需要驗證,那你可以在 action 中寫入 skipauthorization。

def show
  record = Record.find_by(attribute: "value")
  if record.present?
    authorize record
  else
    skip_authorization
  end
end

你可以指定 Model 對應的 Policy 名稱

class Post
  def self.policy_class
    PostablePolicy
  end
end

pundit 也有提供 generator

# in bash:
rails g pundit:policy post

你可以做一個 ApplicationPolicy ,讓他被繼承到每一個 Policy, 使得所有的 Policy 都會去檢查是否使用者有登入

class ApplicationPolicy
  def initialize(user, record)
    raise Pundit::NotAuthorizedError, "must be logged in" unless user
    @user   = user
    @record = record
  end
end

然後你就可以在 ApplicationController 用 rescue_from 去接他。

class ApplicationController < ActionController::Base
  protect_from_forgery
  include Pundit

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized
    flash[:alert] = "You are not authorized to perform this action."
    redirect_to(request.referrer || root_path)
  end
end

或者在你的 config/application.rb 去接他 config.actiondispatch.rescueresponses["Pundit::NotAuthorizedError"] = :forbidden

pundit 也可以用來處理 params

# in policy
class PostPolicy < ApplicationPolicy
  def permitted_attributes
    if user.admin? || user.owner_of?(post)
      [:title, :body, :tag_list]
    else
      [:tag_list]
    end
  end
end

# in controller
def update
  @post = Post.find(params[:id])
  if @post.update_attributes(permitted_attributes(@post))
    redirect_to @post
  else
    render :edit
  end
end

你可以針對每個不同的 action 提供不同的 permitted_attributes

class PostPolicy < ApplicationPolicy
  def permitted_attributes_for_create
    [:title, :body]
  end

  def permitted_attributes_for_edit
    [:body]
  end
end

2017/11/21

mac - 關閉 chrome 的兩指滑動換頁功能

在 mac 上的 chrome 有一個功能是使用兩指向右滑動可以回到上一頁,可是幹,我只是想看左邊的東西欸,所以我找到怎麼把這個功能關閉的方法。

在 bash 下輸入以下指令:

defaults write com.google.Chrome AppleEnableMouseSwipeNavigateWithScrolls -bool false
defaults write com.google.Chrome AppleEnableSwipeNavigateWithScrolls -bool false

然後重開 chrome

2017/11/8

rails - encode & decode

HTML encode

require 'cgi'
CGI.escapeHTML('<')
# "&lt;"

HTML decode

require 'cgi'
CGI.unescapeHTML('&lt;')
# "<"

mac - 移除所有 ANSI escape code

當你用 rspec 跑測試並且生成結果檔案時,檔案的內容可能會包含 ANSI escape code 所表示的顏色。

舉例來說,如果你用以下程式跑測試:

rspec --format documentation --out result.txt

你可以使用 cat 去讀他

cat result.txt

結果看起來像這樣:

但是打開 result.txt 時,你會看到這樣的東西:

...
Finished in 1.76 seconds (files took 6.55 seconds to load)
 [32m1 example, 0 failures [0m

Randomized with seed 1

[32m [0m 代表的就是顏色,這被稱為 ANSI escape code

但是我希望讓他消失,在請教大大之後得到一個不錯的解法,有人用 node.js 做了一個簡單的指令strip-ansi

安裝方法(如果你有node.js)

# 這裡是 bash
npm install --global strip-ansi-cli

使用方法

# 基本用法
strip-ansi 字串

# 透過 |
cat 輸入檔案路徑 | strip-ansi > 輸出檔案路徑

2017/11/6

Ruby - warning: toplevel constant B referenced by A::B

問題

warning: toplevel constant B referenced by A::B

成因

當 A 是一個 class 且 A::B 還沒有被定義時,ruby 找不到 A::B 時,若 B 有定義,就先使用 B, 而不是拋出 Module#const_missing。

class A
end

A::String
#warning: toplevel constant String referenced by A::String

但 rails 的 autoload 是透過修改 Module#const_missing 而完成的。也就是說,rails 還來不及 autoload 就已經被 toplevel constant 攔截了。

解法一

在使用到 A::B 的檔案前面都加 require_dependency 'a/b'

解法二

在使用到 B 的檔案後面加 require_dependency 'a/b' 確保 rails 不可能會有知道 B 的存在但不知道 A::B 的存在的可能發生。

參考文件 http://stem.ps/rails/2015/01/25/ruby-gotcha-toplevel-constant-referenced-by.html https://stackoverflow.com/questions/18515100/warning-toplevel-constant-referenced