├── .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 |6 | <%= @link.url %> 7 |
8 |Oops, something went wrong! Please check the errors below:
5 | 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 |
--------------------------------------------------------------------------------