├── .exenv-version ├── elixir_buildpack.config ├── phoenix_static_buildpack.config ├── assets ├── .babelrc ├── static │ ├── images │ │ ├── favicon.ico │ │ └── phoenix.png │ └── robots.txt ├── css │ ├── _custom.scss │ └── app.scss ├── js │ ├── app.js │ └── socket.js ├── package.json └── webpack.config.js ├── test ├── test_helper.exs ├── phlink_web │ ├── views │ │ ├── page_view_test.exs │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs │ └── controllers │ │ ├── page_controller_test.exs │ │ ├── auth_controller_test.exs │ │ └── link_controller_test.exs ├── support │ ├── git_hub │ │ └── test.ex │ ├── channel_case.ex │ ├── conn_case.ex │ └── data_case.ex └── phlink │ └── user_test.exs ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20150424071208_add_user_id_to_links.exs │ │ ├── 20150425221224_add_avatar_url_to_users.exs │ │ ├── 20150423132140_create_user.exs │ │ └── 20150411194321_create_link.exs │ └── seeds.exs ├── static │ └── images │ │ └── phoenix.png └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ └── errors.pot ├── lib ├── phlink_web │ ├── views │ │ ├── link_view.ex │ │ ├── page_view.ex │ │ ├── layout_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── templates │ │ ├── link │ │ │ ├── new.html.eex │ │ │ ├── show.html.eex │ │ │ └── form.html.eex │ │ ├── page │ │ │ └── index.html.eex │ │ └── layout │ │ │ └── app.html.eex │ ├── controllers │ │ ├── page_controller.ex │ │ ├── auth_controller.ex │ │ └── link_controller.ex │ ├── gettext.ex │ ├── channels │ │ └── user_socket.ex │ ├── endpoint.ex │ └── router.ex ├── phlink.ex ├── phlink │ ├── repo.ex │ ├── cache │ │ ├── supervisor.ex │ │ ├── url_cache_supervisor.ex │ │ ├── url_cache.ex │ │ └── mapper.ex │ ├── shortcode.ex │ ├── user.ex │ ├── cache.ex │ ├── application.ex │ ├── release_tasks.ex │ └── link.ex ├── git_hub.ex └── phlink_web.ex ├── .formatter.exs ├── config ├── prod.exs ├── releases.exs ├── test.exs ├── config.exs └── dev.exs ├── rel ├── env.bat.eex ├── vm.args.eex └── env.sh.eex ├── README.md ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── mix.exs └── mix.lock /.exenv-version: -------------------------------------------------------------------------------- 1 | 1.9.4 2 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | elixir_version=1.9.4 2 | erlang_version=22.2.1 -------------------------------------------------------------------------------- /phoenix_static_buildpack.config: -------------------------------------------------------------------------------- 1 | node_version=12.4.0 2 | yarn_version=1.16.0 -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Phlink.Repo, :manual) 3 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/phlink_web/views/link_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.LinkView do 2 | use PhlinkWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/phlink_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.PageView do 2 | use PhlinkWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /priv/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrismcg/phlink/HEAD/priv/static/images/phoenix.png -------------------------------------------------------------------------------- /assets/static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrismcg/phlink/HEAD/assets/static/images/favicon.ico -------------------------------------------------------------------------------- /assets/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrismcg/phlink/HEAD/assets/static/images/phoenix.png -------------------------------------------------------------------------------- /test/phlink_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.PageViewTest do 2 | use PhlinkWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /lib/phlink_web/templates/link/new.html.eex: -------------------------------------------------------------------------------- 1 | <%= render "form.html", changeset: @changeset, action: Routes.link_path(@conn, :create) %> 2 | -------------------------------------------------------------------------------- /test/phlink_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.LayoutViewTest do 2 | use PhlinkWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /lib/phlink_web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 |

Sign in with github to get started

2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /assets/css/_custom.scss: -------------------------------------------------------------------------------- 1 | /* Fontawesome 5 config */ 2 | $fa-font-path: "~@fortawesome/fontawesome-free/webfonts"; 3 | 4 | /* my tweaks */ 5 | 6 | body { 7 | padding-top: 90px; 8 | } 9 | 10 | .footer { 11 | padding-top: 40px; 12 | } 13 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150424071208_add_user_id_to_links.exs: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Repo.Migrations.AddUserIdToLinks do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:links) do 6 | add :user_id, references(:users) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150425221224_add_avatar_url_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Repo.Migrations.AddAvatarUrlToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :avatar_url, :string 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # See releases.exs for configuration that uses env variables 4 | 5 | # Do not print debug messages in production 6 | config :logger, level: :info 7 | 8 | # Start the listeners in production 9 | config :phoenix, serve_endpoints: true 10 | -------------------------------------------------------------------------------- /lib/phlink.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink do 2 | @moduledoc """ 3 | Phlink keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /rel/env.bat.eex: -------------------------------------------------------------------------------- 1 | @echo off 2 | rem Set the release to work across nodes. If using the long name format like 3 | rem the one below (my_app@127.0.0.1), you need to also uncomment the 4 | rem RELEASE_DISTRIBUTION variable below. 5 | rem set RELEASE_DISTRIBUTION=name 6 | rem set RELEASE_NODE=<%= @release.name %>@127.0.0.1 7 | -------------------------------------------------------------------------------- /lib/phlink_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.LayoutView do 2 | use PhlinkWeb, :view 3 | 4 | # Use unquote as the values are available at compile time so there's no need 5 | # to read them every request 6 | def elixir_version, do: unquote(System.version()) 7 | def phoenix_version, do: unquote(Application.spec(:phoenix, :vsn)) 8 | end 9 | -------------------------------------------------------------------------------- /lib/phlink_web/templates/link/show.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

3 | <%= Routes.link_url(@conn, :unshorten, @link.shortcode) %> 4 |

5 |

6 | <%= @link.url %> 7 |

