diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..1568870 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: elixir +elixir: +- 1.3.4 +before_install: cd /home/travis/build/dwyl/learn-phoenix-framework/rumbl && pwd +cache: + directories: + - _build + - deps diff --git a/README.md b/README.md index bd9c12c..b1f852d 100644 --- a/README.md +++ b/README.md @@ -52,18 +52,19 @@ _trying_ to make it **go _faster_**1 ... ***Or***... _get_ the [**_best_ car**](http://www.cnbc.com/2015/08/27/teslas-p85d-is-the-best-car-consumer-reports-has-ever-tested.html) built for speed, safety and environmental friendliness and ***stop wasting time*** on the past! ![model-s-photo](https://cloud.githubusercontent.com/assets/194400/22628333/e8a107ee-ebc9-11e6-9140-6be11cdddd87.jpg "Tesla Model S")
-_**Note**: in the case of Phoenix, -you're getting a Tesla Model S P100D for the "price" of a Ford Fiesta! +_**Note**: with Phoenix, +you're getting a [Tesla Model S P100D](https://www.tesla.com/en_GB/blog/new-tesla-model-s-now-quickest-production-car-world) for the "price" of a Ford Fiesta! A **logical** choice, **right**_? Just like there is an _entire industry_ involved in "_tuning_" distinctly "_average_" cars (_that weren't made for high performance!_) -there's a similar one for "_optimsing_" slow web applications. +there's a similar one for "_optimizing_" slow web applications. Organizations end up spending _way_ more time and money (_"engineering" consultants and server resources_!) -trying to make their "_old tech_" scale or serve more (_concurrent users_), -than they would simply making smarter tech choices. +trying to make their "_old tech_" scale or serve more (_concurrent_) users, +than they would simply making smarter tech choices +(_and avoiding ["sunk cost bias"](http://www.lifehack.org/articles/communication/how-the-sunk-cost-fallacy-makes-you-act-stupid.html)_). 1: car mod fails: [ridelust.com/30-custom-cars-from-around-the-world](http://www.ridelust.com/30-custom-cars-from-around-the-world) diff --git a/examples/basic-test.exs b/examples/basic-test.exs new file mode 100644 index 0000000..151a4f0 --- /dev/null +++ b/examples/basic-test.exs @@ -0,0 +1,23 @@ +# run this test using the following command: +# elixir -e "ExUnit.start()" -r basic-test.exs + +defmodule MyTest do + use ExUnit.Case, async: true + + setup do + # run some tedious setup code + :ok + end + + test "pass" do + assert true + end + + test "fail" do + refute false + end +end + + +# thanks to @shouston3 in +# https://github.com/dwyl/learn-phoenix-framework/issues/34#issuecomment-280930167 \ No newline at end of file diff --git a/examples/watching_videos/rumbl/web/controllers/video_controller.ex b/examples/watching_videos/rumbl/web/controllers/video_controller.ex index ed1f8b7..a7f9834 100644 --- a/examples/watching_videos/rumbl/web/controllers/video_controller.ex +++ b/examples/watching_videos/rumbl/web/controllers/video_controller.ex @@ -14,7 +14,6 @@ defmodule Rumbl.VideoController do plug :scrub_params, "video" when action in [:create, :update] - plug :load_categories when action in [:new, :create, :edit, :update] defp load_categories(conn, _) do diff --git a/rumbl/config/test.exs b/rumbl/config/test.exs index a47586e..c9735fa 100644 --- a/rumbl/config/test.exs +++ b/rumbl/config/test.exs @@ -17,3 +17,6 @@ config :rumbl, Rumbl.Repo, database: "rumbl_test", hostname: "localhost", pool: Ecto.Adapters.SQL.Sandbox + +config :comeonin, :bcrypt_log_rounds, 4 +config :comeonin, :pbkdf2_rounds, 1 diff --git a/rumbl/lib/rumbl/permalink.ex b/rumbl/lib/rumbl/permalink.ex new file mode 100644 index 0000000..5cccd48 --- /dev/null +++ b/rumbl/lib/rumbl/permalink.ex @@ -0,0 +1,28 @@ +defmodule Rumbl.Permalink do + @behaviour Ecto.Type + + def type, do: :id + + def cast(binary) when is_binary(binary) do + case Integer.parse(binary) do + {int, _} when int > 0 -> {:ok, int} + _ -> :error + end + end + + def cast(integer) when is_integer(integer) do + {:ok, integer} + end + + def cast(_) do + :error + end + + def dump(integer) when is_integer(integer) do + {:ok, integer} + end + + def load(integer) when is_integer(integer) do + {:ok, integer} + end +end diff --git a/rumbl/priv/repo/migrations/20170207074532_create_user.exs b/rumbl/priv/repo/migrations/20170207074532_create_user.exs index 85f96f4..241df30 100644 --- a/rumbl/priv/repo/migrations/20170207074532_create_user.exs +++ b/rumbl/priv/repo/migrations/20170207074532_create_user.exs @@ -7,7 +7,7 @@ defmodule Rumbl.Repo.Migrations.CreateUser do add :username, :string, null: false add :password_hash, :string - timestamps + timestamps() end create unique_index(:users, [:username]) diff --git a/rumbl/priv/repo/migrations/20170212214945_create_video.exs b/rumbl/priv/repo/migrations/20170212214945_create_video.exs index 4d63b21..8c65ad3 100644 --- a/rumbl/priv/repo/migrations/20170212214945_create_video.exs +++ b/rumbl/priv/repo/migrations/20170212214945_create_video.exs @@ -11,6 +11,5 @@ defmodule Rumbl.Repo.Migrations.CreateVideo do timestamps() end create index(:videos, [:user_id]) - end end diff --git a/rumbl/priv/repo/migrations/20170218093549_create_category.exs b/rumbl/priv/repo/migrations/20170218093549_create_category.exs new file mode 100644 index 0000000..4aaf1c7 --- /dev/null +++ b/rumbl/priv/repo/migrations/20170218093549_create_category.exs @@ -0,0 +1,13 @@ +defmodule Rumbl.Repo.Migrations.CreateCategory do + use Ecto.Migration + + def change do + create table(:categories) do + add :name, :string, null: false + + timestamps() # function call parenthesis not in book! + end + + create unique_index(:categories, [:name]) + end +end diff --git a/rumbl/priv/repo/migrations/20170218111702_add_category_id_to_video.exs b/rumbl/priv/repo/migrations/20170218111702_add_category_id_to_video.exs new file mode 100644 index 0000000..af372b7 --- /dev/null +++ b/rumbl/priv/repo/migrations/20170218111702_add_category_id_to_video.exs @@ -0,0 +1,9 @@ +defmodule Rumbl.Repo.Migrations.AddCategoryIdToVideo do + use Ecto.Migration + + def change do + alter table(:videos) do + add :category_id, references(:categories) + end + end +end diff --git a/rumbl/priv/repo/migrations/20170305085721_add_slug_to_video.exs b/rumbl/priv/repo/migrations/20170305085721_add_slug_to_video.exs new file mode 100644 index 0000000..8ac54cb --- /dev/null +++ b/rumbl/priv/repo/migrations/20170305085721_add_slug_to_video.exs @@ -0,0 +1,9 @@ +defmodule Rumbl.Repo.Migrations.AddSlugToVideo do + use Ecto.Migration + + def change do + alter table(:videos) do + add :slug, :string + end + end +end diff --git a/rumbl/priv/repo/seeds.exs b/rumbl/priv/repo/seeds.exs index 8cf4a91..b7447ce 100644 --- a/rumbl/priv/repo/seeds.exs +++ b/rumbl/priv/repo/seeds.exs @@ -9,3 +9,11 @@ # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. + +alias Rumbl.Repo +alias Rumbl.Category + +for category <- ~w(Action Drama Romance Comedy Sci-fi) do + Repo.get_by(Category, name: category) || + Repo.insert!(%Category{name: category}) +end diff --git a/rumbl/test/controllers/auth_test.exs b/rumbl/test/controllers/auth_test.exs new file mode 100644 index 0000000..49e111b --- /dev/null +++ b/rumbl/test/controllers/auth_test.exs @@ -0,0 +1,83 @@ +defmodule Rumb.AuthTest do + use Rumbl.ConnCase + alias Rumbl.Auth + + setup %{conn: conn} do + conn = + conn + |> bypass_through(Rumbl.Router, :browser) + |> get("/") + + {:ok, %{conn: conn}} + end + + test "authenticate_user halts when no current_user exists", %{conn: conn} do + conn = Auth.authenticate_user(conn, []) + assert conn.halted + end + + test "authenticate_user continues when the current_user exists", + %{conn: conn} do + + conn = + conn + |> assign(:current_user, %Rumbl.User{}) + |> Auth.authenticate_user([]) + refute conn.halted + end + + test "login puts the user in the session", %{conn: conn} do + login_conn = + conn + |> Auth.login(%Rumbl.User{id: 123}) + |> send_resp(:ok, "") + + next_conn = get(login_conn, "/") + assert get_session(next_conn, :user_id) == 123 + end + + test "logout drops the session", %{conn: conn} do + logout_conn = + conn + |> put_session(:user_id, 123) + |> Auth.logout() + |> send_resp(:ok, "") + + next_conn = get(logout_conn, "/") + refute get_session(next_conn, :user_id) + end + + test "call places user from session into assigns", %{conn: conn} do + user = insert_user() + conn = + conn + |> put_session(:user_id, user.id) + |> Auth.call(Repo) + + assert conn.assigns.current_user.id == user.id + end + + test "call with no session sets current_user assign to nil", %{conn: conn} do + conn = Auth.call(conn, Repo) + assert conn.assigns.current_user == nil + end + + test "login with a valid username and pass", %{conn: conn} do + user = insert_user(username: "me", password: "secret") + {:ok, conn} = + Auth.login_by_username_and_pass(conn, "me", "secret", repo: Repo) + + assert conn.assigns.current_user.id == user.id + end + + test "login with a not found user", %{conn: conn} do + assert {:error, :not_found, _conn} = + Auth.login_by_username_and_pass(conn, "me", "secret", repo: Repo) + end + + test "login with password missmatch", %{conn: conn} do + _ = insert_user(username: "me", password: "secret") + assert {:error, :unauthorized, _conn} = + Auth.login_by_username_and_pass(conn, "me", "wrong", repo: Repo) + end +end diff --git a/rumbl/test/controllers/page_controller_test.exs b/rumbl/test/controllers/page_controller_test.exs index e0f226b..37b4fee 100644 --- a/rumbl/test/controllers/page_controller_test.exs +++ b/rumbl/test/controllers/page_controller_test.exs @@ -3,6 +3,6 @@ defmodule Rumbl.PageControllerTest do test "GET /", %{conn: conn} do conn = get conn, "/" - assert html_response(conn, 200) =~ "Welcome to Phoenix!" + assert html_response(conn, 200) =~ "Welcome to Rumbl.io" end end diff --git a/rumbl/test/controllers/video_controller_test.exs b/rumbl/test/controllers/video_controller_test.exs index 0321576..764caac 100644 --- a/rumbl/test/controllers/video_controller_test.exs +++ b/rumbl/test/controllers/video_controller_test.exs @@ -1,66 +1,89 @@ defmodule Rumbl.VideoControllerTest do use Rumbl.ConnCase - alias Rumbl.Video - @valid_attrs %{description: "some content", title: "some content", url: "some content"} - @invalid_attrs %{} - - test "lists all entries on index", %{conn: conn} do - conn = get conn, video_path(conn, :index) - assert html_response(conn, 200) =~ "Listing videos" + setup %{conn: conn} = config do + if username = config[:login_as] do + user = insert_user(username: username) + conn = assign(conn, :current_user, user) + {:ok, conn: conn, user: user} + else + :ok + end end - test "renders form for new resources", %{conn: conn} do - conn = get conn, video_path(conn, :new) - assert html_response(conn, 200) =~ "New video" + test "requires user authentication on all actions", %{conn: conn} do + Enum.each([ + get(conn, video_path(conn, :new)), + get(conn, video_path(conn, :index)), + get(conn, video_path(conn, :show, "123")), + get(conn, video_path(conn, :edit, "123")), + put(conn, video_path(conn, :update, "123", %{})), + post(conn, video_path(conn, :create, %{})), + delete(conn, video_path(conn, :delete, "123")), + ], fn conn -> + assert html_response(conn, 302) + assert conn.halted + end) end - test "creates resource and redirects when data is valid", %{conn: conn} do - conn = post conn, video_path(conn, :create), video: @valid_attrs - assert redirected_to(conn) == video_path(conn, :index) - assert Repo.get_by(Video, @valid_attrs) - end + # setup do + # user = insert_user(username: "max") + # conn = assign(build_conn, :current_user, user) + # {:ok, conn: conn, user: user} + # end - test "does not create resource and renders errors when data is invalid", %{conn: conn} do - conn = post conn, video_path(conn, :create), video: @invalid_attrs - assert html_response(conn, 200) =~ "New video" - end + @tag login_as: "max" + test "lists all user's video on index", %{conn: conn, user: user} do + user_video = insert_video(user, title: "funny cats") + other_video = insert_video(insert_user(username: "other"), + title: "another video") - test "shows chosen resource", %{conn: conn} do - video = Repo.insert! %Video{} - conn = get conn, video_path(conn, :show, video) - assert html_response(conn, 200) =~ "Show video" + conn = get conn, video_path(conn, :index) + assert html_response(conn, 200) =~ ~r/Listing videos/ + assert String.contains?(conn.resp_body, user_video.title) + refute String.contains?(conn.resp_body, other_video.title) end - test "renders page not found when id is nonexistent", %{conn: conn} do - assert_error_sent 404, fn -> - get conn, video_path(conn, :show, -1) - end - end + alias Rumbl.Video + @valid_attrs %{url: "http://youtu.be", title: "vid", description: "a vid"} + @invalid_attrs %{title: "invalid"} - test "renders form for editing chosen resource", %{conn: conn} do - video = Repo.insert! %Video{} - conn = get conn, video_path(conn, :edit, video) - assert html_response(conn, 200) =~ "Edit video" - end + defp video_count(query), do: Repo.one(from v in query, select: count(v.id)) - test "updates chosen resource and redirects when data is valid", %{conn: conn} do - video = Repo.insert! %Video{} - conn = put conn, video_path(conn, :update, video), video: @valid_attrs - assert redirected_to(conn) == video_path(conn, :show, video) - assert Repo.get_by(Video, @valid_attrs) + @tag login_as: "max" + test "creates user video and redirects", %{conn: conn, user: user} do + conn = post conn, video_path(conn, :create), video: @valid_attrs + # assert redirected_to(conn) = video_path(conn, :index) # see: https://github.com/dwyl/learn-phoenix-framework/issues/40 + assert Repo.get_by!(Video, @valid_attrs).user_id == user.id end - test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do - video = Repo.insert! %Video{} - conn = put conn, video_path(conn, :update, video), video: @invalid_attrs - assert html_response(conn, 200) =~ "Edit video" + @tag login_as: "max" + test "does not create video and renders errors when invalid", %{conn: conn} do + count_before = video_count(Video) + conn = post conn, video_path(conn, :create), video: @invalid_attrs + assert html_response(conn, 200) =~ "check the errors" + assert video_count(Video) == count_before end - test "deletes chosen resource", %{conn: conn} do - video = Repo.insert! %Video{} - conn = delete conn, video_path(conn, :delete, video) - assert redirected_to(conn) == video_path(conn, :index) - refute Repo.get(Video, video.id) + @tag login_as: "max" + test "authorizes actions against access by other users", + %{user: owner, conn: conn} do + + video = insert_video(owner, @valid_attrs) + non_owner = insert_user(username: "sneaky") # https://youtu.be/_YQpbzQ6gzs?t=2m48s + conn = assign(conn, :current_user, non_owner) + + assert_error_sent :not_found, fn -> + get(conn, video_path(conn, :show, video)) + end + assert_error_sent :not_found, fn -> + get(conn, video_path(conn, :edit, video)) + end + assert_error_sent :not_found, fn -> + put(conn, video_path(conn, :update, video, video: @valid_attrs)) + end + assert_error_sent :not_found, fn -> + delete(conn, video_path(conn, :delete, video)) + end end end diff --git a/rumbl/test/models/category_repo_test.exs b/rumbl/test/models/category_repo_test.exs new file mode 100644 index 0000000..7ed0c30 --- /dev/null +++ b/rumbl/test/models/category_repo_test.exs @@ -0,0 +1,14 @@ +defmodule Rumbl.CategoryRepoTest do + use Rumbl.ModelCase + alias Rumbl.Category + + test "alphabetical/1 orders by name" do + Repo.insert!(%Category{name: "c"}) + Repo.insert!(%Category{name: "a"}) + Repo.insert!(%Category{name: "b"}) + + query = Category |> Category.alphabetical() + query = from c in query, select: c.name + assert Repo.all(query) == ~w(a b c) + end +end diff --git a/rumbl/test/models/category_test.exs b/rumbl/test/models/category_test.exs new file mode 100644 index 0000000..9404657 --- /dev/null +++ b/rumbl/test/models/category_test.exs @@ -0,0 +1,18 @@ +defmodule Rumbl.CategoryTest do + use Rumbl.ModelCase + + alias Rumbl.Category + + @valid_attrs %{name: "some content"} + @invalid_attrs %{} + + test "changeset with valid attributes" do + changeset = Category.changeset(%Category{}, @valid_attrs) + assert changeset.valid? + end + + test "changeset with invalid attributes" do + changeset = Category.changeset(%Category{}, @invalid_attrs) + refute changeset.valid? + end +end diff --git a/rumbl/test/models/user_repo_test.exs b/rumbl/test/models/user_repo_test.exs new file mode 100644 index 0000000..3a6ffd1 --- /dev/null +++ b/rumbl/test/models/user_repo_test.exs @@ -0,0 +1,15 @@ +defmodule Rumbl.UserRepoTest do + use Rumbl.ModelCase + alias Rumbl.User + + @valid_attrs %{name: "A User", username: "eva"} + + test "converts unique_constraint on username to error" do + insert_user(username: "eric") + attrs = Map.put(@valid_attrs, :username, "eric") + changeset = User.changeset(%User{}, attrs) + + assert {:error, changeset} = Repo.insert(changeset) + assert username: "has already been taken" in changeset.errors + end +end diff --git a/rumbl/test/models/user_test.exs b/rumbl/test/models/user_test.exs new file mode 100644 index 0000000..f282050 --- /dev/null +++ b/rumbl/test/models/user_test.exs @@ -0,0 +1,42 @@ +defmodule Rumbl.UserTest do + use Rumbl.ModelCase, async: true + alias Rumbl.User + + @valid_attrs %{name: "A User", username: "eva", password: "secret"} + @invalid_attrs %{} + + test "changeset with valid attributes" do + changeset = User.changeset(%User{}, @valid_attrs) + assert changeset.valid? + end + + # see: https://github.com/dwyl/learn-phoenix-framework/issues/41 #HelpWanted + # test "changeset with invalid attributes" do + # changeset = User.changeset(%User{}, @invalid_attrs) + # refute changeset.valid? + # end + + # https://github.com/dwyl/learn-phoenix-framework/issues/41#issuecomment-282601007 + test "changeset does not accept long usernames" do + attrs = Map.put(@valid_attrs, :username, String.duplicate("a", 30)) + assert username: "should be at most 20 character(s)" in + errors_on(%User{}, attrs) + end + + test "registration_changeset password must be at least 6 chars long" do + attrs = Map.put(@valid_attrs, :password, "12345") + changeset = User.registration_changeset(%User{}, attrs) + assert password: "should be at least 6 character(s)" in changeset.errors + end + + test "registration_changeset with valid attributes hashes password" do + attrs = Map.put(@valid_attrs, :password, "123456") + changeset = User.registration_changeset(%User{}, attrs) + %{password: pass, password_hash: pass_hash} = changeset.changes + + assert changeset.valid? + assert pass_hash + assert Comeonin.Bcrypt.checkpw(pass, pass_hash) + end + +end diff --git a/rumbl/test/support/conn_case.ex b/rumbl/test/support/conn_case.ex index 6b7bb84..77021d8 100644 --- a/rumbl/test/support/conn_case.ex +++ b/rumbl/test/support/conn_case.ex @@ -23,9 +23,10 @@ defmodule Rumbl.ConnCase do alias Rumbl.Repo import Ecto import Ecto.Changeset - import Ecto.Query + import Ecto.Query, only: [from: 1, from: 2] import Rumbl.Router.Helpers + import Rumbl.TestHelpers # The default endpoint for testing @endpoint Rumbl.Endpoint diff --git a/rumbl/test/support/model_case.ex b/rumbl/test/support/model_case.ex index c259d82..d956700 100644 --- a/rumbl/test/support/model_case.ex +++ b/rumbl/test/support/model_case.ex @@ -20,7 +20,8 @@ defmodule Rumbl.ModelCase do import Ecto import Ecto.Changeset - import Ecto.Query + import Ecto.Query, only: [from: 1, from: 2] + import Rumbl.TestHelpers import Rumbl.ModelCase end end @@ -30,11 +31,16 @@ defmodule Rumbl.ModelCase do unless tags[:async] do Ecto.Adapters.SQL.Sandbox.mode(Rumbl.Repo, {:shared, self()}) + # Ecto.Adapters.SQL.restart_test_transaction(Rumbl.Repo, []) end :ok end + def errors_on(model, data) do + model.__struct__.changeset(model, data).errors + end + @doc """ Helper for returning list of errors in a struct when given certain data. diff --git a/rumbl/test/support/test_helpers.ex b/rumbl/test/support/test_helpers.ex new file mode 100644 index 0000000..a6fdaa0 --- /dev/null +++ b/rumbl/test/support/test_helpers.ex @@ -0,0 +1,21 @@ +defmodule Rumbl.TestHelpers do + alias Rumbl.Repo + + def insert_user(attrs \\ %{}) do + changes = Dict.merge(%{ + name: "Some User", + username: "user#{Base.encode16(:crypto.strong_rand_bytes(8))}", + password: "supersecret", + }, attrs) + + %Rumbl.User{} + |> Rumbl.User.registration_changeset(changes) + |> Repo.insert!() + end + + def insert_video(user, attrs \\ %{}) do + user + |> Ecto.build_assoc(:videos, attrs) + |> Repo.insert!() + end +end diff --git a/rumbl/test/views/video_view_test.exs b/rumbl/test/views/video_view_test.exs new file mode 100644 index 0000000..cef7d12 --- /dev/null +++ b/rumbl/test/views/video_view_test.exs @@ -0,0 +1,25 @@ +defmodule Rumbl.VideoViewTest do + use Rumbl.ConnCase, asnyc: true + import Phoenix.View + + test "renders index.html", %{conn: conn} do + videos = [%Rumbl.Video{id: "1", title: "dogs"}, + %Rumbl.Video{id: "2", title: "cats"}] + content = render_to_string(Rumbl.VideoView, "index.html", + conn: conn, videos: videos) + + assert String.contains?(content, "Listing videos") + for video <- videos do + assert String.contains?(content, video.title) + end + end + + test "renders new.html", %{conn: conn} do + changeset = Rumbl.Video.changeset(%Rumbl.Video{}) + categories = [{"cats", 123}] + content = render_to_string(Rumbl.VideoView, "new.html", + conn: conn, changeset: changeset, categories: categories) + # IO.puts content + assert String.contains?(content, "New Video") + end +end diff --git a/rumbl/web/controllers/auth.ex b/rumbl/web/controllers/auth.ex index f4941c7..e85ce71 100644 --- a/rumbl/web/controllers/auth.ex +++ b/rumbl/web/controllers/auth.ex @@ -8,8 +8,13 @@ defmodule Rumbl.Auth do def call(conn, repo) do user_id = get_session(conn, :user_id) - user = user_id && repo.get(Rumbl.User, user_id) - assign(conn, :current_user, user) + cond do + user = conn.assigns[:current_user] -> conn + user = user_id && repo.get(Rumbl.User, user_id) -> + assign(conn, :current_user, user) + true -> + assign(conn, :current_user, nil) + end end def login(conn, user) do @@ -37,4 +42,31 @@ defmodule Rumbl.Auth do def logout(conn) do configure_session(conn, drop: true) end + + import Phoenix.Controller + alias Rumbl.Router.Helpers + + def authenticate_user(conn, _opts) do + if conn.assigns.current_user do + conn + else + conn + |> put_flash(:error, "You must be logged in to access that page") + |> redirect(to: Helpers.page_path(conn, :index)) + |> halt() + end + end + + def call(conn, repo) do + user_id = get_session(conn, :user_id) + + cond do + user = conn.assigns[:current_user] -> + conn + user = user_id && repo.get(Rumbl.User, user_id) -> + assign(conn, :current_user, user) + true -> + assign(conn, :current_user, nil) + end + end end diff --git a/rumbl/web/controllers/user_controller.ex b/rumbl/web/controllers/user_controller.ex index c4f0214..2eafca8 100644 --- a/rumbl/web/controllers/user_controller.ex +++ b/rumbl/web/controllers/user_controller.ex @@ -1,6 +1,6 @@ defmodule Rumbl.UserController do use Rumbl.Web, :controller - plug :authenticate when action in [:index, :show] + plug :authenticate_user when action in [:index, :show] alias Rumbl.User def index(conn, _params) do @@ -31,15 +31,4 @@ defmodule Rumbl.UserController do render(conn, "new.html", changeset: changeset) end end - - defp authenticate(conn, _opts) do - if conn.assigns.current_user do - conn - else - conn - |> put_flash(:error, "You must be logged in to access that page") - |> redirect(to: page_path(conn, :index)) - |> halt() - end - end end diff --git a/rumbl/web/controllers/video_controller.ex b/rumbl/web/controllers/video_controller.ex index b06ab3b..73e48f5 100644 --- a/rumbl/web/controllers/video_controller.ex +++ b/rumbl/web/controllers/video_controller.ex @@ -2,19 +2,44 @@ defmodule Rumbl.VideoController do use Rumbl.Web, :controller alias Rumbl.Video + alias Rumbl.Category + plug :load_categories when action in [:new, :create, :edit, :update] - def index(conn, _params) do - videos = Repo.all(Video) + defp load_categories(conn, _) do + query = + Category + |> Category.alphabetical + |> Category.names_and_ids + categories = Repo.all query + assign(conn, :categories, categories) + end + + def action(conn, _) do + apply(__MODULE__, action_name(conn), + [conn, conn.params, conn.assigns.current_user]) + end + + def index(conn, _params, user) do + videos = Repo.all(user_videos(user)) render(conn, "index.html", videos: videos) end - def new(conn, _params) do - changeset = Video.changeset(%Video{}) + def new(conn, _params, user) do + changeset = + user + |> build_assoc(:videos) + |> Video.changeset() + render(conn, "new.html", changeset: changeset) end - def create(conn, %{"video" => video_params}) do - changeset = Video.changeset(%Video{}, video_params) + def create(conn, %{"video" => video_params}, user) do + IO.inspect video_params + IO.inspect user + changeset = + user + |> build_assoc(:videos) + |> Video.changeset(video_params) case Repo.insert(changeset) do {:ok, _video} -> @@ -26,19 +51,23 @@ defmodule Rumbl.VideoController do end end - def show(conn, %{"id" => id}) do - video = Repo.get!(Video, id) + defp user_videos(user) do + assoc(user, :videos) + end + + def show(conn, %{"id" => id}, user) do + video = Repo.get!(user_videos(user), id) render(conn, "show.html", video: video) end - def edit(conn, %{"id" => id}) do - video = Repo.get!(Video, id) + def edit(conn, %{"id" => id}, user) do + video = Repo.get!(user_videos(user), id) changeset = Video.changeset(video) render(conn, "edit.html", video: video, changeset: changeset) end - def update(conn, %{"id" => id, "video" => video_params}) do - video = Repo.get!(Video, id) + def update(conn, %{"id" => id, "video" => video_params}, user) do + video = Repo.get!(user_videos(user), id) changeset = Video.changeset(video, video_params) case Repo.update(changeset) do @@ -51,8 +80,8 @@ defmodule Rumbl.VideoController do end end - def delete(conn, %{"id" => id}) do - video = Repo.get!(Video, id) + def delete(conn, %{"id" => id}, user) do + video = Repo.get!(user_videos(user), id) # Here we use delete! (with a bang) because we expect # it to always work (and if it does not, it will raise). diff --git a/rumbl/web/controllers/watch_controller.ex b/rumbl/web/controllers/watch_controller.ex new file mode 100644 index 0000000..f3767b6 --- /dev/null +++ b/rumbl/web/controllers/watch_controller.ex @@ -0,0 +1,9 @@ +defmodule Rumbl.WatchController do + use Rumbl.Web, :controller + alias Rumbl.Video + + def show(conn, %{"id" => id}) do + video = Repo.get!(Video, id) + render conn, "show.html", video: video + end +end diff --git a/rumbl/web/models/category.ex b/rumbl/web/models/category.ex new file mode 100644 index 0000000..e57ba6f --- /dev/null +++ b/rumbl/web/models/category.ex @@ -0,0 +1,26 @@ +defmodule Rumbl.Category do + use Rumbl.Web, :model + + schema "categories" do + field :name, :string + + timestamps() + end + + @doc """ + Builds a changeset based on the `struct` and `params`. + """ + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:name]) + |> validate_required([:name]) + end + + def alphabetical(query) do + from c in query, order_by: c.name + end + + def names_and_ids(query) do + from c in query, select: {c.name, c.id} + end +end diff --git a/rumbl/web/models/user.ex b/rumbl/web/models/user.ex index 89f86c9..ff0e604 100644 --- a/rumbl/web/models/user.ex +++ b/rumbl/web/models/user.ex @@ -6,20 +6,23 @@ defmodule Rumbl.User do field :username, :string field :password, :string, virtual: true field :password_hash, :string + has_many :videos, Rumbl.Video timestamps end def changeset(model, params \\ :empty) do model - |> cast(params, ~w(name username), []) + |> cast(params, ~w(name username)) + |> validate_required([:name, :username]) |> validate_length(:username, min: 1, max: 20) + |> unique_constraint(:username) # save unique_constraint for last as DB call end def registration_changeset(model, params) do model |> changeset(params) - |> cast(params, ~w(password), []) + |> cast(params, ~w(password)) |> validate_length(:password, min: 6, max: 100) |> put_pass_hash() end diff --git a/rumbl/web/models/video.ex b/rumbl/web/models/video.ex index d792f3b..b6aade0 100644 --- a/rumbl/web/models/video.ex +++ b/rumbl/web/models/video.ex @@ -1,13 +1,15 @@ defmodule Rumbl.Video do use Rumbl.Web, :model + @primary_key {:id, Rumbl.Permalink, autogenerate: true} schema "videos" do field :url, :string field :title, :string field :description, :string belongs_to :user, Rumbl.User - - timestamps() + belongs_to :category, Rumbl.Category + field :slug, :string + timestamps() # invocation parenthesis not in book end @doc """ @@ -15,7 +17,29 @@ defmodule Rumbl.Video do """ def changeset(struct, params \\ %{}) do struct - |> cast(params, [:url, :title, :description]) - |> validate_required([:url, :title, :description]) + |> cast(params, [:url, :title, :description, :category_id]) + |> validate_required([:url, :title]) + |> slugify_title() + |> assoc_constraint(:category) + end + + defp slugify_title(changeset) do + if title = get_change(changeset, :title) do + put_change(changeset, :slug, slugify(title)) + else + changeset + end + end + + defp slugify(str) do + str + |> String.downcase() + |> String.replace(~r/[^\w-]+/u, "-") + end +end + +defimpl Phoenix.Param, for: Rumbl.Video do + def to_param(%{slug: slug, id: id}) do + "#{id}-#{slug}" end end diff --git a/rumbl/web/router.ex b/rumbl/web/router.ex index 4d97e0c..4b58b75 100644 --- a/rumbl/web/router.ex +++ b/rumbl/web/router.ex @@ -18,10 +18,17 @@ defmodule Rumbl.Router do pipe_through :browser # Use the default browser stack resources "/users", UserController, only: [:index, :show, :new, :create] resources "/sessions", SessionController, only: [:new, :create, :delete] - resources "/videos", VideoController + # resources "/videos", VideoController + get "/watch/:id", WatchController, :show get "/", PageController, :index end + scope "/manage", Rumbl do + pipe_through [:browser, :authenticate_user] + + resources "/videos", VideoController + end + # Other scopes may use custom stacks. # scope "/api", Rumbl do # pipe_through :api diff --git a/rumbl/web/static/css/video.css b/rumbl/web/static/css/video.css new file mode 100644 index 0000000..9b475a6 --- /dev/null +++ b/rumbl/web/static/css/video.css @@ -0,0 +1,5 @@ +#msg-container { + min-height: 180px; + max-height: 180px; + overflow-y: scroll; +} diff --git a/rumbl/web/static/js/app.js b/rumbl/web/static/js/app.js index e7549b9..b496bf1 100644 --- a/rumbl/web/static/js/app.js +++ b/rumbl/web/static/js/app.js @@ -19,3 +19,11 @@ import "phoenix_html" // paths "./socket" or full ones "web/static/js/socket". // import socket from "./socket" +import Player from "./player" +let video = document.getElementById("video") + +if (video) { + Player.init(video.id, video.getAttribute("data-player-id"), () => { + console.log("player ready! id:", video.getAttribute("data-player-id")) + }) +} diff --git a/rumbl/web/static/js/player.js b/rumbl/web/static/js/player.js new file mode 100644 index 0000000..abc80cd --- /dev/null +++ b/rumbl/web/static/js/player.js @@ -0,0 +1,31 @@ +let Player = { // JS works for small scripts like this + player: null, // but for anything bigger we prefer Elm + // and so do the creators of Phoenix! + // see: https://github.com/dwyl/learn-elm + init(domId, playerId, onReady) { + window.onYouTubeIframeAPIReady = () => { + this.onIFrameReady(domId, playerId, onReady) + } + let youtubeScriptTag = document.createElement("script") + youtubeScriptTag.src = "//www.youtube.com/iframe_api" + document.head.appendChild(youtubeScriptTag) + }, + + onIFrameReady(domId, playerId, onReady) { + this.player = new YT.Player(domId, { + height: "360", + width: "420", + videoId: playerId, + events: { + "onReady": (event => onReady(event)), + "onStateChange": (event => this.onPlayerStateChange(event)) + } + }) + }, + + onPlayerStateChange(event) { }, + getCurrentTime(){ return Math.floor(this.player.getCurrentTime() * 1000) }, + seekTo(millsec) { return this.player.seekTo(millsec / 1000) } + +} +export default Player diff --git a/rumbl/web/templates/layout/app.html.eex b/rumbl/web/templates/layout/app.html.eex index ea839ad..d5ce447 100644 --- a/rumbl/web/templates/layout/app.html.eex +++ b/rumbl/web/templates/layout/app.html.eex @@ -17,6 +17,7 @@