20200707のRailsに関する記事は10件です。

Railsアプリケーションに、コメント機能を実装する

Railsアプリケーションに、いいね機能を実装する - Qiitaに続きまして、
コメント機能の実装について触れていきたいと思います。
Railsでコメント機能をつくってみよう - Qiitaの記事を参考にさせていただきました。
ER図は下記のようになります。(この図の書き方で合ってるのだろうか...)
image.png

コメント機能の追加

コメントモデルを作成します。

$ rails g model comment content:string user_id:integer article_id:integer
$ rails db:migrate

Commentモデルを作成したら、アソシエーションを定義します。

user.rb
  has_many :comments, dependent: :destroy #追加
article.rb
  has_many :comments, dependent: :destroy #追加

comment.rb側にもアソシエーションを設定したいところですが、今回はコメントをしたユーザや、コメントがついている投稿は表示させないので、belongs_toは記述しません。(モデル間の関係が分かりやすくなるので、書くことを推奨します。)

commentsコントローラーを作成

$ rails g controller comments

ルーティングを先に設定していきます。

routes.rb
Rails.application.routes.draw do
  root "home#top"
  devise_for :users
  resources :articles do
    resource :favorites, only: [:create, :destroy]
    resources :comments, only: [:create]
  end
  resources :comments, only: [:destroy]

  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

resources :comments, only: [:destroy]のみネストの外に出していますが、後々解説します。

commentsコントローラー記述

comments.controller.rb
class CommentsController < ApplicationController
  def create
    @comment = Comment.new(comment_params)
    @comment.user_id = current_user.id
    if @comment.save
      redirect_back(fallback_location: root_path)
    else
      redirect_back(fallback_location: root_path)
    end
  end

  def destroy
    Comment.find(params[:id]).destroy
    redirect_back(fallback_location: root_path)
  end

  private

  def comment_params
    params.require(:comment).permit(:content, :article_id)
  end
end

commentは、contentと、user_idと、article_idの3つが揃うことによってsaveできます。
@comment.user_idには、ログインしているユーザー、つまりcurrent_user.idを入れます。
後に投稿詳細画面でコメントを作成するので、新規入力したcontentと、artile_idを取ってこれます。
よって、コメントをsaveできます。

articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.all
  end

  def show
    @article = Article.find(params[:id])
    @comments = @article.comments
    @comment = Comment.new
  end

  def new
    @article = Article.new
  end

  def create
    @article = Article.new(article_params)
    @article.user_id = current_user.id
    if @article.save
      redirect_to article_path(@article), notice: "投稿に成功しました"
    else
      render :new
    end
  end


  private

  def article_params
    params.require(:article).permit(:title, :description)
  end
end

@comment = Comment.new、は、投稿の詳細(show)内においてコメント投稿を行いたいので、showアクションにコメントを新規作成するフォームを作成します。
@comments = @article.commentsは、投稿に紐づくコメントを全て取ってきています。それを投稿の詳細(show)内において表示させます。

articles/show.html.erb
<h2>投稿詳細</h2>
<p>タイトル:<%= @article.title %></p>
<p>内容:<%= @article.description %></p>

<% if @article.user == current_user %>
  <%= link_to "編集", edit_article_path(@article) %>
  <%= link_to "削除", article_path(@article), method: :delete %>

<% end %>

  <%= form_for [@article, @comment] do |f| %>
  <%= f.text_field :content %>
   <%= f.hidden_field :article_id, value: @article.id %>
  <br>
  <%= f.submit "コメントする" %>
  <% end %>

<% @comments.each do |comment| %>
  <%= comment.content %>
  <% if comment.user_id == current_user.id %>
   <%= link_to "削除", comment_path(comment.id), method: :delete %>
  <% end %>

<% end %>
コメント:<%= @comments.count %>
articles/show.html.erb
  <%= form_for [@article, @comment] do |f| %>
  <%= f.text_field :content %>
   <%= f.hidden_field :article_id, value: @article.id %>
  <br>
  <%= f.submit "コメントする" %>
  <% end %>

<%= form_for [@article, @comment] do |f| %>は、
今回コメントをするためには、どの記事に対するコメントなのかをパスから判断する必要があります。なので

  resources :recipes do
    resources :comments, only: [:create]
  end

という形にルーティングをネストする必要があります。
上記のように書くと、実際のパスはどうなるかというと、
image.png
/recipes/記事のid/commentsになります。(※recipeは、articleに読み替えてください)
ネストしたルーティングの場合は、今回の場合 引数を2つ渡してあげる 必要があります。

コメントの保存には、先ほども書きました content,user_id,article_idの3つが必要です。
user_idはコントローラー側で current_user.id として取得できているので、残り2つです。
<%= f.text_field :content %>で、コメントをフォームから入力します。

<%= f.hidden_field :article_id, value: @article.id %>で、投稿のidをフォームに渡しています。

これでコメント投稿ができるようになります。

次は、コメント削除についてです。

image.png
コメントの削除が少し難しいのですが、冒頭の方で

routes.rb
 resources :articles do
    resource :favorites, only: [:create, :destroy]
    resources :comments, only: [:create]
  end
  resources :comments, only: [:destroy]

destroyのルーティングだけネストの外に出していました。
image.png