8 |
9 | -------------------------------------------------------------------------------- /assets/css/app.scss: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | 3 | @import "custom"; 4 | @import "~bootstrap/scss/bootstrap"; 5 | @import "~@fortawesome/fontawesome-free/scss/fontawesome"; 6 | @import "~@fortawesome/fontawesome-free/scss/brands"; 7 | @import "~@fortawesome/fontawesome-free/scss/solid"; 8 | @import "~@fortawesome/fontawesome-free/scss/regular"; 9 | -------------------------------------------------------------------------------- /lib/phlink/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Repo do 2 | use Ecto.Repo, 3 | otp_app: :phlink, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | @doc """ 7 | Dynamically loads the repository url from the 8 | DATABASE_URL environment variable. 9 | """ 10 | def init(_, opts) do 11 | {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150423132140_create_user.exs: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Repo.Migrations.CreateUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :name, :string 7 | add :github_id, :integer 8 | add :github_user, :map 9 | 10 | timestamps() 11 | end 12 | 13 | create index(:users, [:github_id], unique: true) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /rel/vm.args.eex: -------------------------------------------------------------------------------- 1 | ## Customize flags given to the VM: http://erlang.org/doc/man/erl.html 2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here 3 | 4 | ## Number of dirty schedulers doing IO work (file, sockets, etc) 5 | ##+SDio 5 6 | 7 | ## Increase number of concurrent ports/sockets 8 | ##+Q 65536 9 | 10 | ## Tweak GC to run more often 11 | ##-env ERL_FULLSWEEP_AFTER 10 12 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Phlink.Repo.insert!(%Phlink.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /lib/phlink/cache/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Cache.Supervisor do 2 | use Supervisor 3 | 4 | def start_link do 5 | Supervisor.start_link(__MODULE__, []) 6 | end 7 | 8 | def init([]) do 9 | children = [ 10 | worker(Phlink.Cache.Mapper, []), 11 | supervisor(Phlink.Cache.UrlCacheSupervisor, []) 12 | ] 13 | 14 | supervise(children, strategy: :rest_for_one) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150411194321_create_link.exs: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Repo.Migrations.CreateLink do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:links) do 6 | add :url, :text 7 | add :shortcode, :string 8 | 9 | timestamps() 10 | end 11 | 12 | create index(:links, [:url], unique: true) 13 | create index(:links, [:shortcode], unique: true) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/phlink/shortcode.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Shortcode do 2 | @moduledoc """ 3 | Shortcode Generation 4 | """ 5 | 6 | @doc """ 7 | Generate shortcode by creating v5 UUID, passing it through `phash2` to create 8 | an integer and converts that to a hex string. 9 | """ 10 | def generate(url) do 11 | UUID.uuid5(:url, url, :hex) 12 | |> :erlang.phash2() 13 | |> Integer.to_string(16) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/git_hub/test.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.GitHub.Test do 2 | def authorize_url!(_params \\ []) do 3 | "http://example.com/test" 4 | end 5 | 6 | def get_user(_code) do 7 | github_user() 8 | end 9 | 10 | def github_user do 11 | %{ 12 | "login" => "chrismcg", 13 | "id" => 212, 14 | "avatar_url" => "https://avatars.githubusercontent.com/u/212?v=3", 15 | "name" => "Chris McGrath" 16 | } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/phlink/cache/url_cache_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Cache.UrlCacheSupervisor do 2 | use Supervisor 3 | 4 | def start_link do 5 | Supervisor.start_link(__MODULE__, [], name: __MODULE__) 6 | end 7 | 8 | def start_child(url) do 9 | Supervisor.start_child(__MODULE__, [url]) 10 | end 11 | 12 | def init([]) do 13 | children = [ 14 | worker(Phlink.Cache.UrlCache, []) 15 | ] 16 | 17 | supervise(children, strategy: :simple_one_for_one) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/phlink_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.ErrorViewTest do 2 | use PhlinkWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(PhlinkWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(PhlinkWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/phlink_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.PageController do 2 | @moduledoc """ 3 | Show the homepage 4 | """ 5 | use PhlinkWeb, :controller 6 | 7 | @doc """ 8 | If the user isn't logged in then display a login link. 9 | 10 | If they are logged in redirect to the new link form. 11 | """ 12 | def index(conn, _params) do 13 | case conn.assigns[:current_user] do 14 | nil -> render(conn, "index.html") 15 | _ -> redirect(conn, to: Routes.link_path(conn, :new)) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /config/releases.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phlink, PhlinkWeb.Endpoint, 4 | http: [:inet6, port: String.to_integer(System.get_env("PORT") || "4000")], 5 | url: [host: "phl.ink", port: 80], 6 | cache_static_manifest: "priv/static/cache_manifest.json", 7 | secret_key_base: Map.fetch!(System.get_env(), "SECRET_KEY_BASE"), 8 | root: "." 9 | 10 | # Configure database via URL 11 | config :phlink, Phlink.Repo, 12 | url: System.get_env("DATABASE_URL"), 13 | ssl: true, 14 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "18") 15 | -------------------------------------------------------------------------------- /lib/phlink/cache/url_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Cache.UrlCache do 2 | use GenServer 3 | 4 | @cache_timeout 5 * 60 * 1000 5 | 6 | def start_link(url) do 7 | GenServer.start_link(__MODULE__, url) 8 | end 9 | 10 | def url(pid) do 11 | GenServer.call(pid, :url) 12 | end 13 | 14 | def init(url) do 15 | {:ok, url, @cache_timeout} 16 | end 17 | 18 | def handle_call(:url, _from, url) do 19 | {:reply, url, url, @cache_timeout} 20 | end 21 | 22 | def handle_info(:timeout, url) do 23 | {:stop, :normal, url} 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | import css from "../css/app.scss" 5 | 6 | // webpack automatically bundles all modules in your 7 | // entry points. Those entry points can be configured 8 | // in "webpack.config.js". 9 | // 10 | // Import dependencies 11 | // 12 | import "phoenix_html" 13 | 14 | // Import local files 15 | // 16 | // Local files can be imported directly using relative paths, for example: 17 | // import socket from "./socket" 18 | -------------------------------------------------------------------------------- /lib/phlink_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.ErrorView do 2 | use PhlinkWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /rel/env.sh.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Sets and enables heart (recommended only in daemon mode) 4 | # case $RELEASE_COMMAND in 5 | # daemon*) 6 | # HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND" 7 | # export HEART_COMMAND 8 | # export ELIXIR_ERL_OPTIONS="-heart" 9 | # ;; 10 | # *) 11 | # ;; 12 | # esac 13 | 14 | # Set the release to work across nodes. If using the long name format like 15 | # the one below (my_app@127.0.0.1), you need to also uncomment the 16 | # RELEASE_DISTRIBUTION variable below. 17 | # export RELEASE_DISTRIBUTION=name 18 | # export RELEASE_NODE=<%= @release.name %>@127.0.0.1 19 | -------------------------------------------------------------------------------- /test/phlink_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.PageControllerTest do 2 | use PhlinkWeb.ConnCase 3 | 4 | @current_user %{ 5 | id: 212, 6 | avatar_url: "https://avatars.githubusercontent.com/u/212?v=3", 7 | name: "Chris McGrath" 8 | } 9 | 10 | test "GET / displays homepage when user not logged in" do 11 | assert build_conn() 12 | |> get("/") 13 | |> html_response(200) 14 | end 15 | 16 | test "GET / redirects to new url page when user logged in" do 17 | assert build_conn() 18 | |> assign(:current_user, @current_user) 19 | |> get("/") 20 | |> redirected_to() == "/shorten/new" 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/phlink_web/templates/link/form.html.eex: -------------------------------------------------------------------------------- 1 | <%= form_for @changeset, @action, fn f -> %> 2 | <%= if @changeset.errors != [] do %> 3 |
4 |

Oops, something went wrong! Please check the errors below:

5 | 10 |
11 | <% end %> 12 | 13 |
14 |
15 | <%= text_input f, :url, class: "form-control", placeholder: "URL" %> 16 |
17 |
18 | <%= submit "Shorten!", class: "btn btn-primary btn-large" %> 19 |
20 |
21 | <% end %> 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phl.ink 2 | 3 | Phlink is a URL shortener implemented with Elixir and the Phoenix framework. 4 | 5 | It's purpose is to be a simple problem domain I can use when learning how to 6 | develop, deploy, and maintain Phoenix and Elixir applications. 7 | 8 | It's currently deployed to Heroku at [http://phl.ink](http://phl.ink). 9 | 10 | ## Running 11 | 12 | To start your Phoenix server: 13 | 14 | * Install dependencies with `mix deps.get` 15 | * Create and migrate your database with `mix ecto.setup` 16 | * Install Node.js dependencies with `cd assets && npm install` 17 | * Start Phoenix endpoint with `mix phx.server` 18 | 19 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 20 | To start your new Phoenix application: 21 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :phlink, PhlinkWeb.Endpoint, 6 | http: [port: 4002], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | # uncomment below to get some more debug messages if required 12 | # config :logger, level: :warn, handle_otp_reports: true, handle_sasl_reports: true 13 | 14 | # Configure your database 15 | config :phlink, Phlink.Repo, 16 | username: "postgres", 17 | password: "postgres", 18 | database: "phlink_test", 19 | hostname: System.get_env("DB_HOST", "localhost"), 20 | pool: Ecto.Adapters.SQL.Sandbox 21 | 22 | config :phlink, :github_api, Phlink.GitHub.Test 23 | -------------------------------------------------------------------------------- /lib/phlink/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.User do 2 | @moduledoc """ 3 | Stores the user details 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | 8 | schema "users" do 9 | field :name, :string 10 | field :github_id, :integer 11 | field :avatar_url, :string 12 | field :github_user, :map 13 | 14 | timestamps() 15 | end 16 | 17 | @required_fields ~w(name github_id avatar_url github_user)a 18 | 19 | @doc """ 20 | Creates a changeset based on the `model` and `params`. 21 | 22 | If `params` are nil, an invalid changeset is returned 23 | with no validation performed. 24 | """ 25 | def changeset(model, params \\ nil) do 26 | model 27 | |> cast(params, @required_fields) 28 | |> validate_required(@required_fields) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/phlink/user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phlink.UserTest do 2 | use Phlink.DataCase 3 | 4 | alias Phlink.User 5 | 6 | @valid_attrs %{ 7 | name: "some content", 8 | github_id: 212, 9 | avatar_url: "https://avatars.githubusercontent.com/u/212?v=3", 10 | github_user: %{ 11 | "login" => "chrismcg", 12 | "id" => 212, 13 | "avatar_url" => "https://avatars.githubusercontent.com/u/212?v=3", 14 | "name" => "Chris McGrath" 15 | } 16 | } 17 | @invalid_attrs %{} 18 | 19 | test "changeset with valid attributes" do 20 | changeset = User.changeset(%User{}, @valid_attrs) 21 | assert changeset.valid? 22 | end 23 | 24 | test "changeset with invalid attributes" do 25 | changeset = User.changeset(%User{}, @invalid_attrs) 26 | refute changeset.valid? 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/phlink_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import PhlinkWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :phlink 24 | end 25 | -------------------------------------------------------------------------------- /lib/phlink/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Cache do 2 | @moduledoc """ 3 | External API for the shortcode to URL cache 4 | """ 5 | alias Phlink.Cache 6 | 7 | @doc """ 8 | Returns the url for the shortcode or nil if it's not a valid shortcode. 9 | 10 | If the shortcode isn't in the cache it will be placed there for 5 minutes 11 | """ 12 | @spec get_url(binary) :: binary | none 13 | def get_url(shortcode) do 14 | GenServer.call(Cache.Mapper, {:get_url, shortcode}) 15 | end 16 | 17 | @doc """ 18 | Returns the pid for the process caching the shortcode or nil if it's not a 19 | valid shortcode. 20 | 21 | If the shortcode isn't in the cache it will be placed there for 5 minutes 22 | """ 23 | @spec warm(binary) :: binary | none 24 | def warm(shortcode) do 25 | GenServer.call(Cache.Mapper, {:warm, shortcode}) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | container: 10 | image: elixir:1.9.1-slim 11 | env: 12 | MIX_ENV: test 13 | 14 | services: 15 | postgres: 16 | image: postgres 17 | ports: 18 | - 5432:5432 19 | env: 20 | POSTGRES_PASSWORD: postgres 21 | POSTGRES_USER: postgres 22 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 23 | 24 | steps: 25 | - uses: actions/checkout@v1 26 | - name: Install Dependencies 27 | run: | 28 | mix local.rebar --force 29 | mix local.hex --force 30 | mix deps.get 31 | - name: Run Tests 32 | run: mix test 33 | env: 34 | DB_HOST: postgres 35 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "deploy": "webpack --mode production", 6 | "watch": "webpack --mode development --watch" 7 | }, 8 | "dependencies": { 9 | "phoenix": "file:../deps/phoenix", 10 | "phoenix_html": "file:../deps/phoenix_html" 11 | }, 12 | "devDependencies": { 13 | "@babel/core": "^7.12.3", 14 | "@babel/preset-env": "^7.12.1", 15 | "@fortawesome/fontawesome-free": "^5.15.1", 16 | "autoprefixer": "^9.8.6", 17 | "babel-loader": "^8.1.0", 18 | "bootstrap": "4.5.0", 19 | "copy-webpack-plugin": "^6.2.1", 20 | "css-loader": "^5.0.0", 21 | "file-loader": "^6.2.0", 22 | "jquery": "^3.5.1", 23 | "mini-css-extract-plugin": "^1.2.1", 24 | "node-sass": "4.14.1", 25 | "optimize-css-assets-webpack-plugin": "^5.0.4", 26 | "popper.js": "^1.16.0", 27 | "postcss-loader": "^3.0.0", 28 | "sass-loader": "^10.0.4", 29 | "uglifyjs-webpack-plugin": "^2.2.0", 30 | "webpack": "4.44.2", 31 | "webpack-cli": "^4.1.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint PhlinkWeb.Endpoint 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Phlink.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(Phlink.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/phlink/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | import Supervisor.Spec 10 | # TODO: Convert to child specs 11 | 12 | # Define workers and child supervisors to be supervised 13 | children = [ 14 | # Start the Ecto repository 15 | supervisor(Phlink.Repo, []), 16 | # Start the endpoint when the application starts 17 | supervisor(PhlinkWeb.Endpoint, []), 18 | # Start the cache 19 | supervisor(Phlink.Cache.Supervisor, []) 20 | ] 21 | 22 | # See https://hexdocs.pm/elixir/Supervisor.html 23 | # for other strategies and supported options 24 | opts = [strategy: :one_for_one, name: Phlink.Supervisor] 25 | Supervisor.start_link(children, opts) 26 | end 27 | 28 | # Tell Phoenix to update the endpoint configuration 29 | # whenever the application is updated. 30 | def config_change(changed, _new, removed) do 31 | PhlinkWeb.Endpoint.config_change(changed, removed) 32 | :ok 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | use Mix.Config 9 | 10 | config :phlink, 11 | ecto_repos: [Phlink.Repo] 12 | 13 | # Configures the endpoint 14 | config :phlink, PhlinkWeb.Endpoint, 15 | url: [host: "localhost"], 16 | secret_key_base: "HOHPGn11S+1dDx+zMcOye6TFICJKxTEwQRTCfHLDmXrxt2DLanXQdvEIwyxIESFw", 17 | render_errors: [view: PhlinkWeb.ErrorView, accepts: ~w(html json)], 18 | pubsub: [name: Phlink.PubSub, adapter: Phoenix.PubSub.PG2] 19 | 20 | # Configures Elixir's Logger 21 | config :logger, :console, 22 | format: "$time $metadata[$level] $message\n", 23 | metadata: [:request_id] 24 | 25 | # Use Jason for JSON parsing in Phoenix 26 | config :phoenix, :json_library, Jason 27 | 28 | config :phlink, :github_api, GitHub 29 | 30 | # Import environment specific config. This must remain at the bottom 31 | # of this file so it overrides the configuration defined above. 32 | import_config "#{Mix.env()}.exs" 33 | -------------------------------------------------------------------------------- /lib/phlink_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", PhlinkWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | def connect(_params, socket, _connect_info) do 19 | {:ok, socket} 20 | end 21 | 22 | # Socket id's are topics that allow you to identify all sockets for a given user: 23 | # 24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 25 | # 26 | # Would allow you to broadcast a "disconnect" event and terminate 27 | # all active sockets and channels for a given user: 28 | # 29 | # PhlinkWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 30 | # 31 | # Returning `nil` makes this socket anonymous. 32 | def id(_socket), do: nil 33 | end 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | phlink-*.tar 24 | 25 | # If NPM crashes, it generates a log, let's ignore it too. 26 | npm-debug.log 27 | 28 | # The directory NPM downloads your dependencies sources to. 29 | /assets/node_modules/ 30 | 31 | # Since we are building assets from assets/, 32 | # we ignore priv/static. You may want to comment 33 | # this depending on your deployment strategy. 34 | /priv/static/ 35 | 36 | # Files matching config/*.secret.exs pattern contain sensitive 37 | # data and you should not commit them into version control. 38 | # 39 | # Alternatively, you may comment the line below and commit the 40 | # secrets files as long as you replace their contents by environment 41 | # variables. 42 | /config/*.secret.exs 43 | 44 | /.env 45 | -------------------------------------------------------------------------------- /lib/phlink_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :phlink 3 | 4 | socket "/socket", PhlinkWeb.UserSocket, 5 | websocket: true, 6 | longpoll: false 7 | 8 | # Serve at "/" the static files from "priv/static" directory. 9 | # 10 | # You should set gzip to true if you are running phx.digest 11 | # when deploying your static files in production. 12 | plug Plug.Static, 13 | at: "/", 14 | from: :phlink, 15 | gzip: true, 16 | only: ~w(css fonts images js favicon.ico robots.txt) 17 | 18 | # Code reloading can be explicitly enabled under the 19 | # :code_reloader configuration of your endpoint. 20 | if code_reloading? do 21 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 22 | plug Phoenix.LiveReloader 23 | plug Phoenix.CodeReloader 24 | end 25 | 26 | plug Plug.RequestId 27 | plug Plug.Logger 28 | 29 | plug Plug.Parsers, 30 | parsers: [:urlencoded, :multipart, :json], 31 | pass: ["*/*"], 32 | json_decoder: Phoenix.json_library() 33 | 34 | plug Plug.MethodOverride 35 | plug Plug.Head 36 | 37 | # The session will be stored in the cookie and signed, 38 | # this means its contents can be read but not tampered with. 39 | # Set :encryption_salt if you would also like to encrypt it. 40 | plug Plug.Session, 41 | store: :cookie, 42 | key: "_phlink_key", 43 | signing_salt: "U9/UNcn1" 44 | 45 | plug PhlinkWeb.Router 46 | end 47 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | alias PhlinkWeb.Router.Helpers, as: Routes 23 | 24 | # TODO: See if this is still needed or can be replaced with context calls 25 | alias Phlink.Repo 26 | import Ecto 27 | import Ecto.Changeset 28 | import Ecto.Query, only: [from: 1, from: 2] 29 | 30 | # The default endpoint for testing 31 | @endpoint PhlinkWeb.Endpoint 32 | 33 | # Model aliases 34 | alias Phlink.Link 35 | alias Phlink.User 36 | end 37 | end 38 | 39 | setup tags do 40 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Phlink.Repo) 41 | 42 | unless tags[:async] do 43 | Ecto.Adapters.SQL.Sandbox.mode(Phlink.Repo, {:shared, self()}) 44 | end 45 | 46 | {:ok, conn: Phoenix.ConnTest.build_conn()} 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/phlink/release_tasks.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.ReleaseTasks do 2 | @start_apps [ 3 | :crypto, 4 | :ssl, 5 | :postgrex, 6 | :ecto, 7 | :ecto_sql # If using Ecto 3.0 or higher 8 | ] 9 | 10 | @repos Application.get_env(:phlink, :ecto_repos, []) 11 | 12 | def migrate(_argv) do 13 | start_services() 14 | 15 | run_migrations() 16 | 17 | stop_services() 18 | end 19 | 20 | defp start_services do 21 | IO.puts("Starting dependencies..") 22 | # Start apps necessary for executing migrations 23 | Enum.each(@start_apps, &Application.ensure_all_started/1) 24 | 25 | # Start the Repo(s) for app 26 | IO.puts("Starting repos..") 27 | 28 | Enum.each(@repos, & &1.start_link(pool_size: 2)) 29 | end 30 | 31 | defp stop_services do 32 | IO.puts("Success!") 33 | :init.stop() 34 | end 35 | 36 | defp run_migrations do 37 | Enum.each(@repos, &run_migrations_for/1) 38 | end 39 | 40 | defp run_migrations_for(repo) do 41 | app = Keyword.get(repo.config, :otp_app) 42 | IO.puts("Running migrations for #{app}") 43 | migrations_path = priv_path_for(repo, "migrations") 44 | Ecto.Migrator.run(repo, migrations_path, :up, all: true) 45 | end 46 | 47 | defp priv_path_for(repo, filename) do 48 | app = Keyword.get(repo.config, :otp_app) 49 | 50 | repo_underscore = 51 | repo 52 | |> Module.split() 53 | |> List.last() 54 | |> Macro.underscore() 55 | 56 | priv_dir = "#{:code.priv_dir(app)}" 57 | 58 | Path.join([priv_dir, repo_underscore, filename]) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias Phlink.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import Phlink.DataCase 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Phlink.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(Phlink.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | @doc """ 39 | A helper that transforms changeset errors into a map of messages. 40 | 41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 42 | assert "password is too short" in errors_on(changeset).password 43 | assert %{password: ["password is too short"]} = errors_on(changeset) 44 | 45 | """ 46 | def errors_on(changeset) do 47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 48 | Enum.reduce(opts, message, fn {key, value}, acc -> 49 | String.replace(acc, "%{#{key}}", to_string(value)) 50 | end) 51 | end) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/phlink_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), class: "help-block") 14 | end) 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # When using gettext, we typically pass the strings we want 22 | # to translate as a static argument: 23 | # 24 | # # Translate "is invalid" in the "errors" domain 25 | # dgettext("errors", "is invalid") 26 | # 27 | # # Translate the number of files with plural rules 28 | # dngettext("errors", "1 file", "%{count} files", count) 29 | # 30 | # Because the error messages we show in our forms and APIs 31 | # are defined inside Ecto, we need to translate them dynamically. 32 | # This requires us to call the Gettext module passing our gettext 33 | # backend as first argument. 34 | # 35 | # Note we use the "errors" domain, which means translations 36 | # should be written to the errors.po file. The :count option is 37 | # set by Ecto and indicates we should also apply plural rules. 38 | if count = opts[:count] do 39 | Gettext.dngettext(PhlinkWeb.Gettext, "errors", msg, msg, count, opts) 40 | else 41 | Gettext.dgettext(PhlinkWeb.Gettext, "errors", msg, opts) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/phlink/link.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Link do 2 | @moduledoc """ 3 | Stores the url and it's shortcode. Associated to the user that created the 4 | link. 5 | """ 6 | use Ecto.Schema 7 | import Ecto.Changeset 8 | alias Phlink.Shortcode 9 | alias __MODULE__ 10 | 11 | schema "links" do 12 | field :url, :string 13 | field :shortcode, :string 14 | belongs_to :user, Phlink.User 15 | 16 | timestamps() 17 | end 18 | 19 | @required_fields ~w(url user_id)a 20 | 21 | def new do 22 | cast(%Link{}, %{}, []) 23 | end 24 | 25 | @doc """ 26 | Creates a changeset based on the `model` and `params`. 27 | 28 | If `params` are nil, an invalid changeset is returned 29 | with no validation performed. 30 | 31 | Generates the shortcode for the url. As the shortcode generation will create 32 | the same shortcode for a given url there's no need to check if we're creating 33 | or updating the record. 34 | """ 35 | def changeset(model, params \\ :invalid) do 36 | changeset = 37 | model 38 | |> cast(params, @required_fields) 39 | |> validate_required(@required_fields) 40 | 41 | changeset = 42 | case get_field(changeset, :url) do 43 | nil -> changeset 44 | url -> change(changeset, %{shortcode: Shortcode.generate(url)}) 45 | end 46 | 47 | changeset 48 | |> unique_constraint(:shortcode) 49 | |> validate_url(:url) 50 | end 51 | 52 | defp validate_url(changeset, field) do 53 | validate_change(changeset, field, fn field, url -> 54 | case :http_uri.parse(String.to_charlist(url)) do 55 | {:ok, _} -> [] 56 | {:error, _} -> [{field, "is not a url"}] 57 | end 58 | end) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/phlink_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | phl.ink 11 | "> 12 | 13 | 14 | 15 |
16 | 35 | 36 | <%= render @view_module, @view_template, assigns %> 37 | 38 | 41 |
42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /lib/phlink_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.Router do 2 | use PhlinkWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_flash 8 | plug :protect_from_forgery 9 | plug :put_secure_browser_headers 10 | plug :assign_current_user 11 | end 12 | 13 | pipeline :unshorten do 14 | plug :accepts, ["html"] 15 | end 16 | 17 | pipeline :authentication do 18 | plug :redirect_if_not_logged_in 19 | end 20 | 21 | scope "/auth", PhlinkWeb do 22 | pipe_through :browser 23 | 24 | get "/", AuthController, :index 25 | get "/callback", AuthController, :callback 26 | end 27 | 28 | scope "/", PhlinkWeb do 29 | pipe_through :unshorten 30 | get "/:shortcode", LinkController, :unshorten 31 | end 32 | 33 | scope "/", PhlinkWeb do 34 | pipe_through :browser 35 | get "/", PageController, :index 36 | end 37 | 38 | scope "/shorten", PhlinkWeb do 39 | pipe_through [:browser, :authentication] 40 | 41 | get "/new", LinkController, :new 42 | get "/:id", LinkController, :show 43 | post "/", LinkController, :create 44 | end 45 | 46 | defp assign_current_user(conn, _) do 47 | # we assign a user in tests so we don't have to mess with the session 48 | case conn.assigns[:current_user] do 49 | nil -> assign(conn, :current_user, get_session(conn, :current_user)) 50 | _ -> conn 51 | end 52 | end 53 | 54 | defp redirect_if_not_logged_in(conn, _) do 55 | case conn.assigns[:current_user] do 56 | nil -> 57 | conn 58 | |> put_flash(:error, "Please login") 59 | |> redirect(to: "/") 60 | |> halt() 61 | 62 | _ -> 63 | conn 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/git_hub.ex: -------------------------------------------------------------------------------- 1 | defmodule GitHub do 2 | @moduledoc """ 3 | An OAuth2 strategy for GitHub. 4 | 5 | Taken from https://github.com/scrogson/oauth2_example/blob/master/web/oauth/github.ex 6 | """ 7 | use OAuth2.Strategy 8 | 9 | import PhlinkWeb.Router.Helpers 10 | 11 | # TODO: Put the github config in application config so that all happens in one place 12 | def client do 13 | OAuth2.Client.new( 14 | strategy: __MODULE__, 15 | client_id: System.get_env("GITHUB_CLIENT_ID"), 16 | client_secret: System.get_env("GITHUB_CLIENT_SECRET"), 17 | redirect_uri: auth_url(PhlinkWeb.Endpoint, :callback), 18 | site: "https://api.github.com", 19 | authorize_url: "https://github.com/login/oauth/authorize", 20 | token_url: "https://github.com/login/oauth/access_token" 21 | ) 22 | end 23 | 24 | @doc """ 25 | URL for GitHub OAuth with minimum permissions 26 | """ 27 | def authorize_url! do 28 | OAuth2.Client.authorize_url!(client(), scope: "") 29 | end 30 | 31 | def get_token!(params \\ [], headers \\ [], opts \\ []) do 32 | OAuth2.Client.get_token!(client(), params, headers, opts) 33 | end 34 | 35 | @doc """ 36 | Fetch the GitHub user details given an authorization code 37 | """ 38 | def get_user(code) do 39 | %{status_code: 200, body: github_user} = 40 | GitHub.get_token!(code: code) 41 | |> OAuth2.Client.get!("/user") 42 | 43 | github_user 44 | end 45 | 46 | # Strategy Callbacks 47 | 48 | def authorize_url(client, params) do 49 | OAuth2.Strategy.AuthCode.authorize_url(client, params) 50 | end 51 | 52 | def get_token(client, params, headers) do 53 | client 54 | |> put_param(:client_secret, client.client_secret) 55 | |> put_header("Accept", "application/json") 56 | |> OAuth2.Client.put_serializer("application/json", Jason) 57 | |> OAuth2.Strategy.AuthCode.get_token(params, headers) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/phlink_web/controllers/auth_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.AuthControllerTest do 2 | use PhlinkWeb.ConnCase 3 | 4 | test "GET /auth redirects to github" do 5 | assert build_conn() 6 | |> get("/auth") 7 | |> redirected_to() == Phlink.GitHub.Test.authorize_url!() 8 | end 9 | 10 | test "GET /auth/callback?code= puts the current user in the session" do 11 | conn = build_conn() |> get("/auth/callback?code=test") 12 | assert redirected_to(conn) == "/" 13 | 14 | current_user = get_session(conn, :current_user) 15 | user = Repo.one!(from u in User, select: u) 16 | 17 | assert current_user.id == user.id 18 | assert current_user.name == Phlink.GitHub.Test.github_user()["name"] 19 | assert current_user.avatar_url == Phlink.GitHub.Test.github_user()["avatar_url"] 20 | end 21 | 22 | test "GET /auth/callback?code= creates a user if they're not already in the db" do 23 | assert user_count() == 0 24 | build_conn() |> get("/auth/callback?code=test") 25 | assert user_count() == 1 26 | 27 | user = Repo.one!(from u in User, select: u) 28 | 29 | assert user.name == "Chris McGrath" 30 | assert user.github_id == Phlink.GitHub.Test.github_user()["id"] 31 | assert user.avatar_url == Phlink.GitHub.Test.github_user()["avatar_url"] 32 | assert user.github_user == Phlink.GitHub.Test.github_user() 33 | end 34 | 35 | test "GET /auth/callback?code= uses the existing user if their github id is already in the db" do 36 | user = 37 | Repo.insert!(%User{ 38 | name: "Test User", 39 | github_id: Phlink.GitHub.Test.github_user()["id"], 40 | github_user: Phlink.GitHub.Test.github_user() 41 | }) 42 | 43 | current_user = 44 | build_conn() 45 | |> get("/auth/callback?code=test") 46 | |> get_session(:current_user) 47 | 48 | assert current_user.id == user.id 49 | end 50 | 51 | defp user_count do 52 | Repo.one(from(u in User, select: count(u.id))) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/phlink_web/controllers/auth_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.AuthController do 2 | @moduledoc """ 3 | Handle OAuth to GitHub 4 | """ 5 | use PhlinkWeb, :controller 6 | alias Phlink.User 7 | 8 | @doc """ 9 | Take the user to github to authorize phl.ink and login 10 | """ 11 | def index(conn, _params) do 12 | redirect(conn, external: github().authorize_url!) 13 | end 14 | 15 | @doc """ 16 | Try and find the user by thier GitHub id. Creates a new user record if we 17 | haven't seen them before. Add user details to the session once they're 18 | logged in. 19 | """ 20 | def callback(conn, %{"code" => code}) do 21 | github_user = github().get_user(code) 22 | 23 | %{ 24 | "name" => name, 25 | "id" => github_id, 26 | "avatar_url" => avatar_url 27 | } = github_user 28 | 29 | user = get_user_from_github_id(github_id) 30 | 31 | conn 32 | |> handle_callback(user, name, github_id, avatar_url, github_user) 33 | |> redirect(to: "/") 34 | end 35 | 36 | defp handle_callback(conn, nil, name, github_id, avatar_url, github_user) do 37 | changeset = 38 | User.changeset(%User{}, %{ 39 | name: name, 40 | github_id: github_id, 41 | avatar_url: avatar_url, 42 | github_user: github_user 43 | }) 44 | 45 | if changeset.valid? do 46 | user = Repo.insert!(changeset) 47 | put_user_in_session(conn, user) 48 | else 49 | conn 50 | |> put_flash(:error, "Couldn't login with GitHub :(") 51 | end 52 | end 53 | 54 | defp handle_callback(conn, user, _name, _github_id, _avatar_url, _github_user) do 55 | put_user_in_session(conn, user) 56 | end 57 | 58 | defp put_user_in_session(conn, user) do 59 | conn 60 | |> put_session(:current_user, %{ 61 | id: user.id, 62 | name: user.name, 63 | avatar_url: user.avatar_url 64 | }) 65 | end 66 | 67 | defp github do 68 | Application.get_env(:phlink, :github_api) 69 | end 70 | 71 | defp get_user_from_github_id(github_id) do 72 | Repo.one(from u in User, where: u.github_id == ^github_id) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | 8 | module.exports = (env, options) => ({ 9 | optimization: { 10 | minimizer: [ 11 | new UglifyJsPlugin({ cache: true, parallel: true, sourceMap: false }), 12 | new OptimizeCSSAssetsPlugin({}) 13 | ] 14 | }, 15 | entry: { 16 | './js/app.js': ['./js/app.js'].concat(glob.sync('./vendor/**/*.js')) 17 | }, 18 | output: { 19 | filename: 'app.js', 20 | path: path.resolve(__dirname, '../priv/static/js') 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.js$/, 26 | exclude: /node_modules/, 27 | use: { 28 | loader: 'babel-loader' 29 | } 30 | }, 31 | { 32 | test: /\.(css|sass|scss)$/, 33 | use: [ 34 | MiniCssExtractPlugin.loader, { 35 | loader: 'css-loader', 36 | options: { 37 | importLoaders: 2, 38 | sourceMap: true 39 | } 40 | }, 41 | { 42 | loader: 'postcss-loader', 43 | options: { 44 | plugins: () => [ 45 | require('autoprefixer') 46 | ], 47 | sourceMap: true 48 | } 49 | }, 50 | { 51 | loader: 'sass-loader', 52 | options: { 53 | sourceMap: true 54 | } 55 | } 56 | ] 57 | }, 58 | { 59 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/, 60 | use: [{ 61 | loader: 'file-loader', 62 | options: { 63 | name: '[name].[ext]', 64 | outputPath: '../fonts' 65 | } 66 | }] 67 | } 68 | ] 69 | }, 70 | plugins: [ 71 | new MiniCssExtractPlugin({ filename: '../css/app.css' }), 72 | new CopyWebpackPlugin({patterns: [{ from: 'static/', to: '../' }]}) 73 | ] 74 | }); 75 | -------------------------------------------------------------------------------- /lib/phlink_web.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use PhlinkWeb, :controller 9 | use PhlinkWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: PhlinkWeb 23 | 24 | import Plug.Conn 25 | import PhlinkWeb.Gettext 26 | alias PhlinkWeb.Router.Helpers, as: Routes 27 | 28 | # TODO: see if I still need this or can replace with context 29 | alias Phlink.Repo 30 | import Ecto 31 | import Ecto.Query, only: [from: 1, from: 2] 32 | 33 | alias Phlink.User 34 | alias Phlink.Link 35 | alias Phlink.Cache 36 | end 37 | end 38 | 39 | def view do 40 | quote do 41 | use Phoenix.View, 42 | root: "lib/phlink_web/templates", 43 | namespace: PhlinkWeb 44 | 45 | # Import convenience functions from controllers 46 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1] 47 | 48 | # Use all HTML functionality (forms, tags, etc) 49 | use Phoenix.HTML 50 | 51 | import PhlinkWeb.ErrorHelpers 52 | import PhlinkWeb.Gettext 53 | alias PhlinkWeb.Router.Helpers, as: Routes 54 | end 55 | end 56 | 57 | def router do 58 | quote do 59 | use Phoenix.Router 60 | import Plug.Conn 61 | import Phoenix.Controller 62 | end 63 | end 64 | 65 | def channel do 66 | quote do 67 | use Phoenix.Channel 68 | import PhlinkWeb.Gettext 69 | 70 | # TODO: see if I still need this or can replace with context 71 | alias Phlink.Repo 72 | import Ecto 73 | import Ecto.Query, only: [from: 1, from: 2] 74 | end 75 | end 76 | 77 | @doc """ 78 | When used, dispatch to the appropriate controller/view/etc. 79 | """ 80 | defmacro __using__(which) when is_atom(which) do 81 | apply(__MODULE__, which, []) 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with webpack to recompile .js and .css sources. 9 | config :phlink, PhlinkWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [ 15 | node: [ 16 | "node_modules/webpack/bin/webpack.js", 17 | "--mode", 18 | "development", 19 | "--watch-stdin", 20 | cd: Path.expand("../assets", __DIR__) 21 | ] 22 | ] 23 | 24 | # ## SSL Support 25 | # 26 | # In order to use HTTPS in development, a self-signed 27 | # certificate can be generated by running the following 28 | # Mix task: 29 | # 30 | # mix phx.gen.cert 31 | # 32 | # Note that this task requires Erlang/OTP 20 or later. 33 | # Run `mix help phx.gen.cert` for more information. 34 | # 35 | # The `http:` config above can be replaced with: 36 | # 37 | # https: [ 38 | # port: 4001, 39 | # cipher_suite: :strong, 40 | # keyfile: "priv/cert/selfsigned_key.pem", 41 | # certfile: "priv/cert/selfsigned.pem" 42 | # ], 43 | # 44 | # If desired, both `http:` and `https:` keys can be 45 | # configured to run both http and https servers on 46 | # different ports. 47 | 48 | # Watch static and templates for browser reloading. 49 | config :phlink, PhlinkWeb.Endpoint, 50 | live_reload: [ 51 | patterns: [ 52 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 53 | ~r{priv/gettext/.*(po)$}, 54 | ~r{lib/phlink_web/views/.*(ex)$}, 55 | ~r{lib/phlink_web/templates/.*(eex)$} 56 | ] 57 | ] 58 | 59 | # Do not include metadata nor timestamps in development logs 60 | config :logger, :console, format: "[$level] $message\n" 61 | 62 | # Set a higher stacktrace during development. Avoid configuring such 63 | # in production as building large stacktraces may be expensive. 64 | config :phoenix, :stacktrace_depth, 20 65 | 66 | # Initialize plugs at runtime for faster development compilation 67 | config :phoenix, :plug_init_mode, :runtime 68 | 69 | # Configure your database 70 | config :phlink, Phlink.Repo, 71 | username: "postgres", 72 | password: "postgres", 73 | database: "phlink_dev", 74 | hostname: "localhost", 75 | pool_size: 10 76 | 77 | config :phlink, :github_api, GitHub 78 | -------------------------------------------------------------------------------- /assets/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "assets/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket, 5 | // and connect at the socket path in "lib/web/endpoint.ex". 6 | // 7 | // Pass the token on params as below. Or remove it 8 | // from the params if you are not using authentication. 9 | import {Socket} from "phoenix" 10 | 11 | let socket = new Socket("/socket", {params: {token: window.userToken}}) 12 | 13 | // When you connect, you'll often need to authenticate the client. 14 | // For example, imagine you have an authentication plug, `MyAuth`, 15 | // which authenticates the session and assigns a `:current_user`. 16 | // If the current user exists you can assign the user's token in 17 | // the connection for use in the layout. 18 | // 19 | // In your "lib/web/router.ex": 20 | // 21 | // pipeline :browser do 22 | // ... 23 | // plug MyAuth 24 | // plug :put_user_token 25 | // end 26 | // 27 | // defp put_user_token(conn, _) do 28 | // if current_user = conn.assigns[:current_user] do 29 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 30 | // assign(conn, :user_token, token) 31 | // else 32 | // conn 33 | // end 34 | // end 35 | // 36 | // Now you need to pass this token to JavaScript. You can do so 37 | // inside a script tag in "lib/web/templates/layout/app.html.eex": 38 | // 39 | // 40 | // 41 | // You will need to verify the user token in the "connect/3" function 42 | // in "lib/web/channels/user_socket.ex": 43 | // 44 | // def connect(%{"token" => token}, socket, _connect_info) do 45 | // # max_age: 1209600 is equivalent to two weeks in seconds 46 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 47 | // {:ok, user_id} -> 48 | // {:ok, assign(socket, :user, user_id)} 49 | // {:error, reason} -> 50 | // :error 51 | // end 52 | // end 53 | // 54 | // Finally, connect to the socket: 55 | socket.connect() 56 | 57 | // Now that you are connected, you can join channels with a topic: 58 | let channel = socket.channel("topic:subtopic", {}) 59 | channel.join() 60 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 61 | .receive("error", resp => { console.log("Unable to join", resp) }) 62 | 63 | export default socket 64 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should be %{count} character(s)" 54 | msgid_plural "should be %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have %{count} item(s)" 59 | msgid_plural "should have %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at least %{count} character(s)" 64 | msgid_plural "should be at least %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at most %{count} character(s)" 74 | msgid_plural "should be at most %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should have at most %{count} item(s)" 79 | msgid_plural "should have at most %{count} item(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | ## From Ecto.Changeset.validate_number/3 84 | msgid "must be less than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than %{number}" 88 | msgstr "" 89 | 90 | msgid "must be less than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be greater than or equal to %{number}" 94 | msgstr "" 95 | 96 | msgid "must be equal to %{number}" 97 | msgstr "" 98 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Phlink.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :phlink, 7 | version: "0.0.1", 8 | elixir: "~> 1.5", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix, :gettext] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | test_coverage: [tool: ExCoveralls], 13 | preferred_cli_env: [ 14 | coveralls: :test, 15 | "coveralls.details": :test, 16 | "coveralls.post": :test 17 | ], 18 | name: "phl.ink", 19 | source_url: "https://github.com/chrismcg/phlink", 20 | homepage_url: "http://phl.ink", 21 | aliases: aliases(), 22 | deps: deps(), 23 | releases: releases() 24 | ] 25 | end 26 | 27 | # Configuration for the OTP application 28 | # 29 | # Type `mix help compile.app` for more information 30 | def application do 31 | [ 32 | mod: {Phlink.Application, []}, 33 | extra_applications: [:logger, :runtime_tools, :inets] 34 | ] 35 | end 36 | 37 | # Specifies which paths to compile per environment. 38 | defp elixirc_paths(:test), do: ["lib", "test/support"] 39 | defp elixirc_paths(_), do: ["lib"] 40 | 41 | # Specifies your project dependencies. 42 | # 43 | # Type `mix help deps` for examples and options 44 | defp deps do 45 | [ 46 | {:phoenix, "~> 1.4.0"}, 47 | {:phoenix_pubsub, "~> 1.0"}, 48 | {:ecto_sql, "~> 3.0"}, 49 | {:phoenix_ecto, "~> 4.0"}, 50 | {:postgrex, ">= 0.0.0"}, 51 | {:phoenix_html, "~> 2.10"}, 52 | {:phoenix_live_reload, "~> 1.0", only: :dev}, 53 | {:gettext, "~> 0.11"}, 54 | {:plug_cowboy, "~> 2.0"}, 55 | {:plug, "~> 1.7"}, 56 | {:jason, "~> 1.0"}, 57 | {:poison, "~> 4.0"}, 58 | {:uuid, "~> 1.1"}, 59 | {:oauth2, "~> 2.0"}, 60 | {:earmark, "~> 1.2", only: :dev}, 61 | {:ex_doc, "~> 0.16", only: :dev}, 62 | {:excoveralls, "~> 0.7", only: :test}, 63 | {:observer_cli, "~> 1.4"} 64 | ] 65 | end 66 | 67 | # Aliases are shortcuts or tasks specific to the current project. 68 | # For example, to create, migrate and run the seeds file at once: 69 | # 70 | # $ mix ecto.setup 71 | # 72 | # See the documentation for `Mix` for more info on aliases. 73 | defp aliases do 74 | [ 75 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 76 | "ecto.reset": ["ecto.drop", "ecto.setup"], 77 | test: ["ecto.create --quiet", "ecto.migrate", "test"] 78 | ] 79 | end 80 | 81 | defp releases do 82 | [ 83 | phlink: [ 84 | include_executables_for: [:unix] 85 | ] 86 | ] 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/phlink/cache/mapper.ex: -------------------------------------------------------------------------------- 1 | defmodule Phlink.Cache.Mapper do 2 | @moduledoc """ 3 | Maps the shortcode to the pid of the process that's caching the URL to 4 | redirect to. 5 | 6 | Creates a new cache process if it can't find one for the shortcode in its 7 | internal state. 8 | 9 | The new cache process is monitored so when it expires we can remove it from 10 | the map. To protect against the pid having died but we haven't received the 11 | down message yet we check if the process is alive before returning the pid. 12 | """ 13 | use GenServer 14 | alias Phlink.Cache 15 | alias Phlink.Repo 16 | import Ecto.Query 17 | alias Phlink.Link 18 | 19 | defstruct shortcodes: %{}, pids: %{} 20 | 21 | def start_link do 22 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 23 | end 24 | 25 | def init([]) do 26 | {:ok, %Cache.Mapper{}} 27 | end 28 | 29 | def handle_call({:get_url, shortcode}, _from, state) do 30 | {_pid, url, state} = get_from_cache(shortcode, state) 31 | {:reply, url, state} 32 | end 33 | 34 | def handle_call({:warm, shortcode}, _from, state) do 35 | {pid, _url, state} = get_from_cache(shortcode, state) 36 | {:reply, pid, state} 37 | end 38 | 39 | def handle_info({:DOWN, _, _, pid, _}, state) do 40 | remove_pid_from_map(pid, state) 41 | {:noreply, state} 42 | end 43 | 44 | defp get_from_cache(shortcode, state) do 45 | case Map.get(state.shortcodes, shortcode) do 46 | nil -> 47 | cache_and_update_map(shortcode, state) 48 | 49 | pid -> 50 | if Process.alive?(pid) do 51 | url = Cache.UrlCache.url(pid) 52 | {pid, url, state} 53 | else 54 | state = remove_pid_from_map(pid, state) 55 | cache_and_update_map(shortcode, state) 56 | end 57 | end 58 | end 59 | 60 | defp remove_pid_from_map(pid, state) do 61 | shortcode_for_pid = Map.get(state.pids, pid) 62 | state = %{state | shortcodes: Map.delete(state.shortcodes, shortcode_for_pid)} 63 | %{state | pids: Map.delete(state.pids, pid)} 64 | end 65 | 66 | defp cache_and_update_map(shortcode, state) do 67 | {pid, url} = get_and_cache(shortcode) 68 | state = %{state | shortcodes: Map.put(state.shortcodes, shortcode, pid)} 69 | state = %{state | pids: Map.put(state.pids, pid, shortcode)} 70 | {pid, url, state} 71 | end 72 | 73 | defp get_and_cache(shortcode) do 74 | case link_from_shortcode(shortcode) do 75 | nil -> 76 | {nil, nil} 77 | 78 | link -> 79 | {:ok, pid} = Cache.UrlCacheSupervisor.start_child(link.url) 80 | Process.monitor(pid) 81 | {pid, link.url} 82 | end 83 | end 84 | 85 | defp link_from_shortcode(shortcode) do 86 | Repo.one(from l in Link, where: l.shortcode == ^shortcode) 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/phlink_web/controllers/link_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.LinkController do 2 | @moduledoc """ 3 | Handles creating shortlinks and redirecting to the original URL. 4 | Requires that the user is logged in. 5 | """ 6 | use PhlinkWeb, :controller 7 | 8 | plug :scrub_params, "link" when action in [:create] 9 | 10 | @doc """ 11 | Display form for user to enter a URL to shorten 12 | """ 13 | def new(conn, _params) do 14 | changeset = Link.new() 15 | render(conn, "new.html", changeset: changeset) 16 | end 17 | 18 | @doc """ 19 | Create a shortened URL. 20 | 21 | If there are errors the form will be redisplayed. 22 | 23 | If the url has already been shortened it just shows the existing record. 24 | 25 | If all is good and the url hasn't been shortened yet it generates the 26 | shortcode and also adds the current user to the record. 27 | 28 | Either success path will warm the cache with the shortcode on the assumption 29 | it will be used soon. 30 | """ 31 | def create(conn, %{"link" => link_params}) do 32 | # try to find an existing url 33 | link = 34 | case link_params["url"] do 35 | nil -> nil 36 | url -> link_from_url(url) 37 | end 38 | 39 | do_create(conn, link, link_params) 40 | end 41 | 42 | # when the url hasn't been shortened before try to create the short version 43 | defp do_create(conn, nil, link_params) do 44 | link_params = Map.merge(link_params, %{"user_id" => conn.assigns[:current_user].id}) 45 | changeset = Link.changeset(%Link{}, link_params) 46 | 47 | if changeset.valid? do 48 | link = Repo.insert!(changeset) 49 | Cache.warm(link.shortcode) 50 | 51 | conn 52 | |> redirect(to: Routes.link_path(conn, :show, link.id)) 53 | else 54 | render(conn, "new.html", changeset: changeset) 55 | end 56 | end 57 | 58 | # when the url has been shortened before just show the existing record 59 | defp do_create(conn, link, _link_params) do 60 | Cache.warm(link.shortcode) 61 | 62 | conn 63 | |> redirect(to: Routes.link_path(conn, :show, link.id)) 64 | end 65 | 66 | @doc """ 67 | Display the shortlink and the target url 68 | """ 69 | def show(conn, %{"id" => id}) do 70 | link = Repo.get(Link, id) 71 | render(conn, "show.html", link: link) 72 | end 73 | 74 | @doc """ 75 | Redirect to the target url. 76 | 77 | If the shortcode wasn't in the cache then add it. 78 | 79 | If the shortcode isn't in the database render a 404. 80 | """ 81 | def unshorten(conn, %{"shortcode" => shortcode}) do 82 | case Phlink.Cache.get_url(shortcode) do 83 | nil -> 84 | conn 85 | |> fetch_session 86 | |> fetch_flash 87 | |> put_status(:not_found) 88 | |> put_view(PhlinkWeb.ErrorView) 89 | |> render("404.html") 90 | 91 | url -> 92 | conn 93 | |> put_status(:moved_permanently) 94 | |> redirect(external: url) 95 | end 96 | end 97 | 98 | defp link_from_url(url) do 99 | Repo.one(from l in Link, where: l.url == ^url) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/phlink_web/controllers/link_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhlinkWeb.LinkControllerTest do 2 | use PhlinkWeb.ConnCase 3 | alias Phlink.Cache 4 | 5 | @url "http://example.com" 6 | @expected_shortcode Phlink.Shortcode.generate(@url) 7 | @model %Link{url: @url, shortcode: @expected_shortcode} 8 | @current_user %{ 9 | id: 212, 10 | name: "Chris McGrath", 11 | avatar_url: "https://avatars.githubusercontent.com/u/212?v=3" 12 | } 13 | 14 | test "GET /shorten/new redirects if user isn't logged in" do 15 | assert build_conn() 16 | |> get("/shorten/new") 17 | |> redirected_to() == "/" 18 | end 19 | 20 | test "GET /shorten/new renders new link form" do 21 | assert build_conn() 22 | |> assign(:current_user, @current_user) 23 | |> get("/shorten/new") 24 | |> html_response(200) =~ ~r/ assign(:current_user, %{@current_user | id: user.id}) 34 | |> post("/shorten", %{"link" => %{"url" => @model.url}}) 35 | 36 | assert link_count() == 1 37 | link = Repo.one!(from l in Link, select: l, preload: [:user]) 38 | assert link.shortcode == @expected_shortcode 39 | assert link.user_id == user.id 40 | 41 | assert redirected_to(conn) == "/shorten/#{link.id}" 42 | end 43 | 44 | test "POST /shorten handles when the url has already been shortened" do 45 | link = Repo.insert!(@model) 46 | 47 | assert build_conn() 48 | |> assign(:current_user, @current_user) 49 | |> post("/shorten", %{"link" => %{"url" => @model.url}}) 50 | |> redirected_to() == "/shorten/#{link.id}" 51 | 52 | assert link_count() == 1 53 | end 54 | 55 | test "POST /shorten errors if the url is blank" do 56 | assert link_count() == 0 57 | 58 | assert build_conn() 59 | |> assign(:current_user, @current_user) 60 | |> post("/shorten", %{"link" => %{"url" => ""}}) 61 | |> html_response(200) =~ "Url can't be blank" 62 | 63 | assert link_count() == 0 64 | end 65 | 66 | test "POST /shorten errors if the url isn't a valid url" do 67 | assert link_count() == 0 68 | 69 | html = 70 | build_conn() 71 | |> assign(:current_user, @current_user) 72 | |> post("/shorten", %{"link" => %{"url" => "not a url"}}) 73 | |> html_response(200) 74 | 75 | assert html =~ "Url is not a url" 76 | assert link_count() == 0 77 | end 78 | 79 | test "GET /shorten/:id displays link and short link" do 80 | link = Repo.insert!(@model) 81 | 82 | conn = 83 | build_conn() 84 | |> assign(:current_user, @current_user) 85 | |> get("/shorten/#{link.id}") 86 | 87 | assert html_response(conn, 200) =~ ~r{ get(@model.shortcode) 98 | |> redirected_to(301) == @model.url 99 | end 100 | 101 | test "GET /:shortcode reads the url from the cache if it's there" do 102 | Repo.insert!(@model) 103 | Cache.warm(@model.shortcode) 104 | 105 | assert build_conn() 106 | |> get(@model.shortcode) 107 | |> redirected_to(301) == @model.url 108 | end 109 | 110 | test "GET /:shortcode handles the cache being expired" do 111 | Repo.insert!(@model) 112 | pid = Cache.warm(@model.shortcode) 113 | send(pid, :timeout) 114 | 115 | assert build_conn() 116 | |> get(@model.shortcode) 117 | |> redirected_to(301) == @model.url 118 | end 119 | 120 | test "GET /:shortcode 404s if shortcode not present" do 121 | assert build_conn() 122 | |> get("/notthere") 123 | |> html_response(404) 124 | end 125 | 126 | defp create_user do 127 | user = %User{ 128 | name: "Chris McGrath", 129 | github_user: %{ 130 | avatar_url: "https://avatars.githubusercontent.com/u/212?v=3" 131 | } 132 | } 133 | 134 | Repo.insert!(user) 135 | end 136 | 137 | defp link_count do 138 | Repo.one(from(l in Link, select: count(l.shortcode))) 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "artificery": {:hex, :artificery, "0.4.2", "3ded6e29e13113af52811c72f414d1e88f711410cac1b619ab3a2666bbd7efd4", [:mix], [], "hexpm"}, 3 | "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, 4 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, 5 | "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, 6 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.0", "69fdb5cf92df6373e15675eb4018cf629f5d8e35e74841bb637d6596cb797bbc", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "42868c229d9a2900a1501c5d0355bfd46e24c862c322b0b4f5a6f14fe0216753"}, 7 | "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, 8 | "db_connection": {:hex, :db_connection, "2.3.0", "d56ef906956a37959bcb385704fc04035f4f43c0f560dd23e00740daf8028c49", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "dcc082b8f723de9a630451b49fdbd7a59b065c4b38176fb147aaf773574d4520"}, 9 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 10 | "distillery": {:hex, :distillery, "2.0.14", "25fc1cdad06282334dbf4a11b6e869cc002855c4e11825157498491df2eed594", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "dotenv": {:hex, :dotenv, "3.0.0", "52a28976955070d8312a81d59105b57ecf5d6a755c728b49c70a7e2120e6cb40", [:mix], [], "hexpm"}, 12 | "earmark": {:hex, :earmark, "1.4.10", "bddce5e8ea37712a5bfb01541be8ba57d3b171d3fa4f80a0be9bcf1db417bcaf", [:mix], [{:earmark_parser, ">= 1.4.10", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "12dbfa80810478e521d3ffb941ad9fbfcbbd7debe94e1341b4c4a1b2411c1c27"}, 13 | "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, 14 | "ecto": {:hex, :ecto, "3.5.4", "73ee115deb10769c73fd2d27e19e36bc4af7c56711ad063616a86aec44f80f6f", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7f13f9c9c071bd2ca04652373ff3edd1d686364de573255096872a4abc471807"}, 15 | "ecto_sql": {:hex, :ecto_sql, "3.5.3", "1964df0305538364b97cc4661a2bd2b6c89d803e66e5655e4e55ff1571943efd", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d2f53592432ce17d3978feb8f43e8dc0705e288b0890caf06d449785f018061c"}, 16 | "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, 17 | "excoveralls": {:hex, :excoveralls, "0.13.3", "edc5f69218f84c2bf61b3609a22ddf1cec0fbf7d1ba79e59f4c16d42ea4347ed", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cc26f48d2f68666380b83d8aafda0fffc65dafcc8d8650358e0b61f6a99b1154"}, 18 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 19 | "file_system": {:hex, :file_system, "0.2.8", "f632bd287927a1eed2b718f22af727c5aeaccc9a98d8c2bd7bff709e851dc986", [:mix], [], "hexpm", "97a3b6f8d63ef53bd0113070102db2ce05352ecf0d25390eb8d747c2bde98bca"}, 20 | "fs": {:hex, :fs, "0.9.2"}, 21 | "gettext": {:hex, :gettext, "0.18.1", "89e8499b051c7671fa60782faf24409b5d2306aa71feb43d79648a8bc63d0522", [:mix], [], "hexpm", "e70750c10a5f88cb8dc026fc28fa101529835026dec4a06dba3b614f2a99c7a9"}, 22 | "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, 23 | "httpoison": {:hex, :httpoison, "0.8.0"}, 24 | "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, 25 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 26 | "jsx": {:hex, :jsx, "2.8.2", "7acc7d785b5abe8a6e9adbde926a24e481f29956dd8b4df49e3e4e7bcc92a018", [:mix, :rebar3], [], "hexpm"}, 27 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 28 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.0", "98312c9f0d3730fde4049985a1105da5155bfe5c11e47bdc7406d88e01e4219b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "75ffa34ab1056b7e24844c90bfc62aaf6f3a37a15faa76b07bc5eba27e4a8b4a"}, 29 | "meck": {:hex, :meck, "0.8.3"}, 30 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 31 | "mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"}, 32 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 33 | "mimetype_parser": {:hex, :mimetype_parser, "0.1.0"}, 34 | "mock": {:hex, :mock, "0.1.1"}, 35 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 36 | "oauth2": {:hex, :oauth2, "2.0.0", "338382079fe16c514420fa218b0903f8ad2d4bfc0ad0c9f988867dfa246731b0", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "881b8364ac7385f9fddc7949379cbe3f7081da37233a1aa7aab844670a91e7e7"}, 37 | "observer_cli": {:hex, :observer_cli, "1.6.0", "f7ffe1c9d43b7bb0ecdbf158d0dd211d44aa505d5510be580d1f25dca5627a08", [:mix, :rebar3], [{:recon, "~>2.5.1", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "7e34e3bb8412393ff9f7f6258f45ce63247bcbace78ee149d69859760dd80e5c"}, 38 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 39 | "phoenix": {:hex, :phoenix, "1.4.17", "1b1bd4cff7cfc87c94deaa7d60dd8c22e04368ab95499483c50640ef3bd838d8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a8e5d7a3d76d452bb5fb86e8b7bd115f737e4f8efe202a463d4aeb4a5809611"}, 40 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"}, 41 | "phoenix_html": {:hex, :phoenix_html, "2.14.2", "b8a3899a72050f3f48a36430da507dd99caf0ac2d06c77529b1646964f3d563e", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "58061c8dfd25da5df1ea0ca47c972f161beb6c875cd293917045b92ffe1bf617"}, 42 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.4", "940c0344b1d66a2e46eef02af3a70e0c5bb45a4db0bf47917add271b76cd3914", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "38f9308357dea4cc77f247e216da99fcb0224e05ada1469167520bed4cb8cccd"}, 43 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"}, 44 | "plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"}, 45 | "plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"}, 46 | "plug_crypto": {:hex, :plug_crypto, "1.2.0", "1cb20793aa63a6c619dd18bb33d7a3aa94818e5fd39ad357051a67f26dfa2df6", [:mix], [], "hexpm", "a48b538ae8bf381ffac344520755f3007cc10bd8e90b240af98ea29b69683fc2"}, 47 | "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, 48 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 49 | "postgrex": {:hex, :postgrex, "0.15.7", "724410acd48abac529d0faa6c2a379fb8ae2088e31247687b16cacc0e0883372", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "88310c010ff047cecd73d5ceca1d99205e4b1ab1b9abfdab7e00f5c9d20ef8f9"}, 50 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, 51 | "recon": {:hex, :recon, "2.5.1", "430ffa60685ac1efdfb1fe4c97b8767c92d0d92e6e7c3e8621559ba77598678a", [:mix, :rebar3], [], "hexpm", "5721c6b6d50122d8f68cccac712caa1231f97894bab779eff5ff0f886cb44648"}, 52 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 53 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5"}, 54 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 55 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, 56 | "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, 57 | "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "f6892c8b55004008ce2d52be7d98b156f3e34569", []}, 58 | } 59 | --------------------------------------------------------------------------------