一人もくもく会 α verでサービス開始しました。

wkhtmltopdfでpdf作成に失敗する時

wkhtmltopdfで何度試してもエラーが出ていた。

アクセス権限もあっているし、ファイルも存在するし中身もおかしくないし…。

色々試した結果、今回は拡張子に.htmlをつけたら変換できるようになった。 (一時ファイルのため拡張子がなかった)

その時々によって色々理由は異なるだろうがとりあえずひとつのパターン。

PhoenixでLet's EncryptによるSSL

前提

PhoenixでLet's Encryptにより無料でSSL対応を行う。

手順

基本的には

Phoenix/Elixir App Secured with Let’s Encrypt – Andrew Forward – Medium

で書かれている通り。

とりあえずサーバー起動。

MIX_ENV=prod mix phx.server

サーバーを起動したまま.well-knownフォルダを更新しなければならないのでそのための設定を行う。

まず.well-knownフォルダ以下をそのままアクセスできるようにするための設定。

lib/プロジェクト名/web/endpoint.ex にてPlug.Staticの設定に.well-knownを追加する。

plug Plug.Static,
 at: "/", from: :yourproject, gzip: false,
 only: ~w(css fonts images js favicon.ico robots.txt .well-known)

これで _build/prod/lib/プロジェクト名/priv/static/.well-known 以下がそのまま公開され、 http://yourdomain.com/.well-known/****.html のような感じでアクセスできるようになる。

ちなみに、設定を変更したらアプリケーションを再起動する必要があると思う。 また、再起動すると作っていた.well-knownは消えるので混乱しないよう注意。

うまくいったらあとはcertbotで証明書を発行する。

そして設定を更新。

config :yourproject, Yourproject.Web.Endpoint,
  http: [port: 80],
  https: [port: 443,
    url: [host: "yourdomain.com", port: 443],
    keyfile: "/etc/letsencrypt/live/yourdomain.com/privkey.pem",
    cacertfile: "/etc/letsencrypt/live/yourdomain.com/chain.pem",  
    certfile: "/etc/letsencrypt/live/yourdomain.com/cert.pem"],
  force_ssl: [hsts: true]

これで完了。更新などもそのまま普通にできるようになる。

Now your site is being served up only through SSL directly through Phoenix (no Nginx required).

と書かれているので別のWEBサーバーを使う冗長な方法もあったのだろうか。 とはいえ_build以下を使う方法なので今後のバージョンによってはできなくなる可能性などもあるのかもしれない。

Phoenixで他のフォルダの共通テンプレートをrenderする

自動生成されたedit.html.eexのformテンプレート読み込み部分を見ると、下記のようになっている。

<%= render "form.html", changeset: @changeset,
                        action: post_path(@conn, :update, @post) %>

ファイル名しか指定されていないので他の共通フォルダなどに入れている場合は指定が出来そうもない。

共通フォルダなどのファイルを指定したい場合どうすればよいかというと、 よくよく調べるとrender関数は第1引数にViewを指定することもできる。

また、自動生成されたプロジェクトのViewを見てみると、LayoutView等、コントローラに連動していないView等もある。

つまり、まず勝手に使用したいフォルダのViewを作成し、

defmodule App.CommonView do
  use App.Web, :view
end

それを使ってrenderを呼べばいい。これでtemplates/common/test.html.eexが表示される。

<%= render App.CommonView, "test.html" %>

Phoenixでmany_to_manyのフォームを対応

Phoenixでmany_to_manyを設定してDBからデータを取得して表示するのは非常に簡単。 ではformで新規登録したり更新したりする際に一緒にmany_to_manyのデータを更新するのはどのようにするのか一通り試してみた。

form

例として、Postに複数のTagが紐付いているパターンで考える。 PostsTagのモデルはなく、join_throughにて文字列でposts_tagsのテーブルを指定しているだけ。

formではTagをカンマ区切りで設定できるという仕様。

とりあえず入力欄として使用するためのtag_namesというvirtualフィールドを作っておく。

    field :tag_names, :string, virtual: true

そしてフォームの入力欄を追加。

  <div class="form-group">
    <%= label f, :tag_names, class: "control-label" %>
    <%= text_input f, :tag_names, class: "form-control" %>
  </div>

ここまではシンプルで難しいことはない。

既に存在するデータを入力欄のデフォルトとして表示

既に登録されているデータを更新する際に、上記の入力欄に表示を行うための処理。 とくに難しいことはなく、tag_namesにカンマ区切りの値を入れておくだけ。

モデルに値をセットする関数を追加。

  def prepare_form(changeset) do
    tag_names = Enum.map(get_field(changeset, :tags), fn(tag) -> tag.name end)
    |> Enum.join(",")
    put_change(changeset, :tag_names, tag_names)
  end

これをedit時に呼び出すだけ。

    changeset = Post.changeset(post)
    |> Post.prepare_form

新規登録、編集時にTagとtags_postsを登録する

とりあえず、Tagを登録するためのRepoを作った。 (Tagのモデル内に実装しても良いのかもしれないが、 デフォルトでモデルにはRepoがaliasされていないことからモデル内ではRepoを使用しない方が良い想定なのかということも考慮し、 専用のRepoを作る形とした。 実際どういった形が望ましいのかは不明)

文字列でtag_namesを渡すと保存したTagの配列を取得する関数がメイン。