コメントのidを取得できたらコメントの削除が可能です。
「削除」ボタンにカーソルを合わせると、画面左下に
image.png
と出てきます。comments/id の、idの部分がコメントidに当たりますので、これをparams[:id]で渡してあげれば削除できます。
Railsコメント削除機能の実装でハマってしまったので一応解決策を。 - Qiitaの記事を参考にさせていただきました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】複数のDB処理をまとめる transaction を実装してみた

はじめに

Rails でアプリを開発していて、複数テーブルのデータを同時に作成する、という処理を実装していた。
その処理において、どちらかの処理が失敗した場合に双方の処理をなかったことにする transaction という概念があることを知ったので備忘録兼アウトプットとして、 transaction についておよび、実装した内容について書く。

transaction とは

「transaction」という言葉の定義を調べると
【処理、取り扱い、処置、業務、取引、売買、会報、紀要、議事録】 このように出てくる。

ITやプログラミングにおいては
「複数の処理を一つにまとめた分割不可能なもの」

と定義づけることが出来そうです。

こちらの記事がトランザクションの概念について非常にわかりやすいくまとめられており、参考にさせていただきました。

「トランザクション」とは何か?を超わかりやすく語ってみた!

transaction メソッド使い方

  • 複数の処理を一つのまとまった処理として扱う
  • 一つでも処理に失敗した場合は、トランザクション全体として失敗となる
  • 失敗した場合は、全ての処理がなかったこととなる

基本的な構文としてはこうなる

モデル.transaction do
  # テーブルへのアクセス処理
  # テーブルへのアクセス処理
end
  # トランザクション処理が成功した場合の処理
rescue => e
  # トランザクション処理が失敗した場合の処理

注意点としては「処理失敗時には例外を発生させるメソッドを使用する」こと。

トランザクションは、含まれる処理のいずれか1つでも失敗すると、トランザクション内のすべての処理をなかったことにするが、そのなかったことにする条件が「例外の発生」となる。

では実際に実装してみる。

実装

今回開発していたアプリにはユーザーのグループ化機能があった。
そこで、グループを新規作成するときにグループ作成をしたユーザーは自動的にグループに所属するユーザーとなる、という仕様を想定。
そこでグループ作成をした際に、思わぬ動作が起きてしまうことを避けるためにトランザクションの利用を検討。

テーブル構造はこのようになっている。
スクリーンショット 2020-07-07 19.57.10.png

groupsテーブルインスタンスを作成すると同時に、ログインユーザーのidをuser_idカラムとする group_usersテーブル(usersテーブルとgroupsテーブルの中間テーブル)が作成されるようにしたかった。

  1. groupsテーブルインスタンスの作成
  2. group_usersテーブルインスタンスの作成

この 1, 2 のうちどちらかの処理が何らかの理由により失敗した場合、双方の処理がなかったこととなるように groups コントローラの create アクションで transaction を活用する。

groups_controller.rb
# createアクション部分を抜粋
# 変数current_userにはログインユーザーのインスタンスが格納

  def create
    @group = Group.new(group_params)

    # トランザクションを適用(グループの作成と中間テーブルを同時作成)
    # save! と create! と「!」がついている点に注意!
    @group.transaction do
      @group.save!
      current_user.group_users.create!(group_id: @group.id, permission: true)
    end
    # トランザクション成功時の処理
      flash[:success] = '新しいグループを作成しました'
      redirect_to @group
    rescue => e
    # トランザクション失敗時の処理
      flash.now[:danger] = 'グループ作成に失敗しました'
      render :new
  end

この transaction 内での処理には両方とも「!」がついており、テーブルデータ作成失敗時には例外が発生するメソッドを使っていることに注意!

仮に transaction を利用せずに以下ように実装した場合は、何らかの理由で group_usersテーブルの作成が失敗した場合に無人のグループが作成されてしまう懸念がある。

groups_controller.rb
# transactionを利用しない場合

def create
  @group = Group.new(group_params)

  if @group.save
    current_user.group_users.create(group_id: @group.id, permission: true)
    flash[:success] = '新しいグループを作成しました'
    redirect_to @group
  else
    flash.now[:danger] = 'グループ作成に失敗しました'
    render :new: 
  end
end

最後に

記事を読んでいただきありがとうございます!
今回調べながら何とか実装してみたのですが、正直トランザクションについての理解はそこまで深くないです。誤りやより良い記述方法などがあれば、気軽にコメント等いただければ幸いです。
下記の記事、参考にさせていただきました。ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RailsとBootstrapでサイドバーにVerticalな展開式のnavを作る方法

Bootstrapでサイドバーにカテゴリー一覧を出し、親要素と子要素をネストさせたい?
スクリーンショット 2020-07-07 18.15.10.png

