diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8076f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Mix artifacts +/_build +/deps +/*.ez + +# Generate on crash by the VM +erl_crash.dump + +# Static artifacts +/node_modules + +# Since we are building js and css from web/static, +# we ignore priv/static/{css,js}. You may want to +# comment this depending on your deployment strategy. +/priv/static/css +/priv/static/js + +# The config/prod.secret.exs file by default contains sensitive +# data and you should not commit it into version control. +# +# Alternatively, you may comment the line below and commit the +# secrets file as long as you replace its contents by environment +# variables. +/config/prod.secret.exs \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f826a47 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2015 René Föhring + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc16481 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# ElixirStatus + +To start your new Phoenix application: + +1. Install dependencies with `mix deps.get` +2. Start Phoenix endpoint with `mix phoenix.server` + +Now you can visit `localhost:4000` from your browser. diff --git a/brunch-config.js b/brunch-config.js new file mode 100644 index 0000000..691b7d1 --- /dev/null +++ b/brunch-config.js @@ -0,0 +1,46 @@ +exports.config = { + // See http://brunch.io/#documentation for docs. + files: { + javascripts: { + joinTo: 'js/app.js' + // To use a separate vendor.js bundle, specify two files path + // https://github.com/brunch/brunch/blob/stable/docs/config.md#files + // joinTo: { + // 'js/app.js': /^(web\/static\/js)/, + // 'js/vendor.js': /^(web\/static\/vendor)/ + // } + // + // To change the order of concatenation of files, explictly mention here + // https://github.com/brunch/brunch/tree/stable/docs#concatenation + // order: { + // before: [ + // 'web/static/vendor/js/jquery-2.1.1.js', + // 'web/static/vendor/js/bootstrap.min.js' + // ] + // } + }, + stylesheets: { + joinTo: 'css/app.css' + }, + templates: { + joinTo: 'js/app.js' + } + }, + + // Phoenix paths configuration + paths: { + // Which directories to watch + watched: ["web/static", "test/static"], + + // Where to compile files to + public: "priv/static" + }, + + // Configure your plugins + plugins: { + babel: { + // Do not use ES6 compiler in vendor code + ignore: [/^(web\/static\/vendor)/] + } + } +}; diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..9157625 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,24 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. +use Mix.Config + +# Configures the endpoint +config :elixir_status, ElixirStatus.Endpoint, + url: [host: "localhost"], + root: Path.dirname(__DIR__), + secret_key_base: "LstH3/4lI4FeMXhEDifTp4UP+EAUR8RsyElC9rzzC7uyR1+hJ9U94iTHiajgzEwQ", + debug_errors: false, + pubsub: [name: ElixirStatus.PubSub, + adapter: Phoenix.PubSub.PG2] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{Mix.env}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..9095b86 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,35 @@ +use Mix.Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we use it +# with brunch.io to recompile .js and .css sources. +config :elixir_status, ElixirStatus.Endpoint, + http: [port: 4000], + debug_errors: true, + code_reloader: true, + cache_static_lookup: false, + watchers: [node: ["node_modules/brunch/bin/brunch", "watch"]] + +# Watch static and templates for browser reloading. +config :elixir_status, ElixirStatus.Endpoint, + live_reload: [ + patterns: [ + ~r{priv/static/.*(js|css|png|jpeg|jpg|gif)$}, + ~r{web/views/.*(ex)$}, + ~r{web/templates/.*(eex)$} + ] + ] + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Configure your database +config :elixir_status, ElixirStatus.Repo, + adapter: Ecto.Adapters.Postgres, + username: "postgres", + password: "postgres", + database: "elixir_status_dev", + size: 10 # The amount of database connections in the pool diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..dc3a61d --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,51 @@ +use Mix.Config + +# For production, we configure the host to read the PORT +# from the system environment. Therefore, you will need +# to set PORT=80 before running your server. +# +# You should also configure the url host to something +# meaningful, we use this information when generating URLs. +# +# Finally, we also include the path to a manifest +# containing the digested version of static files. This +# manifest is generated by the mix phoenix.digest task +# which you typically run after static files are built. +config :elixir_status, ElixirStatus.Endpoint, + http: [port: {:system, "PORT"}], + url: [host: "example.com"], + cache_static_manifest: "priv/static/manifest.json" + +# ## SSL Support +# +# To get SSL working, you will need to add the `https` key +# to the previous section: +# +# config :elixir_status, ElixirStatus.Endpoint, +# ... +# https: [port: 443, +# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), +# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] +# +# Where those two env variables point to a file on +# disk for the key and cert. + +# Do not print debug messages in production +config :logger, level: :info + +# ## Using releases +# +# If you are doing OTP releases, you need to instruct Phoenix +# to start the server for all endpoints: +# +# config :phoenix, :serve_endpoints, true +# +# Alternatively, you can configure exactly which server to +# start per endpoint: +# +# config :elixir_status, ElixirStatus.Endpoint, server: true +# + +# Finally import the config/prod.secret.exs +# which should be versioned separately. +import_config "prod.secret.exs" diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..f052d8e --- /dev/null +++ b/config/test.exs @@ -0,0 +1,18 @@ +use Mix.Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :elixir_status, ElixirStatus.Endpoint, + http: [port: 4001], + server: false + +# Print only warnings and errors during test +config :logger, level: :warn + +# Configure your database +config :elixir_status, ElixirStatus.Repo, + adapter: Ecto.Adapters.Postgres, + username: "postgres", + password: "postgres", + database: "elixir_status_test", + size: 1 # Use a single connection for transactional tests diff --git a/lib/elixir_status.ex b/lib/elixir_status.ex new file mode 100644 index 0000000..e344bd2 --- /dev/null +++ b/lib/elixir_status.ex @@ -0,0 +1,30 @@ +defmodule ElixirStatus do + use Application + + # See http://elixir-lang.org/docs/stable/elixir/Application.html + # for more information on OTP Applications + def start(_type, _args) do + import Supervisor.Spec, warn: false + + children = [ + # Start the endpoint when the application starts + supervisor(ElixirStatus.Endpoint, []), + # Start the Ecto repository + worker(ElixirStatus.Repo, []), + # Here you could define other workers and supervisors as children + # worker(ElixirStatus.Worker, [arg1, arg2, arg3]), + ] + + # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: ElixirStatus.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + def config_change(changed, _new, removed) do + ElixirStatus.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/elixir_status/endpoint.ex b/lib/elixir_status/endpoint.ex new file mode 100644 index 0000000..dfe9494 --- /dev/null +++ b/lib/elixir_status/endpoint.ex @@ -0,0 +1,35 @@ +defmodule ElixirStatus.Endpoint do + use Phoenix.Endpoint, otp_app: :elixir_status + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phoenix.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", from: :elixir_status, gzip: false, + only: ~w(css images js favicon.ico robots.txt) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + plug Plug.Logger + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Poison + + plug Plug.MethodOverride + plug Plug.Head + + plug Plug.Session, + store: :cookie, + key: "_elixir_status_key", + signing_salt: "XPXyl9lg" + + plug :router, ElixirStatus.Router +end diff --git a/lib/elixir_status/repo.ex b/lib/elixir_status/repo.ex new file mode 100644 index 0000000..7e98fac --- /dev/null +++ b/lib/elixir_status/repo.ex @@ -0,0 +1,3 @@ +defmodule ElixirStatus.Repo do + use Ecto.Repo, otp_app: :elixir_status +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..60d45a2 --- /dev/null +++ b/mix.exs @@ -0,0 +1,40 @@ +defmodule ElixirStatus.Mixfile do + use Mix.Project + + def project do + [app: :elixir_status, + version: "0.0.1", + elixir: "~> 1.0", + elixirc_paths: elixirc_paths(Mix.env), + compilers: [:phoenix] ++ Mix.compilers, + build_embedded: Mix.env == :prod, + start_permanent: Mix.env == :prod, + deps: deps] + end + + # Configuration for the OTP application + # + # Type `mix help compile.app` for more information + def application do + [mod: {ElixirStatus, []}, + applications: [:phoenix, :phoenix_html, :cowboy, :logger, + :phoenix_ecto, :postgrex, :oauth2]] + end + + # Specifies which paths to compile per environment + defp elixirc_paths(:test), do: ["lib", "web", "test/support"] + defp elixirc_paths(_), do: ["lib", "web"] + + # Specifies your project dependencies + # + # Type `mix help deps` for examples and options + defp deps do + [{:phoenix, "~> 0.13.1"}, + {:phoenix_ecto, "~> 0.4"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 1.0"}, + {:phoenix_live_reload, "~> 0.4", only: :dev}, + {:cowboy, "~> 1.0"}, + {:oauth2, "~> 0.1.0"}] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..5bf3a3b --- /dev/null +++ b/mix.lock @@ -0,0 +1,19 @@ +%{"cowboy": {:hex, :cowboy, "1.0.0"}, + "cowlib": {:hex, :cowlib, "1.0.1"}, + "decimal": {:hex, :decimal, "1.1.0"}, + "ecto": {:hex, :ecto, "0.12.0-rc"}, + "fs": {:hex, :fs, "0.9.2"}, + "hackney": {:hex, :hackney, "1.1.0"}, + "httpoison": {:hex, :httpoison, "0.7.0"}, + "idna": {:hex, :idna, "1.0.2"}, + "oauth2": {:hex, :oauth2, "0.1.1"}, + "phoenix": {:hex, :phoenix, "0.13.1"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "0.4.0"}, + "phoenix_html": {:hex, :phoenix_html, "1.2.1"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "0.4.1"}, + "plug": {:hex, :plug, "0.13.0"}, + "poison": {:hex, :poison, "1.4.0"}, + "poolboy": {:hex, :poolboy, "1.5.1"}, + "postgrex": {:hex, :postgrex, "0.8.2"}, + "ranch": {:hex, :ranch, "1.0.0"}, + "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5"}} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dc440c9 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "repository": { + }, + "dependencies": { + "brunch": "^1.8.1", + "babel-brunch": "^5.1.1", + "clean-css-brunch": ">= 1.0 < 1.8", + "css-brunch": ">= 1.0 < 1.8", + "javascript-brunch": ">= 1.0 < 1.8", + "sass-brunch": "^1.8.10", + "uglify-js-brunch": ">= 1.0 < 1.8" + } +} diff --git a/priv/static/images/avatar_rrrene.jpg b/priv/static/images/avatar_rrrene.jpg new file mode 100755 index 0000000..63908d2 Binary files /dev/null and b/priv/static/images/avatar_rrrene.jpg differ diff --git a/priv/static/images/phoenix.png b/priv/static/images/phoenix.png new file mode 100644 index 0000000..9c81075 Binary files /dev/null and b/priv/static/images/phoenix.png differ diff --git a/priv/static/robots.txt b/priv/static/robots.txt new file mode 100644 index 0000000..3c9c7c0 --- /dev/null +++ b/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/test/controllers/page_controller_test.exs b/test/controllers/page_controller_test.exs new file mode 100644 index 0000000..ce83bae --- /dev/null +++ b/test/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule ElixirStatus.PageControllerTest do + use ElixirStatus.ConnCase + + test "GET /" do + conn = get conn(), "/" + assert html_response(conn, 200) =~ "Welcome to Phoenix!" + end +end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex new file mode 100644 index 0000000..8ff4c3c --- /dev/null +++ b/test/support/channel_case.ex @@ -0,0 +1,41 @@ +defmodule ElixirStatus.ChannelCase do + @moduledoc """ + This module defines the test case to be used by + channel tests. + + Such tests rely on `Phoenix.ChannelTest` and also + imports other functionality to make it easier + to build and query models. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with channels + use Phoenix.ChannelTest + + # Alias the data repository and import query/model functions + alias ElixirStatus.Repo + import Ecto.Model + import Ecto.Query, only: [from: 2] + + + # The default endpoint for testing + @endpoint ElixirStatus.Endpoint + end + end + + setup tags do + unless tags[:async] do + Ecto.Adapters.SQL.restart_test_transaction(ElixirStatus.Repo, []) + end + + :ok + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..7ec0178 --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,43 @@ +defmodule ElixirStatus.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + imports other functionality to make it easier + to build and query models. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with connections + use Phoenix.ConnTest + + # Alias the data repository and import query/model functions + alias ElixirStatus.Repo + import Ecto.Model + import Ecto.Query, only: [from: 2] + + # Import URL helpers from the router + import ElixirStatus.Router.Helpers + + # The default endpoint for testing + @endpoint ElixirStatus.Endpoint + end + end + + setup tags do + unless tags[:async] do + Ecto.Adapters.SQL.restart_test_transaction(ElixirStatus.Repo, []) + end + + :ok + end +end diff --git a/test/support/model_case.ex b/test/support/model_case.ex new file mode 100644 index 0000000..b5f044b --- /dev/null +++ b/test/support/model_case.ex @@ -0,0 +1,53 @@ +defmodule ElixirStatus.ModelCase do + @moduledoc """ + This module defines the test case to be used by + model tests. + + You may define functions here to be used as helpers in + your model tests. See `errors_on/2`'s definition as reference. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Alias the data repository and import query/model functions + alias ElixirStatus.Repo + import Ecto.Model + import Ecto.Query, only: [from: 2] + import ElixirStatus.ModelCase + end + end + + setup tags do + unless tags[:async] do + Ecto.Adapters.SQL.restart_test_transaction(ElixirStatus.Repo, []) + end + + :ok + end + + @doc """ + Helper for returning list of errors in model when passed certain data. + + ## Examples + + Given a User model that has validation for the presence of a value for the + `:name` field and validation that `:password` is "safe": + + iex> errors_on(%User{}, password: "password") + [{:password, "is unsafe"}, {:name, "is blank"}] + + You would then write your assertion like: + + assert {:password, "is unsafe"} in errors_on(%User{}, password: "password") + """ + def errors_on(model, data) do + model.__struct__.changeset(model, data).errors + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..a892c10 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,6 @@ +ExUnit.start + +# Create the database, run migrations, and start the test transaction. +Mix.Task.run "ecto.create", ["--quiet"] +Mix.Task.run "ecto.migrate", ["--quiet"] +Ecto.Adapters.SQL.begin_test_transaction(ElixirStatus.Repo) diff --git a/test/views/error_view_test.exs b/test/views/error_view_test.exs new file mode 100644 index 0000000..ee9e23b --- /dev/null +++ b/test/views/error_view_test.exs @@ -0,0 +1,21 @@ +defmodule ElixirStatus.ErrorViewTest do + use ElixirStatus.ConnCase, async: true + + # Bring render/3 and render_to_string/3 for testing custom views + import Phoenix.View + + test "renders 404.html" do + assert render_to_string(ElixirStatus.ErrorView, "404.html", []) == + "Page not found" + end + + test "render 500.html" do + assert render_to_string(ElixirStatus.ErrorView, "500.html", []) == + "Server internal error" + end + + test "render any other" do + assert render_to_string(ElixirStatus.ErrorView, "505.html", []) == + "Server internal error" + end +end diff --git a/test/views/page_view_test.exs b/test/views/page_view_test.exs new file mode 100644 index 0000000..f804105 --- /dev/null +++ b/test/views/page_view_test.exs @@ -0,0 +1,3 @@ +defmodule ElixirStatus.PageViewTest do + use ElixirStatus.ConnCase, async: true +end diff --git a/web/controllers/github_auth_controller.ex b/web/controllers/github_auth_controller.ex new file mode 100644 index 0000000..6e12f9b --- /dev/null +++ b/web/controllers/github_auth_controller.ex @@ -0,0 +1,51 @@ +defmodule ElixirStatus.GitHubAuthController do + use ElixirStatus.Web, :controller + + plug :action + + @doc """ + This action is reached via `/auth` and redirects to the OAuth2 provider + based on the chosen strategy. + """ + def sign_in(conn, _params) do + redirect conn, external: GitHubAuth.authorize_url! + end + + @doc """ + This action is reached via `/auth/sign_out` and redirects to the OAuth2 provider + based on the chosen strategy. + """ + def sign_out(conn, _params) do + conn + |> put_session(:current_user, nil) + |> put_session(:access_token, nil) + |> redirect(to: "/") + end + + @doc """ + This action is reached via `/auth/callback` is the the callback URL that + the OAuth2 provider will redirect the user back to with a `code` that will + be used to request an access token. The access token will then be used to + access protected resources on behalf of the user. + """ + def callback(conn, %{"code" => code}) do + # Exchange an auth code for an access token + token = GitHubAuth.get_token!(code: code) + + # Request the user's data with the access token + user = OAuth2.AccessToken.get!(token, "/user") + + # Store the user in the session under `:current_user` and redirect to /. + # In most cases, we'd probably just store the user's ID that can be used + # to fetch from the database. In this case, since this example app has no + # database, I'm just storing the user map. + # + # If you need to make additional resource requests, you may want to store + # the access token as well. + conn + |> put_session(:current_user, user) + |> put_session(:access_token, token.access_token) + |> redirect(to: "/") + end +end + diff --git a/web/controllers/page_controller.ex b/web/controllers/page_controller.ex new file mode 100644 index 0000000..073b1cc --- /dev/null +++ b/web/controllers/page_controller.ex @@ -0,0 +1,9 @@ +defmodule ElixirStatus.PageController do + use ElixirStatus.Web, :controller + + plug :action + + def index(conn, _params) do + render conn, "index.html" + end +end diff --git a/web/models/user.ex b/web/models/user.ex new file mode 100644 index 0000000..c39b377 --- /dev/null +++ b/web/models/user.ex @@ -0,0 +1,9 @@ +defmodule ElixirStatus.User do + @moduledoc """ + The `current_user` is stored in the connection as a map + with string keys. + """ + + def name(user), do: user["name"] + def email(user), do: user["email"] +end diff --git a/web/oauth/git_hub_auth.ex b/web/oauth/git_hub_auth.ex new file mode 100644 index 0000000..32dd523 --- /dev/null +++ b/web/oauth/git_hub_auth.ex @@ -0,0 +1,42 @@ +defmodule GitHubAuth do + @moduledoc """ + An OAuth2 strategy for GitHub. + """ + use OAuth2.Strategy + + alias OAuth2.Strategy.AuthCode + + # Public API + + def new do + OAuth2.new([ + strategy: __MODULE__, + client_id: System.get_env("CLIENT_ID"), + client_secret: System.get_env("CLIENT_SECRET"), + redirect_uri: System.get_env("REDIRECT_URI"), + site: "https://api.github.com", + authorize_url: "https://github.com/login/oauth/authorize", + token_url: "https://github.com/login/oauth/access_token" + ]) + end + + def authorize_url!(params \\ []) do + OAuth2.Client.authorize_url!(new(), params) + end + + def get_token!(params \\ [], headers \\ []) do + OAuth2.Client.get_token!(new(), params) + end + + # Strategy Callbacks + + def authorize_url(client, params) do + AuthCode.authorize_url(client, params) + end + + def get_token(client, params, headers) do + client + |> put_header("Accept", "application/json") + |> AuthCode.get_token(params, headers) + end +end diff --git a/web/router.ex b/web/router.ex new file mode 100644 index 0000000..c099fe4 --- /dev/null +++ b/web/router.ex @@ -0,0 +1,40 @@ +defmodule ElixirStatus.Router do + use ElixirStatus.Web, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_flash + plug :protect_from_forgery + plug :assign_current_user + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", ElixirStatus do + pipe_through :browser # Use the default browser stack + + get "/", PageController, :index + end + + scope "/auth", alias: ElixirStatus do + pipe_through :browser + get "/", GitHubAuthController, :sign_in + get "/sign_out", GitHubAuthController, :sign_out + get "/callback", GitHubAuthController, :callback + end + + # Fetch the current user from the session and add it to `conn.assigns`. This + # will allow you to have access to the current user in your views with + # `@current_user`. + defp assign_current_user(conn, _) do + assign(conn, :current_user, get_session(conn, :current_user)) + end + + # Other scopes may use custom stacks. + # scope "/api", ElixirStatus do + # pipe_through :api + # end +end diff --git a/web/static/css/1_poole.css b/web/static/css/1_poole.css new file mode 100644 index 0000000..5d137f6 --- /dev/null +++ b/web/static/css/1_poole.css @@ -0,0 +1,427 @@ +/* + * ___ + * /\_ \ + * _____ ___ ___\//\ \ __ + * /\ '__`\ / __`\ / __`\\ \ \ /'__`\ + * \ \ \_\ \/\ \_\ \/\ \_\ \\_\ \_/\ __/ + * \ \ ,__/\ \____/\ \____//\____\ \____\ + * \ \ \/ \/___/ \/___/ \/____/\/____/ + * \ \_\ + * \/_/ + * + * Designed, built, and released under MIT license by @mdo. Learn more at + * https://github.com/poole/poole. + */ + + +/* + * Contents + * + * Body resets + * Custom type + * Messages + * Container + * Masthead + * Posts and pages + * Pagination + * Reverse layout + * Themes + */ + + +/* + * Body resets + * + * Update the foundational and global aspects of the page. + */ + +* { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +html { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.5; +} +@media (min-width: 38em) { + html { + font-size: 20px; + } +} + +body { + color: #515151; + background-color: #fff; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} + +/* No `:visited` state is required by default (browsers will use `a`) */ +a { + color: #268bd2; + text-decoration: none; +} +a strong { + color: inherit; +} +/* `:focus` is linked to `:hover` for basic accessibility */ +a:hover, +a:focus { + text-decoration: underline; +} + +/* Headings */ +h1, h2, h3, h4, h5, h6 { + margin-bottom: .5rem; + font-weight: bold; + line-height: 1.25; + color: #313131; + text-rendering: optimizeLegibility; +} +h1 { + margin-top: 1rem; + font-size: 1.5rem; +} +h2 { + margin-top: 1.5rem; + font-size: 1.25rem; +} +h3, h4, h5, h6 { + margin-top: 1rem; + font-size: 1rem; +} + +/* Body text */ +p { + margin-top: 0; + margin-bottom: 1rem; +} + +strong { + color: #303030; +} + + +/* Lists */ +ul, ol, dl { + margin-top: 0; + margin-bottom: 1rem; +} + +dt { + font-weight: bold; +} +dd { + margin-bottom: .5rem; +} + +/* Misc */ +hr { + position: relative; + margin: 1.5rem 0; + border: 0; + border-top: 1px solid #eee; + border-bottom: 1px solid #fff; +} + +abbr { + font-size: 85%; + font-weight: bold; + color: #555; + text-transform: uppercase; +} +abbr[title] { + cursor: help; + border-bottom: 1px dotted #e5e5e5; +} + +/* Code */ +code, +pre { + font-family: Menlo, Monaco, "Courier New", monospace; +} +code { + padding: .25em .5em; + font-size: 85%; + color: #bf616a; + background-color: #f9f9f9; + border-radius: 3px; +} +pre { + display: block; + margin-top: 0; + margin-bottom: 1rem; + padding: 1rem; + font-size: .8rem; + line-height: 1.4; + white-space: pre; + white-space: pre-wrap; + word-break: break-all; + word-wrap: break-word; + background-color: #f9f9f9; +} +pre code { + padding: 0; + font-size: 100%; + color: inherit; + background-color: transparent; +} + +/* Pygments via Jekyll */ +.highlight { + margin-bottom: 1rem; + border-radius: 4px; +} +.highlight pre { + margin-bottom: 0; +} + +/* Gist via GitHub Pages */ +.gist .gist-file { + font-family: Menlo, Monaco, "Courier New", monospace !important; +} +.gist .markdown-body { + padding: 15px; +} +.gist pre { + padding: 0; + background-color: transparent; +} +.gist .gist-file .gist-data { + font-size: .8rem !important; + line-height: 1.4; +} +.gist code { + padding: 0; + color: inherit; + background-color: transparent; + border-radius: 0; +} + +/* Quotes */ +blockquote { + padding: .5rem 1rem; + margin: .8rem 0; + color: #7a7a7a; + border-left: .25rem solid #e5e5e5; +} +blockquote p:last-child { + margin-bottom: 0; +} +@media (min-width: 30em) { + blockquote { + padding-right: 4rem; + padding-left: 1.25rem; + } +} + +img { + display: block; + max-width: 100%; + margin: 0 0 1rem; + border-radius: 5px; +} + +/* Tables */ +table { + margin-bottom: 1rem; + width: 100%; + border: 1px solid #e5e5e5; + border-collapse: collapse; +} +td, +th { + padding: .25rem .5rem; + border: 1px solid #e5e5e5; +} +tbody tr:nth-child(odd) td, +tbody tr:nth-child(odd) th { + background-color: #f9f9f9; +} + + +/* + * Custom type + * + * Extend paragraphs with `.lead` for larger introductory text. + */ + +.lead { + font-size: 1.25rem; + font-weight: 300; +} + + +/* + * Messages + * + * Show alert messages to users. You may add it to single elements like a `

`, + * or to a parent if there are multiple elements to show. + */ + +.message { + margin-bottom: 1rem; + padding: 1rem; + color: #717171; + background-color: #f9f9f9; +} + + +/* + * Container + * + * Center the page content. + */ + +.container { + max-width: 38rem; + padding-left: 1rem; + padding-right: 1rem; + margin-left: auto; + margin-right: auto; +} + + +/* + * Masthead + * + * Super small header above the content for site name and short description. + */ + +.masthead { + padding-top: 1rem; + padding-bottom: 1rem; + margin-bottom: 3rem; +} +.masthead-title { + margin-top: 0; + margin-bottom: 0; + color: #505050; +} +.masthead-title a { + color: #505050; +} +.masthead-title small { + font-size: 75%; + font-weight: 400; + color: #c0c0c0; + letter-spacing: 0; +} + + +/* + * Posts and pages + * + * Each post is wrapped in `.post` and is used on default and post layouts. Each + * page is wrapped in `.page` and is only used on the page layout. + */ + +.page, +.post { + margin-bottom: 2em; +} + +/* Blog post or page title */ +.page-title, +.post-title, +.post-title a { + color: #303030; +} +.page-title, +.post-title { + margin-top: 0; +} + +/* Meta data line below post title */ +.post-date { + display: block; + margin-top: -.5rem; + margin-bottom: 1rem; + color: #9a9a9a; +} + +/* Related posts */ +.related { + padding-top: 2rem; + padding-bottom: 2rem; + border-top: 1px solid #eee; +} +.related-posts { + padding-left: 0; + list-style: none; +} +.related-posts h3 { + margin-top: 0; +} +.related-posts li small { + font-size: 75%; + color: #999; +} +.related-posts li a:hover { + color: #268bd2; + text-decoration: none; +} +.related-posts li a:hover small { + color: inherit; +} + + +/* + * Pagination + * + * Super lightweight (HTML-wise) blog pagination. `span`s are provide for when + * there are no more previous or next posts to show. + */ + +.pagination { + overflow: hidden; /* clearfix */ + margin-left: -1rem; + margin-right: -1rem; + font-family: "PT Sans", Helvetica, Arial, sans-serif; + color: #ccc; + text-align: center; +} + +/* Pagination items can be `span`s or `a`s */ +.pagination-item { + display: block; + padding: 1rem; + border: 1px solid #eee; +} +.pagination-item:first-child { + margin-bottom: -1px; +} + +/* Only provide a hover state for linked pagination items */ +a.pagination-item:hover { + background-color: #f5f5f5; +} + +@media (min-width: 30em) { + .pagination { + margin: 3rem 0; + } + .pagination-item { + float: left; + width: 50%; + } + .pagination-item:first-child { + margin-bottom: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + .pagination-item:last-child { + margin-left: -1px; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } +} \ No newline at end of file diff --git a/web/static/css/3_syntax.css b/web/static/css/3_syntax.css new file mode 100644 index 0000000..1264b87 --- /dev/null +++ b/web/static/css/3_syntax.css @@ -0,0 +1,66 @@ +.hll { background-color: #ffffcc } + /*{ background: #f0f3f3; }*/ +.c { color: #999; } /* Comment */ +.err { color: #AA0000; background-color: #FFAAAA } /* Error */ +.k { color: #006699; } /* Keyword */ +.o { color: #555555 } /* Operator */ +.cm { color: #0099FF; font-style: italic } /* Comment.Multiline */ +.cp { color: #009999 } /* Comment.Preproc */ +.c1 { color: #999; } /* Comment.Single */ +.cs { color: #999; } /* Comment.Special */ +.gd { background-color: #FFCCCC; border: 1px solid #CC0000 } /* Generic.Deleted */ +.ge { font-style: italic } /* Generic.Emph */ +.gr { color: #FF0000 } /* Generic.Error */ +.gh { color: #003300; } /* Generic.Heading */ +.gi { background-color: #CCFFCC; border: 1px solid #00CC00 } /* Generic.Inserted */ +.go { color: #AAAAAA } /* Generic.Output */ +.gp { color: #000099; } /* Generic.Prompt */ +.gs { } /* Generic.Strong */ +.gu { color: #003300; } /* Generic.Subheading */ +.gt { color: #99CC66 } /* Generic.Traceback */ +.kc { color: #006699; } /* Keyword.Constant */ +.kd { color: #006699; } /* Keyword.Declaration */ +.kn { color: #006699; } /* Keyword.Namespace */ +.kp { color: #006699 } /* Keyword.Pseudo */ +.kr { color: #006699; } /* Keyword.Reserved */ +.kt { color: #007788; } /* Keyword.Type */ +.m { color: #FF6600 } /* Literal.Number */ +.s { color: #d44950 } /* Literal.String */ +.na { color: #4f9fcf } /* Name.Attribute */ +.nb { color: #336666 } /* Name.Builtin */ +.nc { color: #00AA88; } /* Name.Class */ +.no { color: #336600 } /* Name.Constant */ +.nd { color: #9999FF } /* Name.Decorator */ +.ni { color: #999999; } /* Name.Entity */ +.ne { color: #CC0000; } /* Name.Exception */ +.nf { color: #CC00FF } /* Name.Function */ +.nl { color: #9999FF } /* Name.Label */ +.nn { color: #00CCFF; } /* Name.Namespace */ +.nt { color: #2f6f9f; } /* Name.Tag */ +.nv { color: #003333 } /* Name.Variable */ +.ow { color: #000000; } /* Operator.Word */ +.w { color: #bbbbbb } /* Text.Whitespace */ +.mf { color: #FF6600 } /* Literal.Number.Float */ +.mh { color: #FF6600 } /* Literal.Number.Hex */ +.mi { color: #FF6600 } /* Literal.Number.Integer */ +.mo { color: #FF6600 } /* Literal.Number.Oct */ +.sb { color: #CC3300 } /* Literal.String.Backtick */ +.sc { color: #CC3300 } /* Literal.String.Char */ +.sd { color: #CC3300; font-style: italic } /* Literal.String.Doc */ +.s2 { color: #CC3300 } /* Literal.String.Double */ +.se { color: #CC3300; } /* Literal.String.Escape */ +.sh { color: #CC3300 } /* Literal.String.Heredoc */ +.si { color: #AA0000 } /* Literal.String.Interpol */ +.sx { color: #CC3300 } /* Literal.String.Other */ +.sr { color: #33AAAA } /* Literal.String.Regex */ +.s1 { color: #CC3300 } /* Literal.String.Single */ +.ss { color: #FFCC33 } /* Literal.String.Symbol */ +.bp { color: #336666 } /* Name.Builtin.Pseudo */ +.vc { color: #003333 } /* Name.Variable.Class */ +.vg { color: #003333 } /* Name.Variable.Global */ +.vi { color: #003333 } /* Name.Variable.Instance */ +.il { color: #FF6600 } /* Literal.Number.Integer.Long */ + +.css .o, +.css .o + .nt, +.css .nt + .nt { color: #999; } diff --git a/web/static/css/app.scss b/web/static/css/app.scss new file mode 100644 index 0000000..e946c33 --- /dev/null +++ b/web/static/css/app.scss @@ -0,0 +1,57 @@ +.post-author { + position: absolute; + margin-left: -80px; + + .avatar { + background-size: 100% 100%; + width: 64px; + height: 64px; + border-radius: 32px; + } +} + +footer, +footer a { + color: #aaa; + font-size: 0.8rem; +} + + +.btn-primary { + color: #fff; + background-color: #428bca; + border-color: #357ebd; +} +.btn { + display: inline-block; + margin-bottom: 0; + font-weight: 400; + text-align: center; + vertical-align: middle; + cursor: pointer; + background-image: none; + border: 1px solid transparent; + white-space: nowrap; + padding: 6px 12px; + font-size: 14px; + line-height: 1.42857143; + border-radius: 4px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.btn { + padding: 10px 16px; + font-size: 18px; + line-height: 1.33; + border-radius: 6px; +} +.btn-primary:hover { + color: #fff; + background-color: #286090; + border-color: #204d74; +} +.btn.focus, .btn:focus, .btn:hover { + text-decoration: none; +} \ No newline at end of file diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..f88a60a --- /dev/null +++ b/web/static/js/app.js @@ -0,0 +1,13 @@ +import {Socket} from "phoenix" + +// let socket = new Socket("/ws") +// socket.connect() +// let chan = socket.chan("topic:subtopic", {}) +// chan.join().receive("ok", chan => { +// console.log("Success!") +// }) + +let App = { +} + +export default App diff --git a/web/static/vendor/phoenix.js b/web/static/vendor/phoenix.js new file mode 100644 index 0000000..ff8420a --- /dev/null +++ b/web/static/vendor/phoenix.js @@ -0,0 +1,1068 @@ +(function(/*! Brunch !*/) { + 'use strict'; + + var globals = typeof window !== 'undefined' ? window : global; + if (typeof globals.require === 'function') return; + + var modules = {}; + var cache = {}; + + var has = function(object, name) { + return ({}).hasOwnProperty.call(object, name); + }; + + var expand = function(root, name) { + var results = [], parts, part; + if (/^\.\.?(\/|$)/.test(name)) { + parts = [root, name].join('/').split('/'); + } else { + parts = name.split('/'); + } + for (var i = 0, length = parts.length; i < length; i++) { + part = parts[i]; + if (part === '..') { + results.pop(); + } else if (part !== '.' && part !== '') { + results.push(part); + } + } + return results.join('/'); + }; + + var dirname = function(path) { + return path.split('/').slice(0, -1).join('/'); + }; + + var localRequire = function(path) { + return function(name) { + var dir = dirname(path); + var absolute = expand(dir, name); + return globals.require(absolute, path); + }; + }; + + var initModule = function(name, definition) { + var module = {id: name, exports: {}}; + cache[name] = module; + definition(module.exports, localRequire(name), module); + return module.exports; + }; + + var require = function(name, loaderPath) { + var path = expand(name, '.'); + if (loaderPath == null) loaderPath = '/'; + + if (has(cache, path)) return cache[path].exports; + if (has(modules, path)) return initModule(path, modules[path]); + + var dirIndex = expand(path, './index'); + if (has(cache, dirIndex)) return cache[dirIndex].exports; + if (has(modules, dirIndex)) return initModule(dirIndex, modules[dirIndex]); + + throw new Error('Cannot find module "' + name + '" from '+ '"' + loaderPath + '"'); + }; + + var define = function(bundle, fn) { + if (typeof bundle === 'object') { + for (var key in bundle) { + if (has(bundle, key)) { + modules[key] = bundle[key]; + } + } + } else { + modules[bundle] = fn; + } + }; + + var list = function() { + var result = []; + for (var item in modules) { + if (has(modules, item)) { + result.push(item); + } + } + return result; + }; + + globals.require = require; + globals.require.define = define; + globals.require.register = define; + globals.require.list = list; + globals.require.brunch = true; +})(); +require.define({'phoenix': function(exports, require, module){ "use strict"; + +var _prototypeProperties = function (child, staticProps, instanceProps) { if (staticProps) Object.defineProperties(child, staticProps); if (instanceProps) Object.defineProperties(child.prototype, instanceProps); }; + +var _classCallCheck = function (instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }; + +// Phoenix Channels JavaScript client +// +// ## Socket Connection +// +// A single connection is established to the server and +// channels are mulitplexed over the connection. +// Connect to the server using the `Socket` class: +// +// let socket = new Socket("/ws") +// socket.connect() +// +// The `Socket` constructor takes the mount point of the socket +// as well as options that can be found in the Socket docs, +// such as configuring the `LongPoller` transport, and heartbeat. +// +// +// ## Channels +// +// Channels are isolated, concurrent processes on the server that +// subscribe to topics and broker events between the client and server. +// To join a channel, you must provide the topic, and channel params for +// authorization. Here's an example chat room example where `"new_msg"` +// events are listened for, messages are pushed to the server, and +// the channel is joined with ok/error matches, and `after` hook: +// +// let chan = socket.chan("rooms:123", {token: roomToken}) +// chan.on("new_msg", msg => console.log("Got message", msg) ) +// $input.onEnter( e => { +// chan.push("new_msg", {body: e.target.val}) +// .receive("ok", (message) => console.log("created message", message) ) +// .receive("error", (reasons) => console.log("create failed", reasons) ) +// .after(10000, () => console.log("Networking issue. Still waiting...") ) +// }) +// chan.join() +// .receive("ok", ({messages}) => console.log("catching up", messages) ) +// .receive("error", ({reason}) => console.log("failed join", reason) ) +// .after(10000, () => console.log("Networking issue. Still waiting...") ) +// +// +// ## Joining +// +// Joining a channel with `chan.join(topic, params)`, binds the params to +// `chan.params`. Subsequent rejoins will send up the modified params for +// updating authorization params, or passing up last_message_id information. +// Successful joins receive an "ok" status, while unsuccessful joins +// receive "error". +// +// +// ## Pushing Messages +// +// From the prevoius example, we can see that pushing messages to the server +// can be done with `chan.push(eventName, payload)` and we can optionally +// receive responses from the push. Additionally, we can use +// `after(millsec, callback)` to abort waiting for our `receive` hooks and +// take action after some period of waiting. +// +// +// ## Socket Hooks +// +// Lifecycle events of the multiplexed connection can be hooked into via +// `socket.onError()` and `socket.onClose()` events, ie: +// +// socket.onError( () => console.log("there was an error with the connection!") ) +// socket.onClose( () => console.log("the connection dropped") ) +// +// +// ## Channel Hooks +// +// For each joined channel, you can bind to `onError` and `onClose` events +// to monitor the channel lifecycle, ie: +// +// chan.onError( () => console.log("there was an error!") ) +// chan.onClose( () => console.log("the channel has gone away gracefully") ) +// +// ### onError hooks +// +// `onError` hooks are invoked if the socket connection drops, or the channel +// crashes on the server. In either case, a channel rejoin is attemtped +// automatically in an exponential backoff manner. +// +// ### onClose hooks +// +// `onClose` hooks are invoked only in two cases. 1) the channel explicitly +// closed on the server, or 2). The client explicitly closed, by calling +// `chan.leave()` +// + +var SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 }; +var CHAN_STATES = { + closed: "closed", + errored: "errored", + joined: "joined", + joining: "joining" }; +var CHAN_EVENTS = { + close: "phx_close", + error: "phx_error", + join: "phx_join", + reply: "phx_reply", + leave: "phx_leave" +}; + +var Push = (function () { + + // Initializes the Push + // + // chan - The Channel + // event - The event, ie `"phx_join"` + // payload - The payload, ie `{user_id: 123}` + // + + function Push(chan, event, payload) { + _classCallCheck(this, Push); + + this.chan = chan; + this.event = event; + this.payload = payload || {}; + this.receivedResp = null; + this.afterHook = null; + this.recHooks = []; + this.sent = false; + } + + _prototypeProperties(Push, null, { + send: { + value: function send() { + var _this = this; + + var ref = this.chan.socket.makeRef(); + this.refEvent = this.chan.replyEventName(ref); + this.receivedResp = null; + this.sent = false; + + this.chan.on(this.refEvent, function (payload) { + _this.receivedResp = payload; + _this.matchReceive(payload); + _this.cancelRefEvent(); + _this.cancelAfter(); + }); + + this.startAfter(); + this.sent = true; + this.chan.socket.push({ + topic: this.chan.topic, + event: this.event, + payload: this.payload, + ref: ref + }); + }, + writable: true, + configurable: true + }, + receive: { + value: function receive(status, callback) { + if (this.receivedResp && this.receivedResp.status === status) { + callback(this.receivedResp.response); + } + + this.recHooks.push({ status: status, callback: callback }); + return this; + }, + writable: true, + configurable: true + }, + after: { + value: function after(ms, callback) { + if (this.afterHook) { + throw "only a single after hook can be applied to a push"; + } + var timer = null; + if (this.sent) { + timer = setTimeout(callback, ms); + } + this.afterHook = { ms: ms, callback: callback, timer: timer }; + return this; + }, + writable: true, + configurable: true + }, + matchReceive: { + + // private + + value: function matchReceive(_ref) { + var status = _ref.status; + var response = _ref.response; + var ref = _ref.ref; + + this.recHooks.filter(function (h) { + return h.status === status; + }).forEach(function (h) { + return h.callback(response); + }); + }, + writable: true, + configurable: true + }, + cancelRefEvent: { + value: function cancelRefEvent() { + this.chan.off(this.refEvent); + }, + writable: true, + configurable: true + }, + cancelAfter: { + value: function cancelAfter() { + if (!this.afterHook) { + return; + } + clearTimeout(this.afterHook.timer); + this.afterHook.timer = null; + }, + writable: true, + configurable: true + }, + startAfter: { + value: function startAfter() { + var _this = this; + + if (!this.afterHook) { + return; + } + var callback = function () { + _this.cancelRefEvent(); + _this.afterHook.callback(); + }; + this.afterHook.timer = setTimeout(callback, this.afterHook.ms); + }, + writable: true, + configurable: true + } + }); + + return Push; +})(); + +var Channel = exports.Channel = (function () { + function Channel(topic, params, socket) { + var _this = this; + + _classCallCheck(this, Channel); + + this.state = CHAN_STATES.closed; + this.topic = topic; + this.params = params || {}; + this.socket = socket; + this.bindings = []; + this.joinedOnce = false; + this.joinPush = new Push(this, CHAN_EVENTS.join, this.params); + this.pushBuffer = []; + + this.joinPush.receive("ok", function () { + _this.state = CHAN_STATES.joined; + }); + this.onClose(function () { + _this.state = CHAN_STATES.closed; + _this.socket.remove(_this); + }); + this.onError(function (reason) { + _this.state = CHAN_STATES.errored; + setTimeout(function () { + return _this.rejoinUntilConnected(); + }, _this.socket.reconnectAfterMs); + }); + this.on(CHAN_EVENTS.reply, function (payload, ref) { + _this.trigger(_this.replyEventName(ref), payload); + }); + } + + _prototypeProperties(Channel, null, { + rejoinUntilConnected: { + value: function rejoinUntilConnected() { + var _this = this; + + if (this.state !== CHAN_STATES.errored) { + return; + } + if (this.socket.isConnected()) { + this.rejoin(); + } else { + setTimeout(function () { + return _this.rejoinUntilConnected(); + }, this.socket.reconnectAfterMs); + } + }, + writable: true, + configurable: true + }, + join: { + value: function join() { + if (this.joinedOnce) { + throw "tried to join mulitple times. 'join' can only be called a singe time per channel instance"; + } else { + this.joinedOnce = true; + } + this.sendJoin(); + return this.joinPush; + }, + writable: true, + configurable: true + }, + onClose: { + value: function onClose(callback) { + this.on(CHAN_EVENTS.close, callback); + }, + writable: true, + configurable: true + }, + onError: { + value: function onError(callback) { + this.on(CHAN_EVENTS.error, function (reason) { + return callback(reason); + }); + }, + writable: true, + configurable: true + }, + on: { + value: function on(event, callback) { + this.bindings.push({ event: event, callback: callback }); + }, + writable: true, + configurable: true + }, + off: { + value: function off(event) { + this.bindings = this.bindings.filter(function (bind) { + return bind.event !== event; + }); + }, + writable: true, + configurable: true + }, + canPush: { + value: function canPush() { + return this.socket.isConnected() && this.state === CHAN_STATES.joined; + }, + writable: true, + configurable: true + }, + push: { + value: function push(event, payload) { + if (!this.joinedOnce) { + throw "tried to push '" + event + "' to '" + this.topic + "' before joining. Use chan.join() before pushing events"; + } + var pushEvent = new Push(this, event, payload); + if (this.canPush()) { + pushEvent.send(); + } else { + this.pushBuffer.push(pushEvent); + } + + return pushEvent; + }, + writable: true, + configurable: true + }, + leave: { + + // Leaves the channel + // + // Unsubscribes from server events, and + // instructs channel to terminate on server + // + // Triggers onClose() hooks + // + // To receive leave acknowledgements, use the a `receive` + // hook to bind to the server ack, ie: + // + // chan.leave().receive("ok", () => alert("left!") ) + // + + value: function leave() { + var _this = this; + + return this.push(CHAN_EVENTS.leave).receive("ok", function () { + _this.trigger(CHAN_EVENTS.close, "leave"); + }); + }, + writable: true, + configurable: true + }, + isMember: { + + // private + + value: function isMember(topic) { + return this.topic === topic; + }, + writable: true, + configurable: true + }, + sendJoin: { + value: function sendJoin() { + this.state = CHAN_STATES.joining; + this.joinPush.send(); + }, + writable: true, + configurable: true + }, + rejoin: { + value: function rejoin() { + this.sendJoin(); + this.pushBuffer.forEach(function (pushEvent) { + return pushEvent.send(); + }); + this.pushBuffer = []; + }, + writable: true, + configurable: true + }, + trigger: { + value: function trigger(triggerEvent, payload, ref) { + this.bindings.filter(function (bind) { + return bind.event === triggerEvent; + }).map(function (bind) { + return bind.callback(payload, ref); + }); + }, + writable: true, + configurable: true + }, + replyEventName: { + value: function replyEventName(ref) { + return "chan_reply_" + ref; + }, + writable: true, + configurable: true + } + }); + + return Channel; +})(); + +var Socket = exports.Socket = (function () { + + // Initializes the Socket + // + // endPoint - The string WebSocket endpoint, ie, "ws://example.com/ws", + // "wss://example.com" + // "/ws" (inherited host & protocol) + // opts - Optional configuration + // transport - The Websocket Transport, ie WebSocket, Phoenix.LongPoller. + // Defaults to WebSocket with automatic LongPoller fallback. + // heartbeatIntervalMs - The millisec interval to send a heartbeat message + // reconnectAfterMs - The millisec interval to reconnect after connection loss + // logger - The optional function for specialized logging, ie: + // `logger: function(msg){ console.log(msg) }` + // longpoller_timeout - The maximum timeout of a long poll AJAX request. + // Defaults to 20s (double the server long poll timer). + // + // For IE8 support use an ES5-shim (https://github.com/es-shims/es5-shim) + // + + function Socket(endPoint) { + var opts = arguments[1] === undefined ? {} : arguments[1]; + + _classCallCheck(this, Socket); + + this.stateChangeCallbacks = { open: [], close: [], error: [], message: [] }; + this.reconnectTimer = null; + this.channels = []; + this.sendBuffer = []; + this.ref = 0; + this.transport = opts.transport || window.WebSocket || LongPoller; + this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000; + this.reconnectAfterMs = opts.reconnectAfterMs || 5000; + this.logger = opts.logger || function () {}; // noop + this.longpoller_timeout = opts.longpoller_timeout || 20000; + this.endPoint = this.expandEndpoint(endPoint); + } + + _prototypeProperties(Socket, null, { + protocol: { + value: function protocol() { + return location.protocol.match(/^https/) ? "wss" : "ws"; + }, + writable: true, + configurable: true + }, + expandEndpoint: { + value: function expandEndpoint(endPoint) { + if (endPoint.charAt(0) !== "/") { + return endPoint; + } + if (endPoint.charAt(1) === "/") { + return "" + this.protocol() + ":" + endPoint; + } + + return "" + this.protocol() + "://" + location.host + "" + endPoint; + }, + writable: true, + configurable: true + }, + disconnect: { + value: function disconnect(callback, code, reason) { + if (this.conn) { + this.conn.onclose = function () {}; // noop + if (code) { + this.conn.close(code, reason || ""); + } else { + this.conn.close(); + } + this.conn = null; + } + callback && callback(); + }, + writable: true, + configurable: true + }, + connect: { + value: function connect() { + var _this = this; + + this.disconnect(function () { + _this.conn = new _this.transport(_this.endPoint); + _this.conn.timeout = _this.longpoller_timeout; + _this.conn.onopen = function () { + return _this.onConnOpen(); + }; + _this.conn.onerror = function (error) { + return _this.onConnError(error); + }; + _this.conn.onmessage = function (event) { + return _this.onConnMessage(event); + }; + _this.conn.onclose = function (event) { + return _this.onConnClose(event); + }; + }); + }, + writable: true, + configurable: true + }, + log: { + + // Logs the message. Override `this.logger` for specialized logging. noops by default + + value: function log(msg) { + this.logger(msg); + }, + writable: true, + configurable: true + }, + onOpen: { + + // Registers callbacks for connection state change events + // + // Examples + // + // socket.onError(function(error){ alert("An error occurred") }) + // + + value: function onOpen(callback) { + this.stateChangeCallbacks.open.push(callback); + }, + writable: true, + configurable: true + }, + onClose: { + value: function onClose(callback) { + this.stateChangeCallbacks.close.push(callback); + }, + writable: true, + configurable: true + }, + onError: { + value: function onError(callback) { + this.stateChangeCallbacks.error.push(callback); + }, + writable: true, + configurable: true + }, + onMessage: { + value: function onMessage(callback) { + this.stateChangeCallbacks.message.push(callback); + }, + writable: true, + configurable: true + }, + onConnOpen: { + value: function onConnOpen() { + var _this = this; + + this.flushSendBuffer(); + clearInterval(this.reconnectTimer); + if (!this.conn.skipHeartbeat) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = setInterval(function () { + return _this.sendHeartbeat(); + }, this.heartbeatIntervalMs); + } + this.stateChangeCallbacks.open.forEach(function (callback) { + return callback(); + }); + }, + writable: true, + configurable: true + }, + onConnClose: { + value: function onConnClose(event) { + var _this = this; + + this.log("WS close:"); + this.log(event); + this.triggerChanError(); + clearInterval(this.reconnectTimer); + clearInterval(this.heartbeatTimer); + this.reconnectTimer = setInterval(function () { + return _this.connect(); + }, this.reconnectAfterMs); + this.stateChangeCallbacks.close.forEach(function (callback) { + return callback(event); + }); + }, + writable: true, + configurable: true + }, + onConnError: { + value: function onConnError(error) { + this.log("WS error:"); + this.log(error); + this.triggerChanError(); + this.stateChangeCallbacks.error.forEach(function (callback) { + return callback(error); + }); + }, + writable: true, + configurable: true + }, + triggerChanError: { + value: function triggerChanError() { + this.channels.forEach(function (chan) { + return chan.trigger(CHAN_EVENTS.error); + }); + }, + writable: true, + configurable: true + }, + connectionState: { + value: function connectionState() { + switch (this.conn && this.conn.readyState) { + case SOCKET_STATES.connecting: + return "connecting"; + case SOCKET_STATES.open: + return "open"; + case SOCKET_STATES.closing: + return "closing"; + default: + return "closed"; + } + }, + writable: true, + configurable: true + }, + isConnected: { + value: function isConnected() { + return this.connectionState() === "open"; + }, + writable: true, + configurable: true + }, + remove: { + value: function remove(chan) { + this.channels = this.channels.filter(function (c) { + return !c.isMember(chan.topic); + }); + }, + writable: true, + configurable: true + }, + chan: { + value: function chan(topic, params) { + var chan = new Channel(topic, params, this); + this.channels.push(chan); + return chan; + }, + writable: true, + configurable: true + }, + push: { + value: function push(data) { + var _this = this; + + var callback = function () { + return _this.conn.send(JSON.stringify(data)); + }; + if (this.isConnected()) { + callback(); + } else { + this.sendBuffer.push(callback); + } + }, + writable: true, + configurable: true + }, + makeRef: { + + // Return the next message ref, accounting for overflows + + value: function makeRef() { + var newRef = this.ref + 1; + if (newRef === this.ref) { + this.ref = 0; + } else { + this.ref = newRef; + } + + return this.ref.toString(); + }, + writable: true, + configurable: true + }, + sendHeartbeat: { + value: function sendHeartbeat() { + this.push({ topic: "phoenix", event: "heartbeat", payload: {}, ref: this.makeRef() }); + }, + writable: true, + configurable: true + }, + flushSendBuffer: { + value: function flushSendBuffer() { + if (this.isConnected() && this.sendBuffer.length > 0) { + this.sendBuffer.forEach(function (callback) { + return callback(); + }); + this.sendBuffer = []; + } + }, + writable: true, + configurable: true + }, + onConnMessage: { + value: function onConnMessage(rawMessage) { + this.log("message received:"); + this.log(rawMessage); + var msg = JSON.parse(rawMessage.data); + var topic = msg.topic; + var event = msg.event; + var payload = msg.payload; + var ref = msg.ref; + + this.channels.filter(function (chan) { + return chan.isMember(topic); + }).forEach(function (chan) { + return chan.trigger(event, payload, ref); + }); + this.stateChangeCallbacks.message.forEach(function (callback) { + return callback(msg); + }); + }, + writable: true, + configurable: true + } + }); + + return Socket; +})(); + +var LongPoller = exports.LongPoller = (function () { + function LongPoller(endPoint) { + _classCallCheck(this, LongPoller); + + this.retryInMs = 5000; + this.endPoint = null; + this.token = null; + this.sig = null; + this.skipHeartbeat = true; + this.onopen = function () {}; // noop + this.onerror = function () {}; // noop + this.onmessage = function () {}; // noop + this.onclose = function () {}; // noop + this.upgradeEndpoint = this.normalizeEndpoint(endPoint); + this.pollEndpoint = this.upgradeEndpoint + (/\/$/.test(endPoint) ? "poll" : "/poll"); + this.readyState = SOCKET_STATES.connecting; + + this.poll(); + } + + _prototypeProperties(LongPoller, null, { + normalizeEndpoint: { + value: function normalizeEndpoint(endPoint) { + return endPoint.replace("ws://", "http://").replace("wss://", "https://"); + }, + writable: true, + configurable: true + }, + endpointURL: { + value: function endpointURL() { + return this.pollEndpoint + ("?token=" + encodeURIComponent(this.token) + "&sig=" + encodeURIComponent(this.sig)); + }, + writable: true, + configurable: true + }, + closeAndRetry: { + value: function closeAndRetry() { + this.close(); + this.readyState = SOCKET_STATES.connecting; + }, + writable: true, + configurable: true + }, + ontimeout: { + value: function ontimeout() { + this.onerror("timeout"); + this.closeAndRetry(); + }, + writable: true, + configurable: true + }, + poll: { + value: function poll() { + var _this = this; + + if (!(this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting)) { + return; + } + + Ajax.request("GET", this.endpointURL(), "application/json", null, this.timeout, this.ontimeout.bind(this), function (resp) { + if (resp) { + var status = resp.status; + var token = resp.token; + var sig = resp.sig; + var messages = resp.messages; + + _this.token = token; + _this.sig = sig; + } else { + var status = 0; + } + + switch (status) { + case 200: + messages.forEach(function (msg) { + return _this.onmessage({ data: JSON.stringify(msg) }); + }); + _this.poll(); + break; + case 204: + _this.poll(); + break; + case 410: + _this.readyState = SOCKET_STATES.open; + _this.onopen(); + _this.poll(); + break; + case 0: + case 500: + _this.onerror(); + _this.closeAndRetry(); + break; + default: + throw "unhandled poll status " + status; + } + }); + }, + writable: true, + configurable: true + }, + send: { + value: function send(body) { + var _this = this; + + Ajax.request("POST", this.endpointURL(), "application/json", body, this.timeout, this.onerror.bind(this, "timeout"), function (resp) { + if (!resp || resp.status !== 200) { + _this.onerror(status); + _this.closeAndRetry(); + } + }); + }, + writable: true, + configurable: true + }, + close: { + value: function close(code, reason) { + this.readyState = SOCKET_STATES.closed; + this.onclose(); + }, + writable: true, + configurable: true + } + }); + + return LongPoller; +})(); + +var Ajax = exports.Ajax = (function () { + function Ajax() { + _classCallCheck(this, Ajax); + } + + _prototypeProperties(Ajax, { + request: { + value: function request(method, endPoint, accept, body, timeout, ontimeout, callback) { + if (window.XDomainRequest) { + var req = new XDomainRequest(); // IE8, IE9 + this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback); + } else { + var req = window.XMLHttpRequest ? new XMLHttpRequest() : // IE7+, Firefox, Chrome, Opera, Safari + new ActiveXObject("Microsoft.XMLHTTP"); // IE6, IE5 + this.xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback); + } + }, + writable: true, + configurable: true + }, + xdomainRequest: { + value: function xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback) { + var _this = this; + + req.timeout = timeout; + req.open(method, endPoint); + req.onload = function () { + var response = _this.parseJSON(req.responseText); + callback && callback(response); + }; + if (ontimeout) { + req.ontimeout = ontimeout; + } + + // Work around bug in IE9 that requires an attached onprogress handler + req.onprogress = function () {}; + + req.send(body); + }, + writable: true, + configurable: true + }, + xhrRequest: { + value: function xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback) { + var _this = this; + + req.timeout = timeout; + req.open(method, endPoint, true); + req.setRequestHeader("Content-Type", accept); + req.onerror = function () { + callback && callback(null); + }; + req.onreadystatechange = function () { + if (req.readyState === _this.states.complete && callback) { + var response = _this.parseJSON(req.responseText); + callback(response); + } + }; + if (ontimeout) { + req.ontimeout = ontimeout; + } + + req.send(body); + }, + writable: true, + configurable: true + }, + parseJSON: { + value: function parseJSON(resp) { + return resp && resp !== "" ? JSON.parse(resp) : null; + }, + writable: true, + configurable: true + } + }); + + return Ajax; +})(); + +Ajax.states = { complete: 4 }; +Object.defineProperty(exports, "__esModule", { + value: true +}); + }}); +if(typeof(window) === 'object' && !window.Phoenix){ window.Phoenix = require('phoenix') }; \ No newline at end of file diff --git a/web/templates/layout/application.html.eex b/web/templates/layout/application.html.eex new file mode 100644 index 0000000..8ab7880 --- /dev/null +++ b/web/templates/layout/application.html.eex @@ -0,0 +1,27 @@ + + + + + + + + + + Hello Phoenix! + "> + + + + +

+
+

+

+
+ <%= @inner %> +
+ + + + + diff --git a/web/templates/page/index.html.eex b/web/templates/page/index.html.eex new file mode 100644 index 0000000..0dedb16 --- /dev/null +++ b/web/templates/page/index.html.eex @@ -0,0 +1,59 @@ +
+
+ +

+ + elixirstatus.com + +

+ + + +
+

+ Hi there, +

+

+ I'm @rrrene, maintainer of Inch CI and InchEx. +

+

+ This page exists because I think we need a site where people can post what they made in Elixir, simliar to Rubyflow for Ruby. + A site that is build in Elixir, that is Open Source and integrates nicely with the existing ecosystem. +

+

+ I think a site like that, if done right, will eventually be of great use to encourage people to contribute to the Elixir community. + You can read the full reasoning behind this in the project proposal. +

+

+ Ah, nearly forgot: I want to build this site, right here. +

+
+
+ +
+ + <%= if @current_user do %> + + <%= render ElixirStatus.PageView, "index_logged_in.html", current_user: @current_user, conn: @conn %> + + <% else %> + + <%= render ElixirStatus.PageView, "index_not_logged_in.html", conn: @conn %> + + <% end %> + +
+ + + +
diff --git a/web/templates/page/index_logged_in.html.eex b/web/templates/page/index_logged_in.html.eex new file mode 100644 index 0000000..068025b --- /dev/null +++ b/web/templates/page/index_logged_in.html.eex @@ -0,0 +1,24 @@ +
+
+ +

+ Welcome, <%= @current_user["login"] %>! +

+ +
+

+ You successfully signed in and will be among the first to know when this site becomes useful! + Let's get the word out: +

+

+

ElixirStatus, an upcoming community site for Elixir: elixirstatus.com
+

+

+ Tweet this now! +

+
+
+ +
diff --git a/web/templates/page/index_not_logged_in.html.eex b/web/templates/page/index_not_logged_in.html.eex new file mode 100644 index 0000000..2232a4a --- /dev/null +++ b/web/templates/page/index_not_logged_in.html.eex @@ -0,0 +1,8 @@ + +

+ By signing in, you show that you would like this site to exist. +

+ + + Sign in with GitHub + diff --git a/web/views/error_view.ex b/web/views/error_view.ex new file mode 100644 index 0000000..72d9d46 --- /dev/null +++ b/web/views/error_view.ex @@ -0,0 +1,17 @@ +defmodule ElixirStatus.ErrorView do + use ElixirStatus.Web, :view + + def render("404.html", _assigns) do + "Page not found" + end + + def render("500.html", _assigns) do + "Server internal error" + end + + # In case no render clause matches or no + # template is found, let's render it as 500 + def template_not_found(_template, assigns) do + render "500.html", assigns + end +end diff --git a/web/views/layout_view.ex b/web/views/layout_view.ex new file mode 100644 index 0000000..595c5f3 --- /dev/null +++ b/web/views/layout_view.ex @@ -0,0 +1,3 @@ +defmodule ElixirStatus.LayoutView do + use ElixirStatus.Web, :view +end diff --git a/web/views/page_view.ex b/web/views/page_view.ex new file mode 100644 index 0000000..608fdac --- /dev/null +++ b/web/views/page_view.ex @@ -0,0 +1,3 @@ +defmodule ElixirStatus.PageView do + use ElixirStatus.Web, :view +end diff --git a/web/web.ex b/web/web.ex new file mode 100644 index 0000000..1d6a87a --- /dev/null +++ b/web/web.ex @@ -0,0 +1,78 @@ +defmodule ElixirStatus.Web do + @moduledoc """ + A module that keeps using definitions for controllers, + views and so on. + + This can be used in your application as: + + use ElixirStatus.Web, :controller + use ElixirStatus.Web, :view + + The definitions below will be executed for every view, + controller, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. + """ + + def model do + quote do + use Ecto.Model + end + end + + def controller do + quote do + use Phoenix.Controller + + # Alias the data repository and import query/model functions + alias ElixirStatus.Repo + import Ecto.Model + import Ecto.Query, only: [from: 2] + + # Import URL helpers from the router + import ElixirStatus.Router.Helpers + end + end + + def view do + quote do + use Phoenix.View, root: "web/templates" + + # Import convenience functions from controllers + import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] + + # Import URL helpers from the router + import ElixirStatus.Router.Helpers + + # Use all HTML functionality (forms, tags, etc) + use Phoenix.HTML + end + end + + def router do + quote do + use Phoenix.Router + end + end + + def channel do + quote do + use Phoenix.Channel + + # Alias the data repository and import query/model functions + alias ElixirStatus.Repo + import Ecto.Model + import Ecto.Query, only: [from: 2] + + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end