defmodule App.TagRepo do
  import App.Repo
  import Ecto.Changeset
  alias App.Tag

  def save_tags(tag_names) do
    tags = tag_names_to_tags(tag_names)
    |> Enum.map(fn(tag) ->
      case get_by(Tag, name: tag.name) do
        nil ->
          Tag.changeset(tag)
          |> insert!
        saved_tag -> saved_tag
      end
    end)
  end

  def tag_names_to_tags(tag_names) do
    String.split(tag_names, ",")
    |> Enum.map(fn(name) -> %Tag{name: name} end)
  end
end

これをcreateとupdateで呼び出すだけ。

新しく入力されたタグは新しいTagとして保存され、posts_tagsも登録される。 タグが減った場合はposts_tags(のみ)も減る。

    post = Repo.get!(Post, id)
    |> Repo.preload(:tags)

    tags = TagRepo.save_tags(post_params["tag_names"])
    changeset = Post.changeset(post, post_params)
    |> Ecto.Changeset.put_assoc(:tags, tags)

updateの方のみ、上記のようにpreloadが必要となる。

また、タグが減った場合エラーになるので、モデルに下記のon_replaceの追記も必要。

    many_to_many :tags, App.Tag, join_through: "posts_tags", on_replace: :delete

Phoenixでadminルーティングの認証

Phoenixでadminルーティングしてそこだけ認証を入れる。

仕様

  • /admin/articles のように最初にadminを含むURLは管理画面
  • 管理画面は管理者ユーザーで認証が必要
  • 管理画面もgen.htmlで自動生成。自分で頑張って作ったりしない
  • コントローラやアクション毎に全部認証チェックを書いたりしない

モデルを作る

mix phoenix.gen.model でモデルだけ先に作る。もちろんgen.htmlでルーティングのない箇所のCRUDと一緒にモデルを作ってもいい。

管理者側ページを作る。

下記でadmin向けページを作成できる。

mix phoenix.gen.html Admin.Article articles --no-model title:string ....

これでコントローラやテンプレートも全部adminフォルダに分けて作ってくれる。 モデルのaliasにAdminが含まれているのでそこだけ削除。

認証チェックを行う

認証されていない場合にログイン画面へリダイレクトする。まず下記のようなPlugを作る。 (下記のようにAuthモジュールを作るなり直接セッションで見るなりする)

defmodule App.Plug.Prefix do
  import Plug.Conn

  alias App.Auth

  def init(default), do: default

  def call(conn, params) do
    [prefix] = params
    if !Auth.get_user(conn, prefix) do
      Phoenix.Controller.redirect(conn, to: "/admins/login")
      |> halt
    end
    conn
  end
end

(調べたらどこもhaltしてたんだけどほんとにこれでいいんだろうか)

ルーティングの設定

ルーティングで先程のPlugを導入。pipelineで実装。ついでにレイアウトはadmin.html.eexを使用するようにもしておく。

  pipeline :admin do
    plug :put_layout, {App.LayoutView, :admin}
    plug App.Plug.Prefix, [:admin]
  end

URLのルーティング。pipelineは複数設定できるらしい。

  scope "/admin", App, as: :admin do
    pipe_through [:browser, :admin]

    get "/admins/logout", Admin.AdminController, :logout
    resources "/admins", Admin.AdminController
  end

まとめ

これで管理画面側の処理を別に出来た。通常のユーザーのマイページなども同様にしてatomを変えるだけで実装できる。

Phoenixのex_adminでueberauthの認証

Phoenixでex_adminをueberauthを使って行う。 基本的には

Elixir/Phoenixにおけるueberauth(認証)とex_admin(管理画面)の連携方法 - Qiita

の解説通りでいいのだが、一部情報が古いので現在の対応方法を記載。

current_user_nameはログインしていないとエラーになるので下記のように修正。

  def current_user_name(conn) do
    user = current_user(conn)
    if user do
      user.name
    else
      ''
    end
  end

あと現在はconfigのauthorizeはなくなった。 ExAdmin.Authenticationの書き方と同様にできるようになったので下記をconn.exに追記。 コントローラによって処理を分けたいならAnyの部分を変えれば良い。

defimpl ExAdmin.Authorization, for: Any do
  def authorize_query(_, _, query, _, _), do: query
  def authorize_action(resource, conn, action) do
    App.UserAuth.check_logged_in(conn, action, resource)
  end
end

あとuser_auth.exのcheck_logged_inだが、identityが必要ならつける。

  def check_logged_in(conn, _action, _resource_model) do
    login_url = auth_path(conn, :request, "identity")
    if conn.request_path != login_url && !logged_in?(conn) do
      conn |> redirect(to: login_url)
    end
    true
  end

Phoenixのex_adminを使ってみてうまく動かない時

Phoenixでためしにex_adminを使って管理画面を作ってみようと思ったのだがどうもうまく動かない。 その時に試した時の対策。

コンパイルエラー

ex_adminはまだバージョン1にも行っていないし、Phoenix側に追従しきれていない場合があるようなので丁度ビルド時にエラーが出た。 deps/ex_admin/web/web.exに

import Ecto.Model

となっているところがあるので下記に修正。

import Ecto.Schema

既にこの修正のPRはmergeされているようなので、ex_adminのバージョンが上がった時に反映されると思うので気にせず直接depsのファイルをいじって問題ないと思う。

cssが反映されない

どのサイトを見てもアクセスすれば正しく画面が表示される、とあり、githubの情報にも特に何もないのだが、自分が試した時はcssが404になっていてデザインがなかった。

この辺はよくわからないがadmin.installした時に追加されてたweb/static/vendorの中のファイルをpriv/staticのcss, jsの中にコピーしたら表示された。 何も書かれていなくてもコピーするのが当然なのか、staticの機能で勝手に本来は反映されるべきなのかは不明。