展開(collapse)はBootstrapでは#sidemenu1のようなIDをターゲットにするのが決まりらしい。

        <div class="col-md-3 col-xl-2 bd-sidebar">
            <nav class="navbar navbar-expand-lg navbar-light bg-light">
                <a class="navbar-brand">カテゴリー</a>
                <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
                    <span class="navbar-toggler-icon"></span>
                </button>

                <div class="collapse navbar-collapse" id="navbarNavDropdown">
                    <ul class="navbar-nav flex-column">             
                        <% @categories.each do |category| %>
                            <li class="nav-item">
                                <a class="nav-link collapsed text-truncate" href="/categories/<%= category.id %>" data-toggle="collapse" data-target="#submenu<%= category.id %>"><i class="fa"></i><span class="d-none d-sm-inline"><%= category.name %></span></a>
                                <div class="collapse" id="submenu<%= category.id %>">
                                    <ul class="flex-column pl-3 nav">
                                        <% category.children.each do |child| %>
                                            <li class="nav-item"><a class="nav-link" href="/categories/<%= child.id %>"><span><%= child.name %></span></a></li>
                                        <% end %>
                                    </ul>
                                </div>
                            </li>
                        <% end %>                   
                    </ul>
                </div>
            </nav>
        </div>

最初から展開したい場合

親カテゴリのa要素?にaria-expanded="true"属性?を付与
トグルターゲットであるdiv要素にclass="collapse show"を付与

                                <a class="nav-link text-truncate" href="/categories/<%= category.id %>" data-toggle="collapse" data-target="#submenu<%= category.id %>" aria-expanded="false"><i class="fa"></i><span class="d-none d-sm-inline"><%= category.name %></span></a>
                                <div class="collapse show" id="submenu<%= category.id %>">
                                    <ul class="flex-column pl-3 nav">
                                        <% category.children.each do |child| %>
                                            <li class="nav-item"><a class="nav-link" href="/categories/<%= child.id %>"><span><%= child.name %></span></a></li>
                                        <% end %>
                                    </ul>
                                </div>

基本は公式ドキュメントの nav > vertical をもとにしている。
https://getbootstrap.com/docs/4.5/components/navs/#vertical

ナビに子要素をもたせて展開させるにはこちらを参考にした。孫まであるケースなので、おそらく親と子だけしか使わなかった今回は不要なクラスや属性を付与しちゃっているかも。

ただしカテゴリー親要素と子要素の取得は、カテゴリーを多階層化できるGemであるAncestryによる記法で取得している。

post_controller.rb
    def index
        @categories = Category.where(id: Category.pluck(:ancestry).compact.map { |e| e.split('/') }.flatten.uniq)
    end
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

railsでrspecを使用中にハマったこと

初心者がrailsでrspecを用いてテストを記述する際にハマったことを残しておきます。

rspecでcontrollerのテストを試みたところエラーが頻発しました。どこを直してもうまくいかなかったので、一度参考サイト通りに記述し、何が問題かを調査することにしました。しかし、それでもうまくいかない。。。
そこで設定を見直したところ、controllerのテストはrails5から非推奨になっており、以下のように変更する必要がありました。

model       → model(そのまま)
controller     → request
画面遷移のテスト → system

のようにフォルダを作成する必要があり、rails_helperの設定も上記にならい記述する必要があった。
参考記事はcontrollerとrequestが混在しているため注意する必要がある。

また、最近のrailsではコントローラーのテストは非推奨になり、
create,destroy,edit,updateのテストが記述してあれば十分になったらしい。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

railsで開発中にAWSから高額請求されたが、、

はじめに以下の記事にあるように、railsでアプリを開発中に初期設定のミスが原因で高額請求に至った。
https://qiita.com/381704/items/d5216295eadd67eb9e8c

完全に注意不足であり、しっかり利用してしまったあとなので支払うしかないと思っていたが、駄目元でAWSのサポートセンターに以下のようなメールを送ってみた。(本文を載せるのは抵抗があったが、証拠&同じ状況の人の希望になればと思い残しておく)
スクリーンショット 2020-07-07 16.17.45.png

2日後にAWSサポートより返信が来た。返信内容には、現在料金が発生しているRDSのインスタンスとスナップショットの削除をすることとあり、その後に返金が可能かの相談にはいるということだ。
内容通り、RDSの削除をして再度AWSに削除完了のメールを送信した。
その2日後AWSサポートより返答があり、今回に限り返金措置を設けてくださるとのこと!!!

請求料金は合計600$以上になったが、AWS様より返金をしてくださり、最終的な請求料金は100$ほどになった。落ち度は完全に私のほうにあるにも関わらず、今回の対応をしてくださったことは本当に感謝してもしきれないほどだ。

離職中だったので金銭的に苦しい状況だったため本当に助かりました。ありがとうございました。
反省として請求アラームを2個付け、UdemyでAWSに関する教材を購入し基礎からAWSの利用法を学び直しました。
これからもサービスを利用させていただきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails5でECサイトを作る④ ~ヘッダーとフッター~

はじめに

架空のベーカリーで買い物できるECサイトを作るシリーズ、Rails5でECサイトを作る③の続きです。
今回は、ヘッダーとフッターを作ります。せっかくなのでレスポンシブ対応にしてみました。

ソースコード

https://github.com/Sn16799/bakeryFUMIZUKI

配色

いきなり余談ですが、サイト全体のテーマカラーを決めました。店名(架空)が文月なので、夏らしい青系統の色でまとめます。
カラーコードも一応載せておきます。ご参考までに。

濃青……#120136、rgb(18,1,54)
中青……#035aa6、rgb(3,90,166)
淡青……#40bad5、grb(64,186,213)
ワンポイント黄……#fcbf1e、rgb(252,191,30)

ヘッダー

app/views/layouts内に、ヘッダー用の部分テンプレートを作ります。ファイル名は、_header.html.erbとしました。

管理者でログインした時顧客がログインした時ログインしていない時の3パターンで表示が変わるように条件分岐します。
顧客ログイン時に「ようこそ、〇〇さん!」とメッセージを表示したいのですが、データベース上は苗字と名前を別々で保存しているため、viewのコードが煩雑になってしまいます。そのため、Modelのファイルにちょい足しして、苗字と名前を連結した文字列を出力できるようにします。

app/model/customer.rb
def full_name
  self.family_name + " " + self.first_name
end

これでviewやcontrollerでfull_nameと入力すれば、フルネームで表示できるようになりました!

app/views/layouts/_header.html.erb
<header class="container-fluid middle-blue-back">
  <nav class="navbar navbar-expand-lg">
    <!-- ロゴ画像。あとで差し替え。 -->
    <div>
      <%= link_to customer_top_path, style: 'color: #fcbf1e' do %>
      <h1>LOGO</h1>
      <% end %>
    </div>

    <!-- 画面幅が狭くなった時のみ出現するトグルボタン -->
    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarFumizuki" aria-controls="navbarFumizuki" aria-expanded="false" aria-label="Toggle navigation">
    <span class="navbar-toggler-icon"></span>
    </button>

    <!-- 各種リンク。管理者ログイン時と顧客ログイン時、非ログイン時で表示切替。 -->
    <div class="collapse navbar-collapse" id="navbarFumizuki">
      <!-- 管理者 -->
      <ul class="navbar-nav mr-auto w-75 nav-justified">
        <% if admin_signed_in? %>
          <div class="admin-message">管理者としてログインしています</div>
          <li class="nav-item"><%= link_to '商品一覧', admins_products_path, class: 'nav-link' %></li>
          <li class="nav-item"><%= link_to '会員一覧', admins_customers_path, class: 'nav-link' %></li>
          <li class="nav-item"><%= link_to '注文履歴一覧', admins_orders_path, class: 'nav-link' %></li>
          <li class="nav-item"><%= link_to 'ジャンル管理', admins_genres_path, class: 'nav-link' %></li>
          <li class="nav-item"><%= link_to 'ログアウト', destroy_admin_session_path, method: :delete, class: 'nav-link' %></li>
        <!-- 顧客 -->
        <% elsif customer_signed_in? %>
          <li class="nav-item"><%= link_to 'About', customer_about_path, class: 'nav-link' %></li>
          <li class="nav-item"><%= link_to 'マイページ', customer_path(current_customer.id), class: 'nav-link' %></li>
          <li class="nav-item"><%= link_to '商品一覧', products_path, class: 'nav-link' %></li>
          <li class="nav-item"><%= link_to 'カート', cart_items_path, class: 'nav-link' %></li>
          <li class="nav-item"><%= link_to 'ログアウト', destroy_customer_session_path, method: :delete, class: 'nav-link' %></li>
        <!-- 非ログイン -->
        <% else %>
          <li class="nav-item"><%= link_to 'About', customer_about_path, class: 'nav-link' %></li>
          <li class="nav-item"><%= link_to '商品一覧', products_path, class: 'nav-link' %></li>
          <li class="nav-item" ><%= link_to '新規登録', new_customer_registration_path, class: 'nav-link' %></li>
          <li class="nav-item"><%= link_to 'ログイン', new_customer_session_path, class: 'nav-link' %></li>
        <% end %>
      </ul>
      <!-- メッセージ(customer) or 検索窓(admin) -->
      <% if customer_signed_in? %>
        <p>ようこそ<%= current_customer.full_name %>さん!</p>
      <% else admin_signed_in? %>
        <!-- 検索フォームは仮。あとで機能するものを作って差し替え。 -->
        <form class="form-inline my-2 my-lg-0">
        <input type="search" class="form-control mr-sm-2" placeholder="検索..." aria-label="検索...">
        <button type="submit" class="btn btn-outline-success my-2 my-sm-0">検索 
        </button>
        </form>
      <% end %>
    </div>
  </nav>
</header>

フッター

特に機能をつけなかったのであってもなくても良いのですが、ないと寂しいので作ります。

app/views/layouts/_footer.html.erb
<div class="container-fluid middle-blue-back">
  <h3 class="footer-message">Bakery FUMIZUKI</h3>
</div>

青色の背景と、中心に黄色で「Bakery FUMIZUKI」の文字というシンプルな構成です。

おおまかな枠組みを作る

application.html.erbを編集して、サイト全体のレイアウトを調整します。
先ほど作ったヘッダーとフッターもここで読み込みます。

app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title>Fumizuki</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>

    <!-- レスポンシブ -->
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- swiper -->
    <link rel="stylesheet" href="https://unpkg.com/swiper/css/swiper.min.css">
    <script src="https://unpkg.com/swiper/js/swiper.min.js"></script>

    <!-- font awsome -->
    <link href="https://use.fontawesome.com/releases/v5.6.1/css/all.css" rel="stylesheet">
  </head>

  <body>
    <%= render 'layouts/header' %>
    <div class="container-fluid">
      <div class="row">
        <%= yield %>
      </div>
    </div>
    <%= render 'layouts/footer' %>
  </body>
</html>

真ん中あたりにあるswiperというのは、写真をスライド表示するためのライブラリです。今回の記事では使いませんが、便利なのでいずれお世話になるだろうと思って入れました。
その下のfont-awsomeは、リンクをコピーするだけで数多くのアイコンを無料で使える便利なサイトです。こちらもそのうち使うと思います。

scssで装飾

HTML側で記述したクラス名に、各種の色を設定していきます。

app/assets/stylesheets/application.scss
@import 'bootstrap';

.middle-blue-back {
  background-color: #035aa6
}

.light_blue_letter {
 color: #40bad5
}

.footer-message {
  line-height: 100px;
  text-align: center;
  color: #fcbf1e
}

// header
.navbar-toggler {
  border-color: #40bad5;
}
.navbar-toggler .navbar-toggler-icon {
  background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgb(64,186,213)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 8h24M4 16h24M4 24h24'/%3E%3C/svg%3E");
}

.nav-link {
  color: #40bad5;
}

トップページも細工する

テーマカラーに決めた色見本がブラウザでどのように見えるのか確認したかったので、トップページで試してみました。後で正式なトップページを書くときに消します。

app/views/homes/top.html.erb
<div class="col">
  <h1>Homes#top</h1>
  <div class="row">
    <div class="col-3" style="height: 100px; background-color: #120136"></div>
    <div class="col-3" style="height: 100px; background-color: #035aa6"></div>
    <div class="col-3" style="height: 100px; background-color: #40bad5"></div>
    <div class="col-3" style="height: 100px; background-color: #fcbf1e"></div>
  </div>
</div>

localhost:3000にアクセスしてみます。
fumizuki_top.jpg

ヘッダー、フッターとも問題なく表示されています。

toggle.jpg

開発者ツールで画面幅を狭くすると、きちんとハンバーガーアイコンになりました!

後記

Bootstrapを使うとレスポンシブ対応も楽々できて、本当に便利な機能だなと思います。CSSも多少書いていますが、ほぼ色の指定をしているだけです。そのカラーコードも4色だけなので、アプリ全部を完成させても、CSSのコード量はほとんど増えないのではないでしょうか。

あと、ここまでお読みくださった方は薄々あることを思っておられるかも知れません。私もちょっと気になっています。
サイトのテーマカラー、どこかで見たことあるような。

はい。完全にI●EAですね。
不覚にもサイトで表示するまで気付かなかったため、コードを書いてからビックリする事態となってしまいました。私はIK●A好きだから良いですけど、向こうは別に私のこと好きでも何でもないと思うので、このまま実装を続けたものか迷います。
一旦IKE●に見えてしまったら、サイトを見る度に「絶対この店、パン売ってないよな」と思ってしまうのも難点です。

果たして私は●KEAから怒られずにサイトを完成させられるのか? 次回へ続く!

参考

Bootstrap4移行ガイド
Bootstrap4日本語リファレンス
とほほのBootstrap 4入門
Bootstrap4のハンバーガーメニューの色を変える方法

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

URLエンコードについて調べてみた

WEB上のPDFファイルが見れない

なぜかアップロードしたPDFファイルがリンク切れになっていたので
その調査内容を記録します。

ユーザ環境

  • MacBook
  • Chrome

アプリケーション環境

  • AWS EC2
  • AWS S3
  • ruby 2.6.5
  • Rails 5.0.7.2
    • refile (0.6.2)
    • refile-s3 (0.2.0)

見れないファイルのパターン

チルダ「~」がファイル名に含まれているファイルはリンク切れになってしまう。

ファイルリンクを作成するコード

li
  = link_to attachment_url(file, :file), target: '_balnk'
    b-icon(icon="attachment")
    span.file__name = file.file_filename

実際に生成されるhtml

<a target="_balnk" href="/attachments/xxxxxxx/store/xxxxxx/2020.3%7E2020.4%E3%83%86%E3%82%B9%E3%83%88.pdf">
  <span class="file__name">2020.3~2020.4テスト.pdf</span>
</a>

日本語もちゃんとURLエンコードされているし、問題なさそう。

URL規約を調べてみた

URLで使用できる文字は規約で定められていて
いくつかの規約がありましたが、以下2つについてしらべてみました。

  • RFC3986
  • RFC1738

RFC1738

https://tools.ietf.org/html/rfc1738

Unsafe:
Characters can be unsafe for a number of reasons. The space
character is unsafe because significant spaces may disappear and
insignificant spaces may be introduced when URLs are transcribed or
typeset or subjected to the treatment of word-processing programs.
The characters "<" and ">" are unsafe because they are used as the
delimiters around URLs in free text; the quote mark (""") is used to
delimit URLs in some systems. The character "#" is unsafe and should
always be encoded because it is used in World Wide Web and in other
systems to delimit a URL from a fragment/anchor identifier that might
follow it. The character "%" is unsafe because it is used for
encodings of other characters. Other characters are unsafe because
gateways and other transport agents are known to sometimes modify
such characters. These characters are "{", "}", "|", "\", "^", "~",
"[", "]", and "`".
All unsafe characters must always be encoded within a URL

チルダは使用するなら必ずエンコードして使わないといけないと明記されていました。

RFC3986

https://tools.ietf.org/html/rfc3986

2.3. Unreserved Characters
Characters that are allowed in a URI but do not have a reserved
purpose are called unreserved. These include uppercase and lowercase
letters, decimal digits, hyphen, period, underscore, and tilde.
unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"

チルダの扱いが「非予約文字」になっていてエンコードなしで使えそうです。

Amazon CloudFrontはどの規約に準拠しているのか?

URL規約を調べたところで
WEBアプリケーションの動作しているAWS環境を調べてみました。

ユーザからRequestをうけるAmazonCloudFrontのドキュメントを見てたら
https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html

パスに ASCII 以外の文字が含まれるか、RFC 1783 に規定された安全でない文字が含まれる場合、その文字を URL エンコードします

という記述がありました。

なんで見れなかったのか?

上記、調査内容からするとチルダのエンコードで何かしら問題あったということになりますが、、
Rails link_to で作成されたhtmlリンクは正しくエンコードされている。。

<a target="_balnk" href="/attachments/xxxxxxx/store/xxxxxx/2020.3%7E2020.4%E3%83%86%E3%82%B9%E3%83%88.pdf">
  <span class="file__name">2020.3~2020.4テスト.pdf</span>
</a>

ブラウザの問題?

Chrome と Safariで試したところ、SafariからはPDFファイルが見れた!!

Chrome アドレスバーの表示

https://xxxxxx/2020.3~2020.4テスト.pdf

Safari アドレスバーの表示

https://xxxxxx/2020.3%7E2020.4テスト.pdf

エンコードされたリンクを開く際、ブラウザで日本語にデコードしてくれるようだが、
そのデコードによる問題かな。。と推測。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

いちばんやさしいスパムの殺しかた【スパム対策:スパムの登録防止から削除方法まで】

概要

この記事は、『運営しているWebサービスにスパムが登録してきたときの対処法』について書いたものです。

この記事は、自分が開発している英単語学習サービス「BooQs」に、スパムが大量に登録してきた実体験をもとに書いた備忘録です。

(利用技術:Rails5.1)

前提:スパムを放置することで発生する不利益

反省点として、
正直なところ、自分はスパムへの対応に遅れました。
スパム登録に気付いてからもしばらくは、何も対応を取りませんでした。

その理由は2つあります。
1つは、多忙によりスパムへの対応よりも優先すべきと思われるタスクが山ほどあったこと。
そしてもう一つは、自分自身、スパムに登録される不利益について知らなかったため、スパム対策の優先順位がなかなか上がってこなかったためです。

この反省を踏まえて、まずスパムによって引き起こされる不利益を、運営者の視点とユーザーの視点から紹介します。

これを読めば、すぐにスパムに対応したくなるはず。

【運営者の不利益】:重要な指標の精度が下がる

リーンスタートアップを読まれた方には馴染みがあるかと思いますが、
サービスが示す数字には、「虚栄の指標」と「行動につながる指標」の2つがあります。
虚栄の指標とは、サービス運営者の虚栄心を満たすだけの意味のない数字で、
行動につながる指標とは、実際にサービスがユーザーに求められていることを示すサービスの成長につながる数字です。
虚栄の指標は、PVやユーザー登録数がよく挙げられ、行動につながる指標には、定着率やアクティブユーザー率などがよく挙げられます。

もちろん、これらの指標はプロダクトの性質によって変わってきますが、少なくともBooQsにとっては、ユーザー登録数は「虚栄の指標」でした。

それもさらに悪いことに、スパム登録は、虚栄の指標を増やすだけでなく、全体に対するアクティブユーザー率のような「行動につながる指標」の精度を下げるものでした。

スパムによって見かけ上のユーザー登録数が増えるのは、案外、運営者にとっては悪くない心地です。
しかしそれによって本当に意味のある指標の計測が阻まれるなら、自己満足は排除するのが正しい選択です。

つまり、スパム滅ぶべし。

【運営者の不利益2】:すごい速さで増えていき、時間が経つほど削除が大変になる。

スパムは放置していると、すごい速さで増えていきます。
それもスパムは、登録してくるメールアドレスのバリエーションも多様です。
このあと「スパムの削除法:STEP3」で見ていくように、スパムの削除では、コンソールからメールアドレスのドメインでUserに検索をかけていくことになるので、スパムのメールアドレスのバリエーションが増えれば増えるほど、削除も大変になってきます。
僕はもたもたしていた結果、770人もスパムの登録を許してしまいました。
削除、大変だったなぁ...。

つまり、スパム滅ぶべし。

【運営者の不利益3】:スパムではないユーザーまで削除してしまう危険が増える

このあと「スパムを削除する方法:STEP2」で紹介するように、スパムを一括で削除するには、登録期間で登録ユーザーを検索することになります。

そのため、スパムの放置が長引けば長引くほど、スパムではないユーザーまで削除してしまう危険が増えます。

スパムを確認したら、理想はその日のうちに、できればその週のうちに、遅くてもその月のうちには対処するようにしましょう。

というかむしろ、現時点でスパムに襲われていなくても、現在運営しているサービスに次に紹介するreCAPTCHAを導入してもいいくらいだと僕は思いました。

つまり、スパム滅ぶべし。

【利用者の不利益】:本来のメアドの持ち主がサービスに登録できなくなる

BooQsを含めたほとんどのWebサービスは、重複したメールアドレスでユーザー登録できないようにバリデーションをかけています。
つまり、スパムによって勝手にメールアドレスを登録されたユーザーは、そのサービスに登録できないということです。
このことに気づいたのは、下のようにgravaterでアイコンが設定されたスパムを見かけた時でした。

スクリーンショット 2020-07-02 14.57.02.png

スパムユーザーにgravaterアイコンがあるということは、そのメールアドレスが誰か実在の人物が利用しているものだということです。
スパムによるなりすましを防ぐためにも、スパムを見つけたらすぐに対応しましょう。

つまり、スパム滅ぶべし。

(ほかにも自分が思い至らなかった不利益があれば、ぜひ教えていただけると幸いです?‍♂️)

1, スパムの登録を防ぐ方法

スパムを見つけたら、まず登録画面にreCATPCHを導入しましょう。
reCAPTCHとは、下の画像の「私はロボットではありません」というあのおなじみのフォームです。
きっと誰もが一度は見たことあるはず。

スクリーンショット 2020-07-02 15.38.49.png

reCAPTCHの導入は、Railsなら下の記事が詳しいです。
RailsでreCAPTCHA対応

スパムを見つけたら、reCAPTCHだけでもすぐに導入することをオススメします。

(reCAPTCHの他にも、スパム対策にipでbanするなども試しましたが、自分に襲いかかってきたスパムはなぜか全員ipアドレスが異なっていたため、防壁の役割を果たさなかったようでした。)

2, 登録済みスパムを削除する方法

ここからは、すでに登録されてしまったスパムを削除する方法について紹介します。

STEP1:非アクティブユーザーを絞り込む

スパムの弱点は、『登録しかしない』ところです。
プラットフォームやそのユーザーに利益をもたらしてくれる活動は、何一つしません。
彼らは、アクティブユーザーではないのです。
そのため、非アクティブユーザーを絞り込むことで、ユーザー全体からスパムユーザーを絞り込むことができます。
Railsなら、非アクティブユーザーは、ユーザーの行動によって生成される、Userモデルに関連づけられているレコードが存在すれば、簡単に導き出せます
以下はBooQsの例です。
(BooQsでは、ユーザーが問題を解くことでAnswerHistory(解答履歴)レコードが生成されます。)

非アクティブユーザーを絞り込む.rb
active_user_ids  = AnswerHistory.group(:user_id).pluck(:user_id)
non_active_users = User.where.not(id: active_user_ids)

non_active_users.count
=> 1747

登録後に一度も問題を解いたことのない非アクティブユーザーは、1747人でした。

この1741人からスパムを見つけ出して削除すれば良いということになります。

STEP2:登録期間で絞り込む

スパムが登録してきた期間がわかるなら、登録期間で非アクティブユーザーを絞り込むことで、スパムを絞り込むことができます。
このため、スパムの登録を確認したら、reCAPTCHの導入などの対応を迅速に行いましょう。
理想としてはその日のうちに、できるならその週のうちに、遅くてもその月のうちに、スパム登録の防止策を取るようにしましょう。
Railsを使っているなら、次のようにスパムかもしれないユーザーを絞り込めます。

非アクティブユーザーを期間で絞り込む.rb
#その日のうちにスパムに対処できた場合
daily_non_active_users = non_active_users.where(created_at: "2020-05-15".in_time_zone.all_day)
#その週のうちにスパムに対処できた場合
weekly_non_active_users = non_active_users.where(created_at: "2020-05-15".in_time_zone.all_week)
#その月のうちにスパムに対処できた場合
monthly_non_active_users = non_active_users.where(created_at: "2020-05-15".in_time_zone.all_month)
#その四半期のうちにスパムに対処できた場合
quarterly_non_active_users = non_active_users.where(created_at: "2020-05-15".in_time_zone.all_quarter)
#その年のうちにスパムに対処できた場合
yearly_non_active_users = non_active_users.where(created_at: "2020-05-15".in_time_zone.all_year)

参考:16 Timeの拡張

BooQsの場合、スパムへの対応が遅れたため、5月の初めから終わりまでがスパムの登録期間となりました。
結果として、"2020-05-15".in_time_zone.all_monthを使って検索をかけたところ、スパムの可能性のあるユーザーは、821人まで絞り込めました。

STEP3:ドメインで絞り込む

スパムユーザーの情報を観察していると、彼らが日本人には馴染みのないメールアドレスで登録してきていることがわかります。

@comcast.net@verizon.net@sbcglobal.net@bellsouth.net@yahoo.comなどの海外サービスのドメインを利用して登録しているのです。

そのため、特定の期間に登録してきた非アクティブユーザーを、これら海外ドメインで絞り込むことで、スパムを絞り込むこともできます。

ドメインで絞り込む.rb
spams  =  monthly_non_active_users.where(['email LIKE ?', "%comcast.net%"])

spams.count
=> 158

これで一応、スパムたちを絞り込み作業は終わりなのですが、一つ注意点もあります。
それは、ここまで絞り込んでもまだ、スパムではない一般ユーザーを削除する可能性を完全に排除できてはいないということです。
日本のユーザーの中にも、海外のドメインを利用している方もいるかもしれません。
実際、BooQsのスパムではなさそうなユーザーの中にも@yahoo.comを利用されている方が3人いらっしゃいました。
幸い、彼らの登録はスパム登録期間の外だったため削除には至りませんでしたが、ここには重要な事実が隠れています。
それは、このスパムではないユーザーまで削除してしまう危険性は、スパム登録への対応が遅れ、スパム登録期間が長くなるにつれて高くなるということです。
なので、reCAPTCHなどのスパムへの対応は、スパムの存在に気づいたらすぐに行いましょう。

 
さぁ、あとはこの絞り込んだスパムを削除するだけなのですが、Railsの場合、ちょっと気をつけるべき点もあります。
次に進みましょう。

STEP4:スパムはdestroy_allで削除する。

1ヶ月くらい放置していると、スパムは平気で数百人を超えてきます。
こうした大量のユーザーを削除しようとするとき、思いつくのは、spams.destroy_allspams.delete_allの2種類の方法です。
 
もしあなたが、User作成時に、Userと関連づけられたモデルのレコードも同時に自動で生成していないのなら、delete_allを使っても構わないでしょう。

しかし、もしそうでないなら、destroy_allを使うべきです。
 
なぜならdelete_all(とdelete)は、ActiveRecordを介さず直接SQLを実行して削除するメソッドなので、
関連づけられたレコードも一緒に削除してくれるdependent: :destroyが効かないからです。

自分は、このことを忘れてやらかしました。
BooQsでは、Userモデルには、「解答設定」を決めるAnswerSettingモデルをhas_oneで紐づけられています。
そしてユーザーが新規登録するときには、自動でデフォルトの設定をもつAnswerSettingsレコードを作成して、ユーザーに紐づけます。
しかし僕は、大量のスパムを削除するときに、(すぐに処理が終わるからという安直な理由で)delete_allを使ってしまったのです。
結果的にDBに残ったのは、親を失った大量のAnswerSettingたちでした。
 
スパムを削除するときはdestroy_allを使いましょう。

スパムはdestroy_allで削除する.rb
spams.destroy_all

結論

今回削除したスパムユーザーの数は、770人でした。
821人の月間非アクティブユーザーのほとんどを占めていたということになります。ひどい。

しかし、これでもスパムを全員削除できたとは限りません。
スパムユーザーと思しきものの中には、@gmail.com@icloud.com@hotmail.comなど日本でもよく利用されているドメインで登録されているものもありましたが、流石にそれらには怖くて手がつけられませんでした。

こうした事態を避けるためには、自分のようにスパムへの対応を送らせず、遅くてもスパムを確認したその週にはスパムの登録防止策を講じるべきです。

というか、なぜスパムさんはうちのような弱小サービスを狙ってきたのでしょうか?
何が目的なのでしょうか?
本当に疑問です。

ともあれ、スパムは滅ぶべきだと考える次第である。
 

(今回のスパム対策では、運営者ギルドの方々にとても助けられました。
それにつけても運営者ギルドは存続させるべきである。)

宣伝

スパムにも大人気の英単語学習サービス、BooQsを開発しています!
200万回以上も解かれた、BooQsの中でもとくに人気のあるコンテンツは、NGSL(New General Service List)と呼ばれる、一般的な英文の9割を網羅した英単語帳です。
ぜひご利用くださいませ!

 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

「bundle install」するとgem系のファイルについて大量にgit差分が発生してしまう

発生した背景

新しいgemを入れるときに、以下のコマンドを実行するとgitに大量に差分が発生することがあります。

コマンド
bundle install --path vender/bundle

(スクショの「Unstaged Files(1)」というところが、10000とかになったりする。ごめんなさい、再現ができなかったです)
image.png

この件について対応方法を検討したいと思います。

環境

項目 内容
OS.Catalina v10.15.4
Ruby v2.5.1
Ruby On Rails v5.2.4.3
Git 2.20.1

対応手順

【1】Gitignoreを編集する。
Gitignoreのフォーマットなるものがあります。
詳しくは参考文献「編集済みの.gitignoreを簡単に得る方法」を。
以下内容を掲載しておきます。

.gitignore
*.rbc
capybara-*.html
.rspec
/log
/tmp
/db/*.sqlite3
/db/*.sqlite3-journal
/public/system
/coverage/
/spec/tmp
**.orig
rerun.txt
pickle-email-*.html

# TODO Comment out this rule if you are OK with secrets being uploaded to the repo
config/initializers/secret_token.rb

# Only include if you have production secrets in this file, which is no longer a Rails default
# config/secrets.yml

# dotenv
# TODO Comment out this rule if environment variables can be committed
.env

## Environment normalization:
/.bundle
/vendor/bundle

# these should all be checked in to normalize the environment:
# Gemfile.lock, .ruby-version, .ruby-gemset

# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
.rvmrc

# if using bower-rails ignore default bower_components path bower.json files
/vendor/assets/bower_components
*.bowerrc
bower.json

# Ignore pow environment settings
.powenv

# Ignore Byebug command history file.
.byebug_history

特に「/vendor/bundle」が重要ですね。
gitにあげるとパフォーマンスも落ちますし、よくないことだらけなので私は、上記を書き換えるだけでなく、既存のファイルに追記という形をとりました。

参考文献

編集済みの.gitignoreを簡単に得る方法

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails メソッド

railsメソッド

human_attribute_nameで多言語対応できる
const_get
https://docs.ruby-lang.org/ja/latest/method/Module/i/const_get.html

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む