├── .deliver └── config ├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── brunch-config.js ├── css │ ├── _custom.scss │ └── app.scss ├── js │ ├── app.js │ └── socket.js ├── package-lock.json ├── package.json ├── static │ ├── docs │ │ ├── api.apib │ │ └── index.html │ └── robots.txt └── yarn.lock ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── docker-compose.yml ├── entrypoint ├── lib ├── espy.ex ├── espy │ ├── account │ │ ├── account.ex │ │ └── user.ex │ ├── adabter │ │ ├── httpc.ex │ │ └── websocket.ex │ ├── application.ex │ ├── gateway │ │ ├── apps.ex │ │ ├── subscription.ex │ │ └── webhook.ex │ ├── repo.ex │ └── watcher │ │ ├── __mock__ │ │ └── transaction.ex │ │ ├── cache.ex │ │ ├── handler.ex │ │ ├── logging.ex │ │ ├── socket.ex │ │ └── supervisor.ex ├── espy_web.ex ├── util │ └── base58.ex └── web │ ├── controllers │ ├── api │ │ └── v1 │ │ │ ├── subscription_controller.ex │ │ │ └── webhook_controller.ex │ ├── app_controller.ex │ ├── auth_controller.ex │ ├── page_controller.ex │ ├── subscription_controller.ex │ └── webhook_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── helper.ex │ ├── plugs │ ├── api_auth.ex │ ├── protected.ex │ └── web_auth.ex │ ├── router.ex │ ├── templates │ ├── app │ │ ├── create_app.html.eex │ │ ├── dashboard.html.eex │ │ ├── logs.html.eex │ │ └── show.html.eex │ ├── auth │ │ └── request.html │ ├── layout │ │ └── app.html.eex │ ├── page │ │ └── index.html.eex │ ├── subscription │ │ └── list.html.eex │ └── webhook │ │ └── list.html.eex │ └── views │ ├── api │ ├── subscription_view.ex │ └── webhook_view.ex │ ├── app_view.ex │ ├── auth_view.ex │ ├── error_helpers.ex │ ├── error_view.ex │ ├── input_helpers.ex │ ├── layout_view.ex │ ├── page_view.ex │ ├── subscription_view.ex │ └── webhook_view.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── repo │ ├── migrations │ ├── 20180704001416_create_user.exs │ ├── 20190122221618_create_apps.exs │ ├── 20190123224516_create_webhook.exs │ ├── 20190123224517_create_subsciptions.exs │ └── 20190123224518_create_logging.exs │ └── seeds.exs ├── rel ├── config.exs ├── plugins │ └── .gitignore └── vm.args └── test ├── espy_web └── api_test.exs ├── support ├── channel_case.ex ├── conn_case.ex ├── data_case.ex └── factory.ex └── test_helper.exs /.deliver/config: -------------------------------------------------------------------------------- 1 | APP="espy" 2 | 3 | BUILD_HOST="webhook" 4 | BUILD_USER="root" 5 | BUILD_AT="/tmp/edeliver/$APP/builds" 6 | 7 | PRODUCTION_HOSTS="webhook" 8 | PRODUCTION_USER="root" 9 | DELIVER_TO="/srv/app" 10 | 11 | ECTO_REPOSITORY="Elixir.Espy.Repo" 12 | AUTO_VERSION=revision 13 | 14 | pre_erlang_get_and_update_deps() { 15 | local _prod_secret_path="/root/.secret/prod.secret.exs" 16 | if [ "$TARGET_MIX_ENV" = "prod" ]; then 17 | __sync_remote " 18 | ln -sfn '$_prod_secret_path' '$BUILD_AT/config/prod.secret.exs' 19 | " 20 | fi 21 | } 22 | 23 | pre_erlang_clean_compile() { 24 | status "Installing NPM dependencies" 25 | __sync_remote " 26 | [ -f ~/.profile ] && source ~/.profile 27 | set -e 28 | 29 | cd '$BUILD_AT/assets' 30 | npm install --no-warnings --loglevel=error 31 | " 32 | 33 | status "Building static files" 34 | __sync_remote " 35 | [ -f ~/.profile ] && source ~/.profile 36 | set -e 37 | 38 | cd '$BUILD_AT' 39 | mkdir -p priv/static 40 | 41 | cd '$BUILD_AT/assets' 42 | npm run deploy --no-warnings --loglevel=error 43 | " 44 | 45 | status "Running phoenix.digest" 46 | __sync_remote " 47 | [ -f ~/.profile ] && source ~/.profile 48 | set -e 49 | 50 | cd '$BUILD_AT' 51 | APP='$APP' MIX_ENV='$TARGET_MIX_ENV' $MIX_CMD ecto.migrate $SILENCE 52 | APP='$APP' MIX_ENV='$TARGET_MIX_ENV' $MIX_CMD phx.digest $SILENCE 53 | " 54 | } 55 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | _build 2 | deps 3 | assets/node_modules 4 | test 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generated on crash by the VM 8 | erl_crash.dump 9 | 10 | # Generated on crash by NPM 11 | npm-debug.log 12 | 13 | # Static artifacts 14 | /assets/node_modules 15 | 16 | # Since we are building assets from assets/, 17 | # we ignore priv/static. You may want to comment 18 | # this depending on your deployment strategy. 19 | /priv/static/ 20 | 21 | # Files matching config/*.secret.exs pattern contain sensitive 22 | # data and you should not commit them into version control. 23 | # 24 | # Alternatively, you may comment the line below and commit the 25 | # secrets files as long as you replace their contents by environment 26 | # variables. 27 | 28 | # OSX 29 | .DS_Store 30 | 31 | # Prod secret 32 | /config/*.secret.exs 33 | 34 | # Deliver 35 | .deliver/releases/ 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM bitwalker/alpine-elixir-phoenix:latest 2 | 3 | MAINTAINER Wietse Wind 4 | 5 | ENV PORT=4000 MIX_ENV=dev 6 | 7 | COPY entrypoint /entrypoint.sh 8 | RUN mkdir -p /usr/src/app 9 | WORKDIR /usr/src/app 10 | COPY . /usr/src/app 11 | 12 | # Install App node depdendencies 13 | RUN cd /usr/src/app && \ 14 | cd assets && \ 15 | npm install 16 | 17 | # Install App Elixir dependencies 18 | RUN cd /usr/src/app && \ 19 | mix local.hex --force && \ 20 | mix deps.get && \ 21 | mix local.rebar --force && \ 22 | mix deps.update bcrypt_elixir 23 | 24 | RUN rm -rf /var/cache/apk/* 25 | 26 | # Run application 27 | EXPOSE 4000 28 | 29 | ENTRYPOINT [ "/entrypoint.sh" ] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 XRPL Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XRPL Webhook 2 | 3 | Register Apps and add XRPL account subscriptions + Webhook endpoint URL's. The service will watch the XRP ledger for `payment` transactions, and HTTP POST Webhooks will be sent to the subscribed apps + endpoints. 4 | 5 | ## Start & run manually 6 | 7 | * Install dependencies with `mix deps.get` 8 | * Check your database setting at `config/dev.exs` and match your postgresql credential 9 | * Create and migrate your database with `mix ecto.create && mix ecto.migrate` 10 | * Install Node.js dependencies with `cd assets && npm install` 11 | * Add env variables to your environment 12 | * `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` Github Sign-in 13 | * `TWITTER_CONSUMER_KEY` and `TWITTER_CONSUMER_SECRET` Twitter-Sign-in 14 | * `TWITTER_REDIRECT_URI` twitter app callback url (IE: `https://webhook.xrpayments.com/auth/twitter/callback`). This callback url also needs to be set in your twitter app configuration. 15 | * Start Phoenix endpoint with `mix phx.server` 16 | 17 | ## Start & run with Docker Compose 18 | 19 | The attached docker-compose definition will run `postgres:alpine` and this application. 20 | 21 | Run the platform: 22 | 23 | ``` 24 | docker-compose up 25 | ``` 26 | 27 | If you made changes to this reposority, you may need to rebuild the Docker image: 28 | 29 | ``` 30 | docker-compose build 31 | ``` 32 | -------------------------------------------------------------------------------- /assets/brunch-config.js: -------------------------------------------------------------------------------- 1 | exports.config = { 2 | // See http://brunch.io/#documentation for docs. 3 | files: { 4 | javascripts: { 5 | joinTo: "js/app.js" 6 | 7 | // To use a separate vendor.js bundle, specify two files path 8 | // http://brunch.io/docs/config#-files- 9 | // joinTo: { 10 | // "js/app.js": /^js/, 11 | // "js/vendor.js": /^(?!js)/ 12 | // } 13 | // 14 | // To change the order of concatenation of files, explicitly mention here 15 | // order: { 16 | // before: [ 17 | // "vendor/js/jquery-2.1.1.js", 18 | // "vendor/js/bootstrap.min.js" 19 | // ] 20 | // } 21 | }, 22 | stylesheets: { 23 | joinTo: "css/app.css", 24 | order: { 25 | after: ["priv/static/css/app.scss"] 26 | } 27 | }, 28 | templates: { 29 | joinTo: "js/app.js" 30 | } 31 | }, 32 | 33 | conventions: { 34 | // This option sets where we should place non-css and non-js assets in. 35 | // By default, we set this to "/assets/static". Files in this directory 36 | // will be copied to `paths.public`, which is "priv/static" by default. 37 | assets: /^(static)/ 38 | }, 39 | 40 | // Phoenix paths configuration 41 | paths: { 42 | // Dependencies and current project directories to watch 43 | watched: ["static", "css", "js", "scss", "vendor"], 44 | // Where to compile files to 45 | public: "../priv/static" 46 | }, 47 | 48 | // Configure your plugins 49 | plugins: { 50 | babel: { 51 | // Do not use ES6 compiler in vendor code 52 | ignore: [/vendor/] 53 | }, 54 | copycat: { 55 | "fonts": ["node_modules/font-awesome/fonts"], 56 | "docs": ["static/docs"] 57 | }, 58 | sass: { 59 | options: { 60 | includePaths: ["node_modules/bootstrap/scss", "node_modules/font-awesome/scss"], // for sass-brunch to @import files 61 | precision: 8 // minimum precision required by bootstrap 62 | } 63 | } 64 | }, 65 | 66 | modules: { 67 | autoRequire: { 68 | "js/app.js": ["js/app"] 69 | } 70 | }, 71 | 72 | npm: { 73 | enabled: true, 74 | globals: { 75 | // Bootstrap JavaScript requires both '$', 'jQuery' 76 | $: 'jquery', 77 | jQuery: 'jquery', 78 | bootstrap: 'bootstrap' // require Bootstrap JavaScript globally too 79 | } 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /assets/css/_custom.scss: -------------------------------------------------------------------------------- 1 | html { 2 | position: relative; 3 | min-height: 100%; 4 | } 5 | 6 | // body { 7 | // background: #007bff; 8 | // background: linear-gradient(to right, #0062E6, #33AEFF); 9 | // } 10 | 11 | #content { 12 | margin-top: 20px; 13 | margin-bottom: 80px; 14 | } 15 | 16 | .inset { 17 | margin-left: 15px; 18 | width: 35px; 19 | height: 35px; 20 | border-radius: 50%; 21 | background-color: transparent !important; 22 | z-index: 999; 23 | } 24 | 25 | .inset img { 26 | border-radius: inherit; 27 | width: inherit; 28 | height: inherit; 29 | display: block; 30 | position: relative; 31 | z-index: 998; 32 | } 33 | 34 | .navbar-center 35 | { 36 | position: absolute; 37 | width: 100%; 38 | left: 0; 39 | top: 0; 40 | text-align: center; 41 | } 42 | 43 | .footer { 44 | position: absolute; 45 | bottom: 0; 46 | width: 100%; 47 | height: 60px; 48 | line-height: 60px; 49 | background-color: #e9ecef; 50 | } 51 | -------------------------------------------------------------------------------- /assets/css/app.scss: -------------------------------------------------------------------------------- 1 | $fa-font-path: "../fonts"; 2 | @import "font-awesome"; 3 | 4 | @import "bootstrap"; 5 | 6 | @import "custom"; 7 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // Brunch automatically concatenates all files in your 2 | // watched paths. Those paths can be configured at 3 | // config.paths.watched in "brunch-config.js". 4 | // 5 | // However, those files will only be executed if 6 | // explicitly imported. The only exception are files 7 | // in vendor, which are never wrapped in imports and 8 | // therefore are always executed. 9 | 10 | // Import dependencies 11 | // 12 | // If you no longer want to use a dependency, remember 13 | // to also remove its path from "config.paths.watched". 14 | // import "phoenix_html" 15 | 16 | // Import local files 17 | // 18 | // Local files can be imported directly using relative 19 | // paths "./socket" or full ones "web/static/js/socket". 20 | 21 | // import socket from "./socket" 22 | 23 | import "phoenix_html" 24 | 25 | 26 | $(document).ready(function() { 27 | console.log("ready") 28 | $("a.delete").on("click",function(event){ 29 | return confirm("Do you want to delete?") 30 | }); 31 | $("#regenerate").on("click",function(event){ 32 | return confirm("Do you want to regenerate the keys?") 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /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 | import {Socket} from "phoenix" 7 | 8 | let socket = new Socket("/socket", {params: {token: window.userToken}}) 9 | 10 | // When you connect, you'll often need to authenticate the client. 11 | // For example, imagine you have an authentication plug, `MyAuth`, 12 | // which authenticates the session and assigns a `:current_user`. 13 | // If the current user exists you can assign the user's token in 14 | // the connection for use in the layout. 15 | // 16 | // In your "lib/web/router.ex": 17 | // 18 | // pipeline :browser do 19 | // ... 20 | // plug MyAuth 21 | // plug :put_user_token 22 | // end 23 | // 24 | // defp put_user_token(conn, _) do 25 | // if current_user = conn.assigns[:current_user] do 26 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 27 | // assign(conn, :user_token, token) 28 | // else 29 | // conn 30 | // end 31 | // end 32 | // 33 | // Now you need to pass this token to JavaScript. You can do so 34 | // inside a script tag in "lib/web/templates/layout/app.html.eex": 35 | // 36 | // 37 | // 38 | // You will need to verify the user token in the "connect/2" function 39 | // in "lib/web/channels/user_socket.ex": 40 | // 41 | // def connect(%{"token" => token}, socket) do 42 | // # max_age: 1209600 is equivalent to two weeks in seconds 43 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 44 | // {:ok, user_id} -> 45 | // {:ok, assign(socket, :user, user_id)} 46 | // {:error, reason} -> 47 | // :error 48 | // end 49 | // end 50 | // 51 | // Finally, pass the token on connect as below. Or remove it 52 | // from connect if you don't care about authentication. 53 | 54 | socket.connect() 55 | 56 | // Now that you are connected, you can join channels with a topic: 57 | let channel = socket.channel("topic:subtopic", {}) 58 | channel.join() 59 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 60 | .receive("error", resp => { console.log("Unable to join", resp) }) 61 | 62 | export default socket 63 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "build_assets": "brunch build", 6 | "deploy": "brunch build --production", 7 | "watch": "brunch watch --stdin" 8 | }, 9 | "dependencies": { 10 | "phoenix": "file:../deps/phoenix", 11 | "phoenix_html": "file:../deps/phoenix_html", 12 | "font-awesome": "^4.7.0", 13 | "bootstrap": "4.0.0", 14 | "jquery": "3.3.1", 15 | "popper.js": "^1.12.9" 16 | }, 17 | "devDependencies": { 18 | "babel-brunch": "6.1.1", 19 | "brunch": "^2.10.17", 20 | "clean-css-brunch": "2.10.0", 21 | "copycat-brunch": "cmelgarejo/copycat-brunch", 22 | "sass-brunch": "^2.10.4", 23 | "uglify-js-brunch": "2.10.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /assets/static/docs/api.apib: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: https://webhook.xrpayments.co 3 | 4 | # XRPL Webhook API 5 | API requires authorization. All requests must have valid 6 | `x-api-key` and `x-api-secret`. 7 | 8 | 9 | 10 | # Group Subscriptions 11 | 12 | ## /api/v1/subscriptions 13 | 14 | ### Return all Subscriptions belong to app [GET] 15 | Returns a list of the current Activity type subscriptions. 16 | 17 | + Request 200 18 | 19 | + Headers 20 | 21 | x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc 22 | x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09 23 | 24 | + Response 200 (application/json; charset=utf-8) 25 | 26 | + Headers 27 | 28 | cache-control: max-age=0, private, must-revalidate 29 | 30 | + Body 31 | 32 | {"subscriptions":[{"id":37392785,"created_at":"2019-02-16T20:49:33","address":"rqAiBkWakRA9Jr5TCahtKrPS23KBYUZhj"}],"app_id":314014556} 33 | 34 | ### Subscribes address to app [POST] 35 | Subscribes the provided app all events for the provided address for all transaction types. After activation, all transactions for the requesting address will be sent to the provided webhook id via POST request. 36 | 37 | + Parameters 38 | 39 | + address (string, required) - Valid XRP address 40 | 41 | + Request 200 (application/json; charset=utf-8) 42 | 43 | + Headers 44 | 45 | x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc 46 | x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09 47 | 48 | + Attributes (object) 49 | 50 | + address (string) 51 | 52 | + Body 53 | 54 | {"address":"rqAiBkWakRA9Jr5TCahtKrPS23KBYUZhj"} 55 | 56 | + Response 200 (application/json; charset=utf-8) 57 | 58 | + Headers 59 | 60 | cache-control: max-age=0, private, must-revalidate 61 | 62 | + Body 63 | 64 | {"success":true,"subscription_id":37392785} 65 | 66 | + Request 422 (application/json; charset=utf-8) 67 | 68 | + Headers 69 | 70 | x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc 71 | x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09 72 | 73 | + Attributes (object) 74 | 75 | + address (string) 76 | 77 | + Body 78 | 79 | {"address":"invalid_address"} 80 | 81 | + Response 422 (application/json; charset=utf-8) 82 | 83 | + Headers 84 | 85 | cache-control: max-age=0, private, must-revalidate 86 | 87 | + Body 88 | 89 | {"errors":{"address":["invalid ripple address"]}} 90 | 91 | ## /api/v1/subscriptions/{subscription_id} 92 | 93 | ### Delete subscription [DELETE] 94 | Deactivates subscription(s) for the provided subscription ID and application for all activities. After deactivation, all events for the requesting subscription_id will no longer be sent to the webhook URL. 95 | 96 | ::: note 97 | The subscription ID can be accessed by making a call to GET /api/v1/subscriptions. 98 | ::: 99 | 100 | + Parameters 101 | 102 | + subscription_id (number, required) - Subscriptions ID to deactivation 103 | 104 | + Request 204 (application/json; charset=utf-8) 105 | 106 | + Headers 107 | 108 | x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc 109 | x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09 110 | 111 | + Response 204 (application/json; charset=utf-8) 112 | 113 | + Headers 114 | 115 | cache-control: max-age=0, private, must-revalidate 116 | 117 | + Body 118 | 119 | {"success":true} 120 | 121 | # Group Webhooks 122 | 123 | ## /api/v1/webhooks 124 | 125 | ### Return all webhooks [GET] 126 | Returns all webhook URLs and their statuses for the authenticating app 127 | 128 | ::: note 129 | The URL will be validated via CRC request before saving. In case the validation failed, returns comprehensive error message to the requester. 130 | ::: 131 | 132 | + Request 200 133 | 134 | + Headers 135 | 136 | x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc 137 | x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09 138 | 139 | + Response 200 (application/json; charset=utf-8) 140 | 141 | + Headers 142 | 143 | cache-control: max-age=0, private, must-revalidate 144 | 145 | + Body 146 | 147 | {"webhooks":[{"url":"https://myapp.com/webhook","id":83579274,"created_at":"2019-02-16T20:49:35","active":true}],"app_id":314014556} 148 | 149 | ### Registers webhook URL [POST] 150 | Registers a webhook URL for all event types. 151 | 152 | ::: note 153 | The URL will be validated via CRC request before saving. In case the validation failed, returns comprehensive error message to the requester. 154 | ::: 155 | 156 | + Parameters 157 | 158 | + url (string, required) - Encoded URL for the callback endpoint. 159 | 160 | + Request 200 (application/json; charset=utf-8) 161 | 162 | + Headers 163 | 164 | x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc 165 | x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09 166 | 167 | + Attributes (object) 168 | 169 | + url (string) 170 | 171 | + Body 172 | 173 | {"url":"https://myapp.com/webhook"} 174 | 175 | + Response 200 (application/json; charset=utf-8) 176 | 177 | + Headers 178 | 179 | cache-control: max-age=0, private, must-revalidate 180 | 181 | + Body 182 | 183 | {"webhook_id":83579274,"success":true} 184 | 185 | + Request 422 (application/json; charset=utf-8) 186 | 187 | + Headers 188 | 189 | x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc 190 | x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09 191 | 192 | + Attributes (object) 193 | 194 | + url (string) 195 | 196 | + Body 197 | 198 | {"url":"https://invalid-url"} 199 | 200 | + Response 422 (application/json; charset=utf-8) 201 | 202 | + Headers 203 | 204 | cache-control: max-age=0, private, must-revalidate 205 | 206 | + Body 207 | 208 | {"errors":{"url":["invalid host"]}} 209 | 210 | ## /api/v1/webhooks/{webhook_id} 211 | 212 | ### Delete webhook [DELETE] 213 | Removes the webhook from the provided application's all subscription configuration. 214 | 215 | ::: note 216 | The webhook ID can be accessed by making a call to GET /api/v1/webhooks. 217 | ::: 218 | 219 | + Parameters 220 | 221 | + webhook_id (number, required) - Webhook ID to delete 222 | 223 | + Request 204 (application/json; charset=utf-8) 224 | 225 | + Headers 226 | 227 | x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc 228 | x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09 229 | 230 | + Response 204 (application/json; charset=utf-8) 231 | 232 | + Headers 233 | 234 | cache-control: max-age=0, private, must-revalidate 235 | 236 | + Body 237 | 238 | {"success":true} 239 | -------------------------------------------------------------------------------- /assets/static/docs/index.html: -------------------------------------------------------------------------------- 1 | XRPL Webhook API

XRPL Webhook API

API requires authorization. All requests must have valid 2 | x-api-key and x-api-secret.

3 |

Subscriptions

Return all Subscriptions belong to app

GET https://webhook.xrpayments.co/api/v1/subscriptions
Requests200
Headers
x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc
x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09
Responses200
Headers
Content-Type: application/json; charset=utf-8
cache-control: max-age=0, private, must-revalidate
Body
{
  4 |   "subscriptions": [
  5 |     {
  6 |       "id": 34070368,
  7 |       "created_at": "2019-02-15T22:46:00",
  8 |       "address": "rqAiBkWakRA9Jr5TCahtKrPS23KBYUZhj"
  9 |     }
 10 |   ],
 11 |   "app_id": 314014556
 12 | }

Return all Subscriptions belong to app
GET/api/v1/subscriptions

Returns a list of the current Activity type subscriptions.

13 |

POST https://webhook.xrpayments.co/api/v1/subscriptions
Requests200422
Headers
Content-Type: application/json; charset=utf-8
x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc
x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09
Body
{
 14 |   "address": "rqAiBkWakRA9Jr5TCahtKrPS23KBYUZhj"
 15 | }
Schema
{
 16 |   "$schema": "http://json-schema.org/draft-04/schema#",
 17 |   "type": "object",
 18 |   "properties": {}
 19 | }
Responses200
Headers
Content-Type: application/json; charset=utf-8
cache-control: max-age=0, private, must-revalidate
Body
{
 20 |   "success": true,
 21 |   "subscription_id": 34070368
 22 | }
Headers
Content-Type: application/json; charset=utf-8
x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc
x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09
Body
{
 23 |   "address": "invalid_address"
 24 | }
Schema
{
 25 |   "$schema": "http://json-schema.org/draft-04/schema#",
 26 |   "type": "object",
 27 |   "properties": {}
 28 | }
Responses422
Headers
Content-Type: application/json; charset=utf-8
cache-control: max-age=0, private, must-revalidate
Body
{
 29 |   "errors": {
 30 |     "address": [
 31 |       "invalid ripple address"
 32 |     ]
 33 |   }
 34 | }

Subscribes address to app
POST/api/v1/subscriptions

Subscribes the provided app all events for the provided address for all transaction types. After activation, all transactions for the requesting address will be sent to the provided webhook id via POST request.

35 |
URI Parameters
HideShow
address
string (required) 

Valid XRP address

36 |

Delete subscription

DELETE https://webhook.xrpayments.co/api/v1/subscriptions/subscription_id
Requests204
Headers
Content-Type: application/json; charset=utf-8
x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc
x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09
Responses204
Headers
Content-Type: application/json; charset=utf-8
cache-control: max-age=0, private, must-revalidate
Body
{
 37 |   "success": true
 38 | }

Delete subscription
DELETE/api/v1/subscriptions/{subscription_id}

Deactivates subscription(s) for the provided subscription ID and application for all activities. After deactivation, all events for the requesting subscription_id will no longer be sent to the webhook URL.

39 |
40 |

The subscription ID can be accessed by making a call to GET /api/v1/subscriptions.

41 |
42 |
URI Parameters
HideShow
subscription_id
number (required) 

Subscriptions ID to deactivation

43 |

Webhooks

Return all webhooks

GET https://webhook.xrpayments.co/api/v1/webhooks
Requests200
Headers
x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc
x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09
Responses200
Headers
Content-Type: application/json; charset=utf-8
cache-control: max-age=0, private, must-revalidate
Body
{
 44 |   "webhooks": [
 45 |     {
 46 |       "url": "https://myapp.com/webhook",
 47 |       "id": 4192294,
 48 |       "created_at": "2019-02-15T22:46:00",
 49 |       "active": true
 50 |     }
 51 |   ],
 52 |   "app_id": 314014556
 53 | }

Return all webhooks
GET/api/v1/webhooks

Returns all webhook URLs and their statuses for the authenticating app

54 |
55 |

The URL will be validated via CRC request before saving. In case the validation failed, returns comprehensive error message to the requester.

56 |
57 |

POST https://webhook.xrpayments.co/api/v1/webhooks
Requests200422
Headers
Content-Type: application/json; charset=utf-8
x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc
x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09
Body
{
 58 |   "url": "https://myapp.com/webhook"
 59 | }
Schema
{
 60 |   "$schema": "http://json-schema.org/draft-04/schema#",
 61 |   "type": "object",
 62 |   "properties": {}
 63 | }
Responses200
Headers
Content-Type: application/json; charset=utf-8
cache-control: max-age=0, private, must-revalidate
Body
{
 64 |   "webhook_id": 4192294,
 65 |   "success": true
 66 | }
Headers
Content-Type: application/json; charset=utf-8
x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc
x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09
Body
{
 67 |   "url": "https://invalid-url"
 68 | }
Schema
{
 69 |   "$schema": "http://json-schema.org/draft-04/schema#",
 70 |   "type": "object",
 71 |   "properties": {}
 72 | }
Responses422
Headers
Content-Type: application/json; charset=utf-8
cache-control: max-age=0, private, must-revalidate
Body
{
 73 |   "errors": {
 74 |     "url": [
 75 |       "invalid host"
 76 |     ]
 77 |   }
 78 | }

Registers webhook URL
POST/api/v1/webhooks

Registers a webhook URL for all event types.

79 |
80 |

The URL will be validated via CRC request before saving. In case the validation failed, returns comprehensive error message to the requester.

81 |
82 |
URI Parameters
HideShow
url
string (required) 

Encoded URL for the callback endpoint.

83 |

Delete webhook

DELETE https://webhook.xrpayments.co/api/v1/webhooks/webhook_id
Requests204
Headers
Content-Type: application/json; charset=utf-8
x-api-key: 69682cbf-85bd-4507-8cbd-f4ee63fed1dc
x-api-secret: bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09
Responses204
Headers
Content-Type: application/json; charset=utf-8
cache-control: max-age=0, private, must-revalidate
Body
{
 84 |   "success": true
 85 | }

Delete webhook
DELETE/api/v1/webhooks/{webhook_id}

Removes the webhook from the provided application’s all subscription configuration.

86 |
87 |

The webhook ID can be accessed by making a call to GET /api/v1/webhooks.

88 |
89 |
URI Parameters
HideShow
webhook_id
number (required) 

Webhook ID to delete

90 |

Generated by aglio on 15 Feb 2019

-------------------------------------------------------------------------------- /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: / -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | use Mix.Config 7 | 8 | # General application configuration 9 | config :espy, 10 | ecto_repos: [Espy.Repo] 11 | 12 | # Configures the endpoint 13 | config :espy, EspyWeb.Endpoint, 14 | url: [host: "localhost"], 15 | secret_key_base: "U6rb9AJCDoSe1LtiNJOsLGzUW8+S60TApNALK1lnjems+w+yx3pcMD2d4dskTxax", 16 | render_errors: [view: EspyWeb.ErrorView, accepts: ~w(html json)], 17 | pubsub: [name: Espy.PubSub, 18 | adapter: Phoenix.PubSub.PG2] 19 | 20 | # Configures Elixir's Logger 21 | config :logger, :console, 22 | format: "$time [$level] $metadata $message\n", 23 | metadata: [:app_id, :user_id] 24 | 25 | # Configures Blue Bird API Docs 26 | config :blue_bird, 27 | docs_path: "assets/static/docs", 28 | theme: "streak", 29 | router: EspyWeb.Router 30 | 31 | # Configures Ueberauth 32 | config :ueberauth, Ueberauth, 33 | providers: [ 34 | twitter: { Ueberauth.Strategy.Twitter, []}, 35 | github: { Ueberauth.Strategy.Github, [default_scope: "user:email"] }, 36 | ] 37 | 38 | config :ueberauth, Ueberauth.Strategy.Twitter.OAuth, 39 | consumer_key: System.get_env("TWITTER_CONSUMER_KEY"), 40 | consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET") 41 | 42 | config :ueberauth, Ueberauth.Strategy.Github.OAuth, 43 | client_id: System.get_env("GITHUB_CLIENT_ID"), 44 | client_secret: System.get_env("GITHUB_CLIENT_SECRET") 45 | 46 | # Config Rate Limiter 47 | config :hammer, 48 | backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, 49 | cleanup_interval_ms: 60_000 * 10]} 50 | # Import environment specific config. This must remain at the bottom 51 | # of this file so it overrides the configuration defined above. 52 | import_config "#{Mix.env}.exs" 53 | -------------------------------------------------------------------------------- /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 brunch.io to recompile .js and .css sources. 9 | config :espy, EspyWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [node: ["node_modules/brunch/bin/brunch", "watch", "--stdin", cd: Path.expand("../assets", __DIR__)]] 15 | 16 | # ## SSL Support 17 | # 18 | # In order to use HTTPS in development, a self-signed 19 | # certificate can be generated by running the following 20 | # command from your terminal: 21 | # 22 | # openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem 23 | # 24 | # The `http:` config above can be replaced with: 25 | # 26 | # https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"], 27 | # 28 | # If desired, both `http:` and `https:` keys can be 29 | # configured to run both http and https servers on 30 | # different ports. 31 | 32 | # Watch static and templates for browser reloading. 33 | config :espy, EspyWeb.Endpoint, 34 | live_reload: [ 35 | patterns: [ 36 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 37 | ~r{priv/gettext/.*(po)$}, 38 | ~r{lib/web/views/.*(ex)$}, 39 | ~r{lib/web/templates/.*(eex)$} 40 | ] 41 | ] 42 | 43 | # Do not include metadata nor timestamps in development logs 44 | config :logger, :console, format: "[$time][$level] $message\n" 45 | 46 | # Set a higher stacktrace during development. Avoid configuring such 47 | # in production as building large stacktraces may be expensive. 48 | config :phoenix, :stacktrace_depth, 20 49 | 50 | # Configure your database 51 | config :espy, Espy.Repo, 52 | adapter: Ecto.Adapters.Postgres, 53 | username: System.get_env("PGSQL_DEV_USERNAME"), 54 | password: System.get_env("PGSQL_DEV_PASSWORD"), 55 | database: System.get_env("PGSQL_DEV_DATABASE"), 56 | hostname: System.get_env("PGSQL_DEV_HOSTNAME"), 57 | pool_size: 10 58 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, we often load configuration from external 4 | # sources, such as your system environment. For this reason, 5 | # you won't find the :http configuration below, but set inside 6 | # EspyWeb.Endpoint.init/2 when load_from_system_env is 7 | # true. Any dynamic configuration should be done there. 8 | # 9 | # Don't forget to configure the url host to something meaningful, 10 | # Phoenix uses this information when generating URLs. 11 | # 12 | # Finally, we also include the path to a cache manifest 13 | # containing the digested version of static files. This 14 | # manifest is generated by the mix phx.digest task 15 | # which you typically run after static files are built. 16 | config :espy, EspyWeb.Endpoint, 17 | load_from_system_env: true, 18 | url: [host: "webhook.xrpayments.co"], 19 | server: true, 20 | root: ".", 21 | version: Mix.Project.config[:version], 22 | cache_static_manifest: "priv/static/cache_manifest.json" 23 | 24 | # Do not print debug messages in production 25 | config :logger, level: :info 26 | 27 | config :logger, format: "$time [$level] $metadata $message\n", 28 | backends: [{LoggerFileBackend, :error_log}, :console] 29 | 30 | config :logger, :error_log, 31 | path: "log/error.log", 32 | level: :error 33 | # ## SSL Support 34 | # 35 | # To get SSL working, you will need to add the `https` key 36 | # to the previous section and set your `:url` port to 443: 37 | # 38 | # config :espy, EspyWeb.Endpoint, 39 | # ... 40 | # url: [host: "example.com", port: 443], 41 | # https: [:inet6, 42 | # port: 443, 43 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 44 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 45 | # 46 | # Where those two env variables return an absolute path to 47 | # the key and cert in disk or a relative path inside priv, 48 | # for example "priv/ssl/server.key". 49 | # 50 | # We also recommend setting `force_ssl`, ensuring no data is 51 | # ever sent via http, always redirecting to https: 52 | # 53 | # config :espy, EspyWeb.Endpoint, 54 | # force_ssl: [hsts: true] 55 | # 56 | # Check `Plug.SSL` for all available options in `force_ssl`. 57 | 58 | # ## Using releases 59 | # 60 | # If you are doing OTP releases, you need to instruct Phoenix 61 | # to start the server for all endpoints: 62 | # 63 | # config :phoenix, :serve_endpoints, true 64 | # 65 | # Alternatively, you can configure exactly which server to 66 | # start per endpoint: 67 | # 68 | # config :espy, EspyWeb.Endpoint, server: true 69 | # 70 | 71 | # Finally import the config/prod.secret.exs 72 | # which should be versioned separately. 73 | import_config "prod.secret.exs" 74 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :espy, EspyWeb.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | 12 | # Configure your database 13 | config :espy, Espy.Repo, 14 | adapter: Ecto.Adapters.Postgres, 15 | username: System.get_env("PGSQL_TEST_USERNAME"), 16 | password: System.get_env("PGSQL_TEST_PASSWORD"), 17 | database: System.get_env("PGSQL_TEST_DATABASE"), 18 | hostname: System.get_env("PGSQL_TEST_HOSTNAME"), 19 | pool: Ecto.Adapters.SQL.Sandbox 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | 3 | networks: 4 | xrpl-hooks-bridge: 5 | driver: bridge 6 | 7 | services: 8 | web: 9 | build: 10 | context: . 11 | dockerfile: Dockerfile 12 | image: xrplwebhook 13 | restart: always 14 | container_name: xrplwebhook 15 | networks: 16 | - xrpl-hooks-bridge 17 | volumes: 18 | - ./lib/web/templates:/usr/src/app/lib/web/templates 19 | ports: 20 | - 4000:4000 21 | links: 22 | - postgres 23 | environment: 24 | - PGSQL_DEV_DATABASE=xrpllabsdev 25 | - PGSQL_DEV_USERNAME=postgres 26 | - PGSQL_DEV_PASSWORD=xrpllabswebhook 27 | - PGSQL_DEV_HOSTNAME=postgres 28 | - PGSQL_TEST_DATABASE=xrpllabstst 29 | - PGSQL_TEST_USERNAME=postgres 30 | - PGSQL_TEST_PASSWORD=xrpllabswebhook 31 | - PGSQL_TEST_HOSTNAME=postgres 32 | - GITHUB_CLIENT_ID=XXXX 33 | - GITHUB_CLIENT_SECRET=XXXX 34 | - TWITTER_CONSUMER_KEY=XXXX 35 | - TWITTER_CONSUMER_SECRET=XXXX 36 | - TWITTER_REDIRECT_URI=https://webhook.example.com/auth/twitter/callback 37 | postgres: 38 | image: postgres:alpine 39 | restart: always 40 | networks: 41 | - xrpl-hooks-bridge 42 | container_name: xrplwebhook_db 43 | environment: 44 | - POSTGRES_PASSWORD=xrpllabswebhook 45 | -------------------------------------------------------------------------------- /entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd /usr/src/app 4 | 5 | if [[ ! -e /installed ]]; then 6 | touch /installed 7 | 8 | echo "Not installed yet, run init scripts" 9 | 10 | echo "Database (ecto) create & migrate" 11 | mix ecto.create && mix ecto.migrate 12 | 13 | echo "Run tests" 14 | mix test 15 | # DISABLED, MOTHERF&#@$%%#@$* AGLIO (OR RATHER: PROTAGONIST / GYP) 16 | # mix bird.gen.docs 17 | fi 18 | 19 | # Start app, pass through other arguments 20 | exec mix phx.server "$@" 21 | -------------------------------------------------------------------------------- /lib/espy.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy do 2 | @moduledoc """ 3 | EspyWeb 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 | -------------------------------------------------------------------------------- /lib/espy/account/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Account do 2 | @moduledoc """ 3 | The Account context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias Espy.Repo 8 | 9 | alias Espy.Account.User 10 | alias Comeonin.Bcrypt 11 | 12 | require Logger 13 | require Poison 14 | 15 | def get_user!(id), do: Repo.get!(User, id) 16 | 17 | 18 | # github does it this way 19 | defp avatar_from_auth( %{info: %{urls: %{avatar_url: image}} }), do: image 20 | 21 | #facebook does it this way 22 | defp avatar_from_auth( %{info: %{image: image} }), do: image 23 | 24 | # default case if nothing matches 25 | defp avatar_from_auth( auth ) do 26 | Logger.warn auth.provider <> " needs to find an avatar URL!" 27 | Logger.debug(Poison.encode!(auth)) 28 | nil 29 | end 30 | 31 | defp name_from_auth(auth) do 32 | if auth.info.name do 33 | auth.info.name 34 | else 35 | name = [auth.info.first_name, auth.info.last_name] 36 | |> Enum.filter(&(&1 != nil and &1 != "")) 37 | 38 | cond do 39 | length(name) == 0 -> auth.info.nickname 40 | true -> Enum.join(name, " ") 41 | end 42 | end 43 | end 44 | 45 | def find_or_create(auth) do 46 | changeset = User.changeset(%User{}, %{ 47 | uid: Kernel.inspect(auth.uid), 48 | token: Kernel.inspect(auth.credentials.token), 49 | email: auth.info.email, 50 | provider: Atom.to_string(auth.provider), 51 | name: name_from_auth(auth), 52 | avatar: avatar_from_auth(auth), 53 | } 54 | ) 55 | 56 | 57 | case Repo.get_by(User, [uid: changeset.changes.uid, provider: changeset.changes.provider]) do 58 | nil -> 59 | Repo.insert(changeset) 60 | user -> 61 | {:ok, user} 62 | end 63 | end 64 | 65 | end 66 | -------------------------------------------------------------------------------- /lib/espy/account/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Account.User do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias Espy.Account.User 5 | 6 | 7 | schema "user" do 8 | field :email, :string 9 | field :provider, :string 10 | field :token, :string 11 | field :uid, :string 12 | field :is_active, :boolean, default: true 13 | field :name, :string 14 | field :avatar, :string 15 | field :level, :string 16 | timestamps() 17 | end 18 | 19 | 20 | @doc false 21 | def changeset(user, attrs) do 22 | user 23 | |> cast(attrs, [:email, :uid, :provider, :token, :name, :avatar]) 24 | |> validate_required([:uid, :provider, :name]) 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /lib/espy/adabter/httpc.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Adapter.HTTPC do 2 | require Logger 3 | 4 | alias Espy.Watcher.{Logging} 5 | alias Espy.Gateway.{Webhook} 6 | 7 | @default_http_opts timeout: 10000, connect_timeout: 10000, autoredirect: false 8 | @headers %{"Content-Type" => "application/json"} 9 | @method :post 10 | @delay 2000 11 | 12 | 13 | defp to_charlist_headers(headers) do 14 | for {key, value} <- headers, do: {to_charlist(key), to_charlist(value)} 15 | end 16 | 17 | defp build_request(_method, url, headers, body) do 18 | content_type = Map.get(headers, "Content-Type") |> to_charlist() 19 | {url, headers} = build_request(url, headers) 20 | body = Poison.encode!(body) 21 | {url, headers, content_type, body} 22 | end 23 | 24 | defp build_request(url, headers) do 25 | {url |> URI.encode() |> to_charlist(), to_charlist_headers(headers)} 26 | end 27 | 28 | defp publish(request) do 29 | :httpc.request(@method, request, @default_http_opts,[]) 30 | end 31 | 32 | 33 | defp retry(pid, last_status \\ 0, last_response_time \\ 0) do 34 | Agent.update(pid, &Map.update(&1, :retries, 0, fn c -> c + 1 end)) 35 | Agent.update(pid, &Map.put(&1, :last_status, last_status)) 36 | Agent.update(pid, &Map.put(&1, :last_response_time, last_response_time)) 37 | :timer.sleep(@delay) 38 | end 39 | 40 | def call(req) do 41 | %{body: body, url: url, callback: callback } = req 42 | request = build_request(@method, url, @headers, body) 43 | 44 | # CREATE AGENT FOR RETYR STATE 45 | {:ok, pid} = Agent.start_link(fn -> %{:retries => 1, :last_status => 0, :last_response_time => 0} end) 46 | 47 | try do 48 | for x <- [1, 2, 3] do 49 | if ( retries = Agent.get(pid, &Map.get(&1, :retries)) ) < 3 do 50 | case :timer.tc(&publish/1, [request]) do 51 | {time, {:ok, {{_, code, msg}, _headers, body}}} -> 52 | case div(code, 100) do 53 | 2 -> throw({:break , {:ok, code , time, retries, callback}}) # SUCCESS 54 | _ -> retry(pid, code, time) 55 | end 56 | {time, {:error, reason}} -> retry(pid) 57 | end 58 | else 59 | agent = Agent.get(pid, & &1) 60 | throw({:break, {:error, agent.last_status, agent.last_response_time, agent.retries,callback}}) 61 | end 62 | end 63 | catch 64 | {:break, return} -> 65 | Agent.stop(pid) 66 | if callback do 67 | return |> handle_callback 68 | end 69 | end 70 | end 71 | 72 | 73 | # handle HTTPC callback 74 | def handle_callback({:ok, staus_code, response_time, retries, callback}) do 75 | Logging.create_log(%{ 76 | response_time: response_time, 77 | response_status: staus_code, 78 | retry_count: retries, 79 | object_id: callback.object, 80 | webhook_id: callback.webhook, 81 | app_id: callback.app 82 | }) 83 | Webhook.set_failed_count(callback.webhook, 0) 84 | end 85 | 86 | def handle_callback({:error, staus_code, response_time, retries, callback}) do 87 | Logging.create_log(%{ 88 | response_time: response_time, 89 | response_status: staus_code, 90 | retry_count: retries, 91 | object_id: callback.object, 92 | webhook_id: callback.webhook, 93 | app_id: callback.app 94 | }) 95 | with {_,[c]} <- Webhook.increase_failed_count(callback.webhook) do 96 | if c > 20 do 97 | Webhook.deactivate(callback.webhook, "TOO MUCH FAILED REQUEST") 98 | end 99 | end 100 | end 101 | 102 | 103 | 104 | end 105 | -------------------------------------------------------------------------------- /lib/espy/adabter/websocket.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Adapter.WebSocket do 2 | @moduledoc """ 3 | Ripple WebSocket client. 4 | 5 | Behind the scenes, this module uses :websocket_client erlang libray. 6 | """ 7 | 8 | import Logger, only: [info: 1, warn: 1] 9 | 10 | def send_command(client, command, args) do 11 | Process.whereis(client) 12 | |> :websocket_client.send({:text, encode(command, args)}) 13 | end 14 | 15 | def cast_command(client, command, args) do 16 | Process.whereis(client) 17 | |> :websocket_client.cast({:text, encode(command, args)}) 18 | end 19 | 20 | def encode(command, args) do 21 | Poison.encode!(Map.merge(%{command: command}, args)) 22 | end 23 | 24 | defmacro __using__(_params) do 25 | quote do 26 | @behaviour :websocket_client 27 | @test_net false 28 | @url "wss://" <> (@test_net && "s.altnet.rippletest.net:51233" || "s1.ripple.com:443") 29 | @ping_interval 5_000 30 | 31 | ## API 32 | def start_link(args \\ %{}) do 33 | :crypto.start() 34 | :ssl.start() 35 | {:ok, pid} = :websocket_client.start_link(@url, __MODULE__, [], []) 36 | Process.register(pid, __MODULE__) 37 | {:ok, pid} 38 | end 39 | 40 | def start_link(args, _), do: start_link(args) 41 | 42 | ## Callbacks 43 | def init(args) do 44 | {:ok, args} 45 | end 46 | 47 | def onconnect(_ws_req, state) do 48 | info("#{__MODULE__} connected") 49 | {:ok, state} 50 | end 51 | 52 | def ondisconnect(:normal, state) do 53 | info("#{__MODULE__} disconnected with reason :normal") 54 | {:ok, state} 55 | end 56 | 57 | def ondisconnect(reason, state) do 58 | warn("#{__MODULE__} disconnected: #{inspect reason}. Reconnecting") 59 | {:reconnect, state} 60 | end 61 | 62 | def websocket_handle({:pong, _}, _conn_state, state) do 63 | {:ok, state} 64 | end 65 | 66 | def websocket_handle(msg, _conn_state, state) do 67 | with {:text, text} <- msg, 68 | {:ok, resp} <- Poison.Parser.parse(text) do 69 | handle_response(resp) 70 | else 71 | e -> 72 | warn("#{__MODULE__} received unexpected response: #{inspect e}") 73 | end 74 | {:ok, state} 75 | end 76 | 77 | def websocket_info(msg, _conn_state, state) do 78 | warn("#{__MODULE__} received unexpected erlang msg: #{inspect msg}") 79 | {:ok, state} 80 | end 81 | 82 | def websocket_terminate(reason, _conn_state, state) do 83 | warn("#{__MODULE__} closed in state #{inspect state} " <> 84 | "with reason #{inspect reason}") 85 | :ok 86 | end 87 | 88 | def handle_response(resp) do 89 | info("#{__MODULE__} received response: #{inspect resp}") 90 | end 91 | 92 | defoverridable Module.definitions_in(__MODULE__) 93 | end 94 | end 95 | 96 | end 97 | 98 | -------------------------------------------------------------------------------- /lib/espy/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Application do 2 | use Application 3 | 4 | # See https://hexdocs.pm/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec 8 | 9 | # Define workers and child supervisors to be supervised 10 | children = [ 11 | # Start the Ecto repository 12 | supervisor(Espy.Repo, []), 13 | # start watcher Socket 14 | supervisor(Espy.Watcher.Supervisor, []), 15 | # Start the endpoint when the application starts 16 | supervisor(EspyWeb.Endpoint, []), 17 | ] 18 | 19 | # See https://hexdocs.pm/elixir/Supervisor.html 20 | # for other strategies and supported options 21 | Supervisor.start_link(children, strategy: :one_for_one, name: Espy.Supervisor) 22 | end 23 | 24 | # Tell Phoenix to update the endpoint configuration 25 | # whenever the application is updated. 26 | def config_change(changed, _new, removed) do 27 | EspyWeb.Endpoint.config_change(changed, removed) 28 | :ok 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/espy/gateway/apps.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Gateway.App do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | import Ecto.Query, warn: false 5 | 6 | alias Espy.Repo 7 | alias Espy.Gateway.App 8 | alias Espy.Account 9 | alias Espy.Account.{User} 10 | 11 | schema "apps" do 12 | field :app_id, :integer 13 | field :active, :boolean, default: true 14 | field :api_key, :string 15 | field :api_secret, :string 16 | field :deleted, :boolean, default: false 17 | field :description, :string 18 | field :name, :string 19 | field :url, :string 20 | 21 | belongs_to :user, User 22 | timestamps() 23 | end 24 | 25 | def changeset(%App{} = app, attrs) do 26 | app 27 | |> cast(attrs, [:name, :description, :url, :user_id]) 28 | |> cast_assoc(:user) 29 | |> validate_required([:name, :description, :url, :user_id]) 30 | |> validate_length(:name, min: 3, max: 16) 31 | |> validate_length(:description, min: 10, max: 64) 32 | |> validate_url() 33 | |> put_app_id() 34 | |> put_api_key() 35 | |> put_api_secret() 36 | end 37 | 38 | def update_changeset(%App{} = app, attrs) do 39 | app 40 | |> cast(attrs, [:name, :description, :url, :user_id]) 41 | |> cast_assoc(:user) 42 | |> validate_required([:name, :description, :url, :user_id]) 43 | |> validate_length(:name, min: 3, max: 16) 44 | |> validate_length(:description, min: 10, max: 64) 45 | |> validate_url() 46 | end 47 | 48 | 49 | def regenerate_changeset(%App{} = app) do 50 | app 51 | |> put_api_key() 52 | |> put_api_secret() 53 | end 54 | 55 | def validate_url(changeset) do 56 | case get_field(changeset, :url) do 57 | nil -> changeset 58 | url -> 59 | case URI.parse(url) do 60 | %URI{scheme: nil} -> add_error(changeset, :url, "URL is missing host") 61 | %URI{host: nil} -> add_error(changeset, :url, "URL is missing host") 62 | %URI{host: host} -> 63 | case :inet.gethostbyname(Kernel.to_charlist host) do 64 | {:ok, _} -> changeset 65 | {:error, _} -> add_error(changeset, :url, "invalid host") 66 | end 67 | end 68 | end 69 | end 70 | 71 | 72 | def check_limit(user_id) do 73 | apps_count = App 74 | |> where(user_id: ^user_id) 75 | |> where(deleted: false) 76 | |> Repo.aggregate(:count, :id) 77 | 78 | user = Account.get_user!(user_id) 79 | 80 | apps_count >= 1 and user.level != "pro" 81 | end 82 | 83 | def put_app_id(changeset) do 84 | change(changeset, app_id: Enum.random(1000000..99999999)) 85 | end 86 | 87 | 88 | def put_api_key(changeset) do 89 | change(changeset, api_key: SecureRandom.uuid()) 90 | end 91 | 92 | def put_api_secret(changeset) do 93 | change(changeset, api_secret: SecureRandom.urlsafe_base64()) 94 | end 95 | 96 | def user_apps(user_id) do 97 | Repo.all(from a in App, where: a.user_id == ^user_id, where: a.deleted != true) 98 | end 99 | 100 | def get!(id, user_id) do 101 | Repo.get_by!(App, [ app_id: id, user_id: user_id, deleted: false]) |> Repo.preload(:user) 102 | end 103 | 104 | def get(id) do 105 | Repo.get_by(App, [ id: id, deleted: false, active: true]) |> Repo.preload(:user) 106 | end 107 | 108 | 109 | def get_by_token(api_key, api_secret) do 110 | Repo.get_by(App, [ api_key: api_key, api_secret: api_secret]) |> Repo.preload(:user) 111 | end 112 | 113 | def regenerate(id, user_id) do 114 | Repo.get_by!(App, [ app_id: id, user_id: user_id, deleted: false]) 115 | |> App.regenerate_changeset() 116 | |> Repo.update 117 | end 118 | 119 | 120 | def create(attrs \\ %{}) do 121 | %App{} 122 | |> App.changeset(attrs) 123 | |> Repo.insert() 124 | end 125 | 126 | def update(%App{} = app, attrs) do 127 | app 128 | |> App.update_changeset(attrs) 129 | |> Repo.update() 130 | end 131 | 132 | def delete(%App{} = app) do 133 | Repo.delete(app) 134 | end 135 | 136 | def change(%App{} = app) do 137 | App.update_changeset(app, %{}) 138 | end 139 | 140 | 141 | end 142 | -------------------------------------------------------------------------------- /lib/espy/gateway/subscription.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Gateway.Subscription do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | import Ecto.Query, warn: false 5 | 6 | alias Espy.Repo 7 | alias Espy.Gateway.{Webhook, Subscription, App} 8 | 9 | schema "subscriptions" do 10 | field :subscription_id, :integer 11 | field :address, :string 12 | 13 | belongs_to :app, App 14 | timestamps() 15 | end 16 | 17 | @doc false 18 | def changeset(webhook, attrs) do 19 | webhook 20 | |> cast(attrs, [:app_id, :address]) 21 | |> cast_assoc(:app) 22 | |> validate_required([:app_id, :address]) 23 | |> validate_address 24 | |> put_subscrition_id() 25 | end 26 | 27 | 28 | def get_all do 29 | Repo.all(Subscription) 30 | end 31 | 32 | def put_subscrition_id(changeset) do 33 | change(changeset, subscription_id: Enum.random(1000000..99999999)) 34 | end 35 | 36 | def can_add(app) do 37 | case Webhook.count_by_app(app.id) == 0 do 38 | true -> "App has no webhook, please create one and try again." 39 | false -> 40 | subscription_count = Subscription 41 | |> where(app_id: ^app.id) 42 | |> Repo.aggregate(:count, :id) 43 | case subscription_count >= 5 and app.user.level != "pro" do 44 | true -> "Subscriptions limit reached, you cannot add more subscription." 45 | false -> :can_add 46 | end 47 | end 48 | end 49 | 50 | def validate_address(changeset) do 51 | case get_field(changeset, :address) do 52 | nil -> changeset 53 | address -> 54 | case address |> Utils.Base58.valid? do 55 | false -> add_error(changeset, :address, "invalid XRP address") 56 | true -> changeset 57 | end 58 | end 59 | end 60 | 61 | def create(attrs \\ %{}) do 62 | case Repo.get_by(Subscription, attrs) do 63 | nil -> # Subscription not found, we build one 64 | %Subscription{} 65 | |> Subscription.changeset(attrs) 66 | |> Repo.insert() 67 | object -> {:exist , object } # Subscription exists 68 | end 69 | end 70 | 71 | def list_by_app(app_id) do 72 | Repo.all(from a in Subscription, where: a.app_id == ^app_id, order_by: [desc: a.id]) 73 | end 74 | 75 | def delete(params) do 76 | case Repo.get_by(Subscription, params) do 77 | nil -> {:error, :not_found} 78 | subscription -> Repo.delete subscription 79 | end 80 | end 81 | 82 | 83 | def change(%Subscription{} = subscription) do 84 | Subscription.changeset(subscription, %{}) 85 | end 86 | 87 | 88 | end 89 | -------------------------------------------------------------------------------- /lib/espy/gateway/webhook.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Gateway.Webhook do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | import Ecto.Query, warn: false 5 | 6 | alias Espy.Repo 7 | alias Espy.Gateway.{ Webhook, Subscription } 8 | 9 | schema "webhooks" do 10 | field :deactivated, :boolean, default: false 11 | field :deactivate_reason, :string 12 | field :failed_count, :integer, default: 0 13 | field :deleted, :boolean, default: false 14 | field :hook_id, :integer 15 | field :url, :string 16 | 17 | belongs_to :app, App 18 | 19 | timestamps() 20 | end 21 | 22 | @doc false 23 | def changeset(webhook, attrs) do 24 | webhook 25 | |> cast(attrs, [:hook_id, :url, :deactivated, :app_id]) 26 | |> cast_assoc(:app) 27 | |> validate_required([:url, :app_id]) 28 | |> validate_url 29 | |> put_hook_id() 30 | end 31 | 32 | def put_hook_id(changeset) do 33 | change(changeset, hook_id: Enum.random(1000000..99999999)) 34 | end 35 | 36 | def validate_url(changeset) do 37 | case get_field(changeset, :url) do 38 | nil -> changeset 39 | url -> 40 | case URI.parse(url) do 41 | %URI{scheme: nil} -> add_error(changeset, :url, "URL is missing host") 42 | %URI{host: nil} -> add_error(changeset, :url, "URL is missing host") 43 | %URI{host: host} -> 44 | case :inet.gethostbyname(Kernel.to_charlist host) do 45 | {:ok, _} -> changeset 46 | {:error, _} -> add_error(changeset, :url, "invalid host") 47 | end 48 | end 49 | end 50 | end 51 | 52 | def can_add(app) do 53 | webhook_count = Webhook 54 | |> where(app_id: ^app.id) 55 | |> where(deleted: false) 56 | |> Repo.aggregate(:count, :id) 57 | case webhook_count >= 2 and app.user.level != "pro" do 58 | true -> "Webhooks limit reached, you cannot add more webhook." 59 | false -> :can_add 60 | end 61 | end 62 | 63 | 64 | def can_delete(app) do 65 | webhook_count = Webhook 66 | |> where(app_id: ^app.id) 67 | |> where(deleted: false) 68 | |> Repo.aggregate(:count, :id) 69 | subscription_count = Subscription 70 | |> where(app_id: ^app.id) 71 | |> Repo.aggregate(:count, :id) 72 | case webhook_count == 1 and subscription_count > 0 do 73 | true -> "You already have some subscriptions on this app, please remove them and try again!" 74 | false -> :can_delete 75 | end 76 | end 77 | 78 | 79 | def create(attrs \\ %{}) do 80 | case Repo.get_by(Webhook, attrs) do 81 | nil -> 82 | %Webhook{} 83 | |> Webhook.changeset(attrs) 84 | |> Repo.insert_or_update 85 | object -> {:ok ,object} 86 | end 87 | end 88 | 89 | 90 | def get!(id, app_id) do 91 | Repo.get_by!(Webhook, [ hook_id: id, app_id: app_id, deleted: false]) 92 | end 93 | 94 | 95 | def list(app_id) do 96 | Repo.all(from a in Webhook, where: a.app_id == ^app_id, where: a.deleted == false, order_by: [desc: a.id]) 97 | end 98 | 99 | def list_by_app(app_id) do 100 | Repo.all(from a in Webhook, where: a.app_id == ^app_id, where: a.deleted == false, where: a.deactivated == false, order_by: [desc: a.id]) 101 | end 102 | 103 | def list_by_apps(app_ids) do 104 | Repo.all(from a in Webhook, where: a.app_id in ^app_ids, where: a.deleted == false, where: a.deactivated == false, order_by: [desc: a.id]) 105 | end 106 | 107 | 108 | def count_by_app(app_id) do 109 | Repo.one(from a in Webhook, where: a.app_id == ^app_id, where: a.deleted == false, select: count(a.id)) 110 | end 111 | 112 | def delete(params) do 113 | case Repo.get_by(Webhook, params) do 114 | nil -> {:error, :not_found} 115 | hook -> Repo.update(change(hook, deleted: true)) 116 | end 117 | end 118 | 119 | def set_failed_count(id, count) do 120 | from(p in Webhook, where: p.id == ^id) 121 | |> Repo.update_all(set: [failed_count: count]) 122 | end 123 | 124 | def increase_failed_count(id) do 125 | from(p in Webhook, where: p.id == ^id, select: p.failed_count) 126 | |> Repo.update_all(inc: [failed_count: 1]) 127 | end 128 | 129 | def deactivate(id, reason) do 130 | Webhook 131 | |> where(id: ^id) 132 | |> update(set: [deactivated: true, deactivate_reason: ^reason]) 133 | |> Repo.update_all([]) 134 | end 135 | 136 | 137 | def change(%Webhook{} = webhook) do 138 | Webhook.changeset(webhook, %{}) 139 | end 140 | 141 | 142 | end 143 | -------------------------------------------------------------------------------- /lib/espy/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Repo do 2 | use Ecto.Repo, otp_app: :espy 3 | 4 | @doc """ 5 | Dynamically loads the repository url from the 6 | DATABASE_URL environment variable. 7 | """ 8 | def init(_, opts) do 9 | {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/espy/watcher/__mock__/transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Watcher.Mock do 2 | 3 | def transaction do 4 | %{ 5 | "engine_result" => "tesSUCCESS", 6 | "engine_result_code" => 0, 7 | "engine_result_message" => "The transaction was applied. Only final in a validated ledger.", 8 | "ledger_hash" => "887EA4DFE99460F5A5A642F45BAEE33A5CE138B67EA69323FF0BC504960CB845", 9 | "ledger_index" => 45184774, 10 | "meta" => %{ 11 | "AffectedNodes" => [ 12 | %{ 13 | "ModifiedNode" => %{ 14 | "FinalFields" => %{ 15 | "Account" => "rNEygqkMv4Vnj8M2eWnYT1TDnV1Sc1X5SN", 16 | "Balance" => "471234064842", 17 | "Domain" => "6274637475726B2E636F6D", 18 | "Flags" => 131072, 19 | "OwnerCount" => 0, 20 | "Sequence" => 59012 21 | }, 22 | "LedgerEntryType" => "AccountRoot", 23 | "LedgerIndex" => "2D559E46C77FAFDE39F992FF332E32A2E34C4AC5320E3ABC3B2C24F5BD8385C7", 24 | "PreviousFields" => %{ 25 | "Balance" => "471349886438", 26 | "Sequence" => 59011 27 | }, 28 | "PreviousTxnID" => "AFCCC8270E6E8F2B49FA3B293158033997CB0919CA31CDADA1A57FBF714A36FF", 29 | "PreviousTxnLgrSeq" => 45184725 30 | } 31 | }, 32 | %{ 33 | "ModifiedNode" => %{ 34 | "FinalFields" => %{ 35 | "Account" => "rP1afBEfikTz7hJh2ExCDni9W4Bx1dUMRk", 36 | "Balance" => "376699318981", 37 | "Flags" => 131072, 38 | "OwnerCount" => 0, 39 | "Sequence" => 841 40 | }, 41 | "LedgerEntryType" => "AccountRoot", 42 | "LedgerIndex" => "628BCCF8592C8C2D861630192D758E712C0DC9B4101F89CE47EC4694B71B1E69", 43 | "PreviousFields" => %{"Balance" => "376583497695"}, 44 | "PreviousTxnID" => "1C05307E92E841DB3CC13ECCD5354B635E539E2F75297F184B869C9BEB204516", 45 | "PreviousTxnLgrSeq" => 45184724 46 | } 47 | } 48 | ], 49 | "TransactionIndex" => 6, 50 | "TransactionResult" => "tesSUCCESS" 51 | }, 52 | "status" => "closed", 53 | "transaction" => %{ 54 | "Account" => "rNEygqkMv4Vnj8M2eWnYT1TDnV1Sc1X5SN", 55 | "Amount" => "115821286", 56 | "Destination" => "rP1afBEfikTz7hJh2ExCDni9W4Bx1dUMRk", 57 | "DestinationTag" => 84201897, 58 | "Fee" => "310", 59 | "Flags" => 2147483648, 60 | "Sequence" => 59011, 61 | "SigningPubKey" => "02FC56A7601914BC0462E28994EF7F059D5A23AA17705BAD668E3E4EE00450FFB9", 62 | "TransactionType" => "Payment", 63 | "TxnSignature" => "3045022100B62D73409E327FB87F4D24A05523A93FA6684A39ACBE92784B16077C0DBBF036022048186886025CA1A50FF84A6CA0B17E19104EB747FFCA7F2D277638E66BB5BE17", 64 | "date" => 603665393, 65 | "hash" => "D8C12166A6ECCCE097B059168CF3DE869573D358F65DD6B687365F8CCB439CD6" 66 | }, 67 | "type" => "transaction", 68 | "validated" => true 69 | } 70 | end 71 | 72 | end 73 | -------------------------------------------------------------------------------- /lib/espy/watcher/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Watcher.Cache do 2 | use GenServer 3 | 4 | require Logger 5 | 6 | alias Espy.Gateway.{Subscription} 7 | 8 | def start_link(opts \\ []) do 9 | GenServer.start_link(__MODULE__, [ 10 | {:ets_table_name, :watcher_cache_table}, 11 | {:log_limit, 1_000_000} 12 | ], opts) 13 | end 14 | 15 | # Public 16 | # 17 | def fetch(slug) do 18 | case get(slug) do 19 | {:not_found} -> :not_found 20 | {:found, result} -> result 21 | end 22 | end 23 | 24 | def delete(slug, value) do 25 | case GenServer.call(__MODULE__, {:delete, slug, value}) do 26 | true -> :ok 27 | _ -> :ok 28 | end 29 | end 30 | 31 | 32 | # Private 33 | 34 | defp get(slug) do 35 | case GenServer.call(__MODULE__, {:get, slug}) do 36 | [] -> {:not_found} 37 | [{_slug, result}] -> {:found, result} 38 | end 39 | end 40 | 41 | def set(slug, value) do 42 | GenServer.call(__MODULE__, {:set, slug, value}) 43 | end 44 | 45 | # GenServer callbacks 46 | 47 | def handle_call({:get, slug}, _from, state) do 48 | %{ets_table_name: ets_table_name} = state 49 | result = :ets.lookup(ets_table_name, slug) 50 | {:reply, result, state} 51 | end 52 | 53 | def handle_call({:set, slug, value}, _from, state) do 54 | %{ets_table_name: ets_table_name} = state 55 | case :ets.lookup(ets_table_name, slug) do 56 | [] -> 57 | true = :ets.insert(ets_table_name, {slug, [value]}) 58 | [{_slug, current}] -> 59 | true = :ets.insert(ets_table_name, {slug, [ value | current ] }) 60 | end 61 | 62 | {:reply, value, state} 63 | end 64 | 65 | 66 | def handle_call({:delete, slug, value}, _from, state) do 67 | %{ets_table_name: ets_table_name} = state 68 | case :ets.lookup(ets_table_name, slug) do 69 | [] -> true 70 | [{_slug, current}] -> 71 | case length(current) do 72 | 1 -> 73 | result = :ets.delete(ets_table_name, slug) 74 | {:reply, result, state} 75 | _ -> 76 | new_value = List.delete(current, value) 77 | :ets.insert(ets_table_name, {slug, new_value}) 78 | {:reply, true, state} 79 | end 80 | end 81 | end 82 | 83 | 84 | def init(args) do 85 | [{:ets_table_name, ets_table_name}, {:log_limit, log_limit}] = args 86 | 87 | :ets.new(ets_table_name, [:named_table, :set, :private]) 88 | 89 | Logger.info("Loading all Subscriptions to the cache...", ansi_color: :light_blue) 90 | Enum.each Subscription.get_all, fn(s) -> 91 | case :ets.lookup(ets_table_name, s.address) do 92 | [] -> 93 | true = :ets.insert(ets_table_name, {s.address, [s.app_id]}) 94 | [{_slug, current}] -> 95 | true = :ets.insert(ets_table_name, {s.address, [ s.app_id | current ] }) 96 | end 97 | end 98 | 99 | {:ok, %{log_limit: log_limit, ets_table_name: ets_table_name}} 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/espy/watcher/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Watcher.Handler do 2 | require Logger 3 | 4 | alias Espy.Gateway.{App, Webhook, Subsciption} 5 | alias Espy.Watcher.{Cache, Logging} 6 | alias Espy.Adapter.{HTTPC} 7 | 8 | defp send_hook(webhooks , tx) do 9 | webhooks 10 | |> Enum.each( 11 | fn w -> 12 | {:ok, pid } = Task.Supervisor.start_child( 13 | Espy.Supervisor.HTTPC, 14 | HTTPC, 15 | :call, 16 | [%{ url: w.url, body: tx, callback: %{webhook: w.id, app: w.app_id, object: tx["transaction"]["hash"] }}] 17 | ) 18 | Logger.info "Request: #{w.url} on #{Kernel.inspect pid} - [ Parent: #{Kernel.inspect self()}]", ansi_color: :cyan 19 | end 20 | ) 21 | end 22 | 23 | defp check(address) do 24 | case Cache.fetch(address) do 25 | :not_found -> :not_found 26 | app_ids -> 27 | Enum.reduce(app_ids, [], 28 | fn v, n -> 29 | case App.get(v) do 30 | nil -> 31 | # App is not active then remove the Subsciption from cache 32 | Cache.delete(address, v) 33 | n 34 | app -> n ++ [app.id] 35 | end 36 | end 37 | ) 38 | end 39 | end 40 | 41 | defp get_availables(tx) do 42 | Enum.reduce([get_in(tx, ["transaction","Account"]), get_in(tx, ["transaction","Destination"])], [] , 43 | fn v, n -> 44 | case check(v) do 45 | :not_found -> n 46 | app_ids -> n ++ app_ids 47 | end 48 | end 49 | ) 50 | end 51 | 52 | def handle(tx) do 53 | # :timer.sleep(4000) 54 | case get_availables tx do 55 | [] -> true 56 | app_ids -> 57 | case Webhook.list_by_apps Enum.uniq(app_ids) do 58 | [] -> :no_webhook 59 | # Cache.delete(get_in(tx, ["transaction","Account"])) 60 | # Cache.delete(get_in(tx, ["transaction","Destination"])) 61 | webhooks -> send_hook(webhooks, tx) 62 | end 63 | end 64 | end 65 | 66 | end 67 | -------------------------------------------------------------------------------- /lib/espy/watcher/logging.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Watcher.Logging do 2 | use Ecto.Schema 3 | alias Espy.Repo 4 | import Ecto.Changeset 5 | import Ecto.Query, warn: false 6 | 7 | 8 | alias Espy.Watcher.Logging 9 | alias Espy.Gateway.{App, Webhook} 10 | 11 | @per_page 20 12 | 13 | schema "logging" do 14 | field :response_time, :integer 15 | field :response_status, :integer 16 | field :retry_count, :integer 17 | field :object_id, :string 18 | belongs_to :webhook, Webhook 19 | belongs_to :app, App 20 | 21 | timestamps() 22 | end 23 | 24 | @required_fields [:response_status, :response_time, :object_id, :retry_count, :webhook_id, :app_id] 25 | 26 | @doc false 27 | def changeset(activity, attrs) do 28 | activity 29 | |> cast(attrs, @required_fields) 30 | |> validate_required(@required_fields) 31 | end 32 | 33 | 34 | def create_log(attrs \\ %{}) do 35 | %Logging{} 36 | |> Logging.changeset(attrs) 37 | |> Repo.insert!() 38 | end 39 | 40 | def get_last_x_log(page, app_id) do 41 | offset = (page - 1 ) * @per_page 42 | Repo.all(from a in Logging, 43 | where: a.app_id == ^app_id, 44 | join: p in assoc(a, :webhook), where: p.deleted == false, 45 | order_by: [desc: a.id], 46 | limit: ^@per_page, 47 | offset: ^offset, 48 | preload: [:webhook] 49 | ) 50 | end 51 | 52 | def count(app_id) do 53 | Repo.all(from a in Logging, 54 | where: a.app_id == ^app_id, 55 | join: p in assoc(a, :webhook), where: p.deleted == false, 56 | select: count(a.id) 57 | ) 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /lib/espy/watcher/socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Watcher.Socket do 2 | require Logger 3 | use WebSockex 4 | 5 | alias Espy.Watcher.{Handler} 6 | 7 | 8 | @urls ["wss://rippled.xrptipbot.com:443", "wss://s1.ripple.com:443"] 9 | @command "subscribe" 10 | @streams ["transactions"] 11 | @only_handle ["Payment"] 12 | 13 | def start_link(_opts \\ []) do 14 | queue = @urls |> :queue.from_list 15 | {{:value, url}, queue} = :queue.out(queue) 16 | queue = :queue.in(url, queue) 17 | WebSockex.start_link(url, __MODULE__, %{queue: queue, url: url},[name: __MODULE__, handle_initial_conn_failure: true]) 18 | end 19 | 20 | defp filter_transaction(parsed) do 21 | try do 22 | transaction = Map.get(parsed, "transaction") 23 | engine_result = Map.get(parsed, "engine_result") 24 | 25 | cond do 26 | engine_result == "tesSUCCESS" -> 27 | case Map.get(transaction,"TransactionType") in @only_handle do 28 | true -> parsed 29 | _ -> :no_handler 30 | end 31 | true -> :no_handler 32 | end 33 | rescue 34 | _ -> IO.inspect "error" 35 | 36 | end 37 | end 38 | 39 | 40 | defp pass_to_handler(raw) do 41 | with {:ok , parsed } = {:ok, %{}} <- Poison.Parser.parse(raw) do 42 | case filter_transaction(parsed) do 43 | :no_handler -> true 44 | tx -> 45 | {:ok, pid } = Task.Supervisor.start_child( 46 | Espy.Supervisor.Handler, 47 | Handler, 48 | :handle, 49 | [tx] 50 | ) 51 | Logger.info "Handle: #{tx["transaction"]["hash"]} on #{Kernel.inspect pid}", ansi_color: :light_black 52 | end 53 | else 54 | {:error, reason} -> Logger.error reason 55 | end 56 | end 57 | 58 | def handle_connect(_conn, %{url: url} = state) do 59 | Logger.info "Connected to ripple server #{String.upcase(url)}", ansi_color: :green 60 | subscribe_to_streams self() 61 | timer_ref = Process.send_after(self(), :timeout, 20000) 62 | {:ok, Map.put(state, :timer_ref, timer_ref)} 63 | end 64 | 65 | def handle_frame({type, msg}, %{timer_ref: timer_ref} = state) do 66 | cancel_timer(timer_ref) 67 | pass_to_handler msg 68 | new_timer_ref = Process.send_after(self(), :timeout, 20000) 69 | {:ok, %{state | timer_ref: new_timer_ref}} 70 | end 71 | 72 | def handle_info(:timeout, state) do 73 | {:close, state} 74 | end 75 | 76 | def handle_cast({:send, {type, msg} = frame}, state) do 77 | {:reply, frame, state} 78 | end 79 | 80 | 81 | def handle_disconnect(%{reason: {:local, reason}}, %{timer_ref: timer_ref} = state) do 82 | cancel_timer(timer_ref) 83 | Logger.info("Local close with reason: #{inspect reason}") 84 | Logger.info("Reconnecting...") 85 | {:reconnect, state} 86 | end 87 | 88 | def handle_disconnect(%{reason: reson, conn: conn, attempt_number: attempt_number }, %{url: url, timer_ref: timer_ref} = state) when attempt_number < 5 do 89 | cancel_timer(timer_ref) 90 | Logger.info "Cannot Connect to #{String.upcase(url)} attempt #{attempt_number}", ansi_color: :red 91 | Logger.info("Retring after 3 sec ...") 92 | :timer.sleep(3000) 93 | {:reconnect, conn, state} 94 | end 95 | 96 | def handle_disconnect(%{reason: reson, conn: connn, attempt_number: attempt_number }, %{queue: queue} = state) do 97 | {{:value, head}, queue} = :queue.out(queue) 98 | Logger.info "Switching the endpoint #{head}", ansi_color: :red 99 | conn = WebSockex.Conn.new(head) 100 | queue = :queue.in(head, queue) 101 | 102 | state = Map.put(state, :queue, queue) 103 | state = Map.put(state, :url, head) 104 | 105 | {:reconnect, conn, state} 106 | end 107 | 108 | 109 | def terminate(reason, state) do 110 | Logger.info("Socket terminated with: #{inspect reason}") 111 | exit(:normal) 112 | end 113 | 114 | defp cancel_timer(ref) do 115 | case Process.cancel_timer(ref) do 116 | i when is_integer(i) -> :ok 117 | false -> 118 | receive do 119 | :timeout -> :ok 120 | after 121 | 0 -> :ok 122 | end 123 | end 124 | end 125 | 126 | def subscribe_to_streams(pid) do 127 | Logger.info("Sending subscribe request: streams=#{@streams}", ansi_color: :light_blue) 128 | data = %{command: @command, streams: @streams} |> Poison.encode!() 129 | Task.start fn -> WebSockex.send_frame(pid, {:text, data}) end 130 | end 131 | 132 | end 133 | -------------------------------------------------------------------------------- /lib/espy/watcher/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.Watcher.Supervisor do 2 | use Supervisor 3 | 4 | def start_link do 5 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 6 | end 7 | 8 | def init(:ok) do 9 | children = [ 10 | worker(Espy.Watcher.Socket, [[name: Espy.Watcher.Socket]]), 11 | worker(Espy.Watcher.Cache, [[name: Espy.Watcher.Cache]]), 12 | worker(Task.Supervisor, [[name: Espy.Supervisor.Handler]], id: :handler), 13 | worker(Task.Supervisor, [[name: Espy.Supervisor.HTTPC]], id: :httpc) 14 | ] 15 | 16 | supervise(children, strategy: :one_for_one) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/espy_web.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb 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 EspyWeb, :controller 9 | use EspyWeb, :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: EspyWeb 23 | use BlueBird.Controller 24 | import Plug.Conn 25 | import EspyWeb.Router.Helpers 26 | import EspyWeb.Gettext 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, root: "lib/web/templates", 33 | namespace: EspyWeb 34 | 35 | # Import convenience functions from controllers 36 | import Phoenix.Controller, only: [get_flash: 2, view_module: 1] 37 | 38 | # Use all HTML functionality (forms, tags, etc) 39 | use Phoenix.HTML 40 | 41 | import EspyWeb.Router.Helpers 42 | import EspyWeb.ErrorHelpers 43 | import EspyWeb.Gettext 44 | import EspyWeb.LinkHelper 45 | import EspyWeb.InputHelper 46 | end 47 | end 48 | 49 | def router do 50 | quote do 51 | use Phoenix.Router 52 | import Plug.Conn 53 | import Phoenix.Controller 54 | end 55 | end 56 | 57 | def channel do 58 | quote do 59 | use Phoenix.Channel 60 | import EspyWeb.Gettext 61 | end 62 | end 63 | 64 | @doc """ 65 | When used, dispatch to the appropriate controller/view/etc. 66 | """ 67 | defmacro __using__(which) when is_atom(which) do 68 | apply(__MODULE__, which, []) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/util/base58.ex: -------------------------------------------------------------------------------- 1 | 2 | defmodule Utils.Base58 do 3 | 4 | @moduledoc """ 5 | Base58Check encoding. 6 | 7 | Base58Check is used in Bitcoin addresses and WIF. 8 | It's a Base58 where additional 4 checksum bytes are appended to the payload 9 | before encoding (and stripped and checked when decoding). 10 | 11 | Checksum is first 4 bytes from the double sha256 of the payload. 12 | """ 13 | 14 | # Base58 alphabet, without 0,O,l,I 15 | @code 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz' 16 | @code_0 @code |> List.first 17 | 18 | # optimization to avoid browsing code list each time, with this we have O(1) 19 | @num_to_code @code |> Enum.with_index |> Enum.map(fn {k,v} -> {v,k} end) |> Enum.into(%{}) 20 | @code_to_num @code |> Enum.with_index |> Enum.into(%{}) 21 | 22 | @doc """ 23 | Encode binary into Base58Check. 24 | """ 25 | @spec encode(binary) :: String.t 26 | def encode(payload) do 27 | payload 28 | |> Binary.append(payload |> checksum) 29 | |> base_encode 30 | end 31 | 32 | @doc """ 33 | Decode Base58Check string into binary. 34 | 35 | Returns `{:ok, binary}` tuple in case of success, otherwise an `{:error, err}` tuple. 36 | """ 37 | @spec decode(String.t) :: {:ok, binary} | {:error, term} 38 | def decode(string) do 39 | with {:ok, bin} <- base_decode(string), 40 | {:ok, payload} <- validate_checksum(bin), 41 | do: {:ok, payload} 42 | end 43 | 44 | @doc """ 45 | Just like `decode/1` but raises exception in case of an error. 46 | """ 47 | @spec decode!(String.t) :: binary 48 | def decode!(string) do 49 | {:ok, payload} = string |> decode 50 | payload 51 | end 52 | 53 | @doc """ 54 | Returns true if the string is a valid Base58Check encoding. 55 | """ 56 | @spec valid?(String.t) :: boolean 57 | def valid?(string) do 58 | # we need to decode anyway to validate the checksum 59 | {result, _payload} = string |> decode 60 | result == :ok 61 | end 62 | 63 | @doc """ 64 | Encode binary payload in Base58. 65 | """ 66 | @spec base_encode(binary) :: String.t 67 | def base_encode(payload) 68 | 69 | # Convert leading zeros separately, because they would be lost in to_integer conversion 70 | def base_encode(<<0>> <> payload) when byte_size(payload) > 0, do: base_encode(<<0>>) <> base_encode(payload) 71 | # Handle special case because "" would be interpreted as 0, same as <<0>> 72 | def base_encode(""), do: "" 73 | # Actual Base58 encoding 74 | def base_encode(payload) do 75 | payload 76 | |> Binary.to_integer 77 | |> Integer.digits(58) 78 | |> Enum.map(& @num_to_code[&1]) 79 | |> Binary.from_list 80 | end 81 | 82 | 83 | @doc """ 84 | Decode Base58 encoded string into binary. 85 | 86 | Returns `{:ok, binary}` if decoding was successful or `{:error, :invalid_character}` if some 87 | character outside the alphabet was found. 88 | """ 89 | @spec base_decode(String.t) :: {:ok, binary} | {:error, term} 90 | def base_decode(string) do 91 | case base_valid?(string) do 92 | true -> {:ok, string |> base_decode!} 93 | false -> {:error, :invalid_character} 94 | end 95 | end 96 | 97 | @doc """ 98 | Same as `base_decode/1` but returns binary without the tuple and raisse exception in case of an error. 99 | """ 100 | @spec base_decode!(String.t) :: binary 101 | def base_decode!(string) 102 | 103 | # Append base_decoded zeros separately, otherwise they would be lost in From_integer conversion 104 | def base_decode!(<<@code_0>> <> string) when byte_size(string) > 0, do: base_decode!(<<@code_0>>) <> base_decode!(string) 105 | # Handle special case because Integer.undigits([]) == 0 106 | def base_decode!(""), do: "" 107 | # Actual Base58 decoding 108 | def base_decode!(string) do 109 | string 110 | |> Binary.to_list 111 | |> Enum.map(& @code_to_num[&1]) 112 | |> Integer.undigits(58) 113 | |> Binary.from_integer 114 | end 115 | 116 | @doc """ 117 | Check if the string is a valid Base58 encoding. 118 | """ 119 | @spec base_valid?(String.t) :: boolean 120 | def base_valid?(string) 121 | 122 | def base_valid?(""), do: true 123 | def base_valid?(<> <> string), do: char in @code && base_valid?(string) 124 | 125 | @spec validate_checksum(binary) :: {:ok, binary} | {:error, :invalid_checksum} 126 | defp validate_checksum(bin) do 127 | {payload, checksum} = Binary.split_at(bin, -4) 128 | if checksum(payload) == checksum, do: {:ok, payload}, else: {:error, :invalid_checksum} 129 | end 130 | 131 | defp double_sha256(data), do: :crypto.hash(:sha256, :crypto.hash(:sha256, data)) 132 | 133 | 134 | defp checksum(payload), do: payload |> double_sha256 |> Binary.take(4) 135 | 136 | end 137 | -------------------------------------------------------------------------------- /lib/web/controllers/api/v1/subscription_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.Api.SubscriptionController do 2 | use EspyWeb, :controller 3 | 4 | alias Espy.Gateway.{Webhook, Subscription} 5 | 6 | alias Espy.Watcher.{Cache} 7 | 8 | apigroup("Subscriptions", "") 9 | 10 | api :POST, "/api/v1/subscriptions" do 11 | title "Subscribes address to app" 12 | description "Subscribes the provided app all events for the provided address for all transaction types. After activation, all transactions for the requesting address will be sent to the provided webhook id via POST request." 13 | parameter :address, :string, [description: "Valid XRP address"] 14 | end 15 | 16 | def create(conn, %{"address" => address}) do 17 | app = conn.assigns.app 18 | 19 | case Subscription.can_add(app) do 20 | :can_add -> 21 | params = %{app_id: app.id, address: address} 22 | case Subscription.create(params) do 23 | {:ok, subscription} -> 24 | # set new subscription to Watcher Cache 25 | Cache.set(address, app.id) 26 | # return response 27 | json conn, %{success: true, subscription_id: subscription.subscription_id} 28 | {:exist, subscription} -> 29 | # return response 30 | json conn, %{success: true, subscription_id: subscription.subscription_id} 31 | {:error, %Ecto.Changeset{} = changeset } -> 32 | conn 33 | |> put_status(:unprocessable_entity) 34 | |> put_view(EspyWeb.ErrorView) 35 | |> render("error.json", changeset: changeset) 36 | end 37 | error -> 38 | conn 39 | |> put_status(:unprocessable_entity) 40 | |> json(%{success: false, error: error }) 41 | end 42 | 43 | end 44 | 45 | api :GET, "/api/v1/subscriptions" do 46 | title "Return all Subscriptions belong to app" 47 | description "Returns a list of the current Activity type subscriptions." 48 | end 49 | 50 | def list(conn, _params) do 51 | app = conn.assigns.app 52 | subscriptions = Subscription.list_by_app(app.id) 53 | render(conn, "index.json", app_id: app.app_id, subscriptions: subscriptions) 54 | end 55 | 56 | api :DELETE, "/api/v1/subscriptions/:subscription_id" do 57 | title "Delete subscription" 58 | description "Deactivates subscription(s) for the provided subscription ID and application for all activities. After deactivation, all events for the requesting subscription_id will no longer be sent to the webhook URL." 59 | note "The subscription ID can be accessed by making a call to GET /api/v1/subscriptions." 60 | parameter :subscription_id, :number, [description: "Subscriptions ID to deactivation"] 61 | end 62 | 63 | 64 | def delete(conn, %{"subscription_id" => subscription_id}) do 65 | app_id = conn.assigns.app.id 66 | params = %{app_id: app_id, subscription_id: subscription_id} 67 | case Subscription.delete(params) do 68 | {:ok, struct} -> 69 | # Remove address from cache 70 | Cache.delete(struct.address, app_id) 71 | # Response 72 | conn 73 | |> put_status(:no_content) 74 | |> json(%{success: true}) 75 | {:error, :not_found} -> 76 | conn 77 | |> put_status(:not_found) 78 | |> json(%{success: false}) 79 | {:error, %Ecto.Changeset{} = changeset } -> 80 | conn 81 | |> put_status(:unprocessable_entity) 82 | |> put_view(EspyWeb.ErrorView) 83 | |> render("error.json", changeset: changeset) 84 | end 85 | end 86 | 87 | end 88 | -------------------------------------------------------------------------------- /lib/web/controllers/api/v1/webhook_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.Api.WebhookController do 2 | use EspyWeb, :controller 3 | 4 | alias Espy.Gateway.{Webhook} 5 | 6 | 7 | apigroup("Webhooks", "") 8 | 9 | api :POST, "/api/v1/webhooks" do 10 | title "Registers webhook URL" 11 | description "Registers a webhook URL for all event types." 12 | note "The URL will be validated via CRC request before saving. In case the validation failed, returns comprehensive error message to the requester." 13 | parameter :url, :string, [description: "Encoded URL for the callback endpoint."] 14 | end 15 | 16 | def create(conn, %{"url" => url}) do 17 | app = conn.assigns.app 18 | 19 | 20 | case Webhook.can_add(app) do 21 | :can_add -> 22 | params = %{app_id: app.id, url: url, deleted: false} 23 | case Webhook.create(params) do 24 | {:ok, hook} -> json conn, %{success: true, webhook_id: hook.hook_id} 25 | {:error, %Ecto.Changeset{} = changeset } -> 26 | conn 27 | |> put_status(:unprocessable_entity) 28 | |> put_view(EspyWeb.ErrorView) 29 | |> render("error.json", changeset: changeset) 30 | end 31 | error -> 32 | conn 33 | |> put_status(:unprocessable_entity) 34 | |> json(%{success: false, error: error }) 35 | end 36 | end 37 | 38 | api :GET, "/api/v1/webhooks" do 39 | title "Return all webhooks" 40 | description "Returns all webhook URLs and their statuses for the authenticating app" 41 | note "The URL will be validated via CRC request before saving. In case the validation failed, returns comprehensive error message to the requester." 42 | end 43 | 44 | def list(conn, _params) do 45 | app = conn.assigns.app 46 | webhooks = Webhook.list_by_app(app.id) 47 | render(conn, "index.json", app_id: app.app_id, webhooks: webhooks) 48 | end 49 | 50 | api :DELETE, "/api/v1/webhooks/:webhook_id" do 51 | title "Delete webhook" 52 | description "Removes the webhook from the provided application's all subscription configuration." 53 | note "The webhook ID can be accessed by making a call to GET /api/v1/webhooks." 54 | parameter :webhook_id, :number, [description: "Webhook ID to delete"] 55 | end 56 | 57 | 58 | def delete(conn, %{"webhook_id" => webhook_id}) do 59 | app = conn.assigns.app 60 | case Webhook.can_delete(app) do 61 | :can_delete -> 62 | params = %{app_id: app.id, hook_id: webhook_id} 63 | case Webhook.delete(params) do 64 | {:ok, struct} -> 65 | conn 66 | |> put_status(:no_content) 67 | |> json(%{success: true}) 68 | {:error, :not_found} -> 69 | conn 70 | |> put_status(:not_found) 71 | |> json(%{success: false}) 72 | {:error, %Ecto.Changeset{} = changeset } -> 73 | conn 74 | |> put_status(:unprocessable_entity) 75 | |> put_view(EspyWeb.ErrorView) 76 | |> render("error.json", changeset: changeset) 77 | end 78 | error -> 79 | conn 80 | |> put_status(:unprocessable_entity) 81 | |> json(%{success: false, error: error }) 82 | end 83 | end 84 | 85 | end 86 | -------------------------------------------------------------------------------- /lib/web/controllers/app_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.AppController do 2 | use EspyWeb, :controller 3 | 4 | alias Espy.Gateway.{App} 5 | alias Espy.Watcher.{Logging} 6 | 7 | def dashboard(conn, _params) do 8 | user_id = conn.assigns.current_user.id 9 | apps = App.user_apps(user_id) 10 | render(conn, "dashboard.html", apps: apps) 11 | end 12 | 13 | def logs(conn, %{"id" => id} = params) do 14 | user_id = conn.assigns.current_user.id 15 | app = App.get!(id, user_id) 16 | 17 | page = params 18 | |> Map.get("page", "1") 19 | |> String.to_integer 20 | 21 | logs = Logging.get_last_x_log(page, app.id) 22 | count = Logging.count(app.id) |> hd 23 | render(conn, "logs.html", logs: logs, id: id, count: count, page: page) 24 | end 25 | 26 | 27 | def create(conn, params) when params == %{} do 28 | changeset = App.change(%App{}) 29 | render(conn, "create_app.html", changeset: changeset) 30 | end 31 | 32 | def create(conn, %{"app" => params}) do 33 | user = conn.assigns.current_user 34 | # check for user app limit 35 | # TODO: need to write cleaner code 36 | if App.check_limit(user.id) do 37 | conn 38 | |> put_flash(:error, "Private app limit reached, you cannot create more apps.") 39 | |> redirect(to: "/app/dashboard") 40 | else 41 | params = Map.put(params, "user_id", user.id) 42 | case App.create(params) do 43 | {:ok, _app} -> 44 | conn 45 | |> put_flash(:info, "App created successfully") 46 | |> redirect(to: "/app/dashboard") 47 | {:error, %Ecto.Changeset{} = changeset} -> 48 | conn 49 | |> render("create_app.html", changeset: changeset) 50 | end 51 | end 52 | end 53 | 54 | def show(conn, %{"id" => id}) do 55 | user_id = conn.assigns.current_user.id 56 | app = App.get!(id, user_id) 57 | changeset = App.change(app) 58 | render(conn, "show.html", app: app, changeset: changeset) 59 | end 60 | 61 | def update(conn, %{"id" => id, "app" => params}) do 62 | user_id = conn.assigns.current_user.id 63 | app = App.get!(id, user_id) 64 | case App.update(app, params) do 65 | {:ok, _app} -> 66 | conn 67 | |> put_flash(:info, "App updated successfully") 68 | |> redirect(to: app_path(conn, :show, id)) 69 | {:error, %Ecto.Changeset{} = changeset} -> 70 | IO.inspect(changeset) 71 | conn 72 | |> render("show.html", app: app, changeset: changeset) 73 | end 74 | end 75 | 76 | 77 | def regenerate(conn, %{"id" => id}) do 78 | user_id = conn.assigns.current_user.id 79 | app = App.regenerate(id, user_id) 80 | conn 81 | |> put_flash(:info, "New keys successfully generated.") 82 | |> redirect(to: app_path(conn, :show, id)) 83 | end 84 | 85 | end 86 | -------------------------------------------------------------------------------- /lib/web/controllers/auth_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.AuthController do 2 | @moduledoc """ 3 | Auth controller responsible for handling Ueberauth responses 4 | """ 5 | 6 | use EspyWeb, :controller 7 | plug Ueberauth 8 | 9 | alias Ueberauth.Strategy.Helpers 10 | 11 | alias Espy.Account 12 | 13 | def request(conn, _params) do 14 | render(conn, "request.html", callback_url: Helpers.callback_url(conn)) 15 | end 16 | 17 | def delete(conn, _params) do 18 | conn 19 | |> put_flash(:info, "You have been logged out!") 20 | |> configure_session(drop: true) 21 | |> redirect(to: "/") 22 | end 23 | 24 | def callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do 25 | conn 26 | |> put_flash(:error, "Failed to authenticate.") 27 | |> redirect(to: "/") 28 | end 29 | 30 | def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do 31 | case Account.find_or_create(auth) do 32 | {:ok, user} -> 33 | conn 34 | |> put_flash(:info, "Successfully authenticated.") 35 | |> put_session(:current_user_id, user.id) 36 | |> redirect(to: "/app/dashboard") 37 | {:error, reason} -> 38 | conn 39 | |> put_flash(:error, reason) 40 | |> redirect(to: "/") 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.PageController do 2 | use EspyWeb, :controller 3 | 4 | 5 | def index(conn, _params) do 6 | conn 7 | |> render("index.html") 8 | end 9 | 10 | def docs(conn, _params) do 11 | conn 12 | |> redirect(to: "/docs/index.html") 13 | |> halt 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/web/controllers/subscription_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.SubscriptionController do 2 | use EspyWeb, :controller 3 | 4 | alias Espy.Gateway.{Webhook, Subscription, App} 5 | 6 | alias Espy.Watcher.{Cache} 7 | 8 | def list(conn, %{"id" => id }) do 9 | user_id = conn.assigns.current_user.id 10 | app = App.get!(id, user_id) 11 | subscriptions = Subscription.list_by_app(app.id) 12 | changeset = Subscription.change(%Subscription{}) 13 | render(conn, "list.html", subscriptions: subscriptions, app: app,changeset: changeset) 14 | end 15 | 16 | def create(conn, %{"subscription" => %{"address" => address}} = params) do 17 | user = conn.assigns.current_user 18 | app = App.get!(Map.get(params, "id"), user.id) 19 | 20 | 21 | case Subscription.can_add(app) do 22 | :can_add -> subscription = %{app_id: app.id, address: address} 23 | case Subscription.create(subscription) do 24 | {:ok, subscription} -> 25 | # set new subscription to Watcher Cache 26 | Cache.set(address, app.id) 27 | # return response 28 | conn 29 | |> put_flash(:info, "Subscription added successfully") 30 | |> redirect(to: subscription_path(conn, :list, app.app_id )) 31 | {:exist, subscription} -> 32 | # return response 33 | conn 34 | |> put_flash(:info, "Subscription already exist.") 35 | |> redirect(to: subscription_path(conn, :list, app.app_id )) 36 | {:error, %Ecto.Changeset{} = changeset} -> 37 | conn 38 | |> put_flash(:error, "Please enter a valid Ripple Address") 39 | |> redirect(to: subscription_path(conn, :list, app.app_id )) 40 | end 41 | error -> 42 | conn 43 | |> put_flash(:error, error) 44 | |> redirect(to: subscription_path(conn, :list, app.app_id )) 45 | end 46 | 47 | end 48 | 49 | def delete(conn, %{"id" => id, "subscription_id" => subscription_id}) do 50 | user_id = conn.assigns.current_user.id 51 | app = App.get!(id, user_id) 52 | case Subscription.delete(%{subscription_id: subscription_id, app_id: app.id}) do 53 | {:ok, struct} -> 54 | # Remove address from cache 55 | Cache.delete(struct.address, app.id) 56 | _ -> true 57 | end 58 | redirect(conn, to: subscription_path(conn, :list, app.app_id)) 59 | end 60 | 61 | end 62 | -------------------------------------------------------------------------------- /lib/web/controllers/webhook_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.WebhookController do 2 | use EspyWeb, :controller 3 | 4 | alias Espy.Gateway.{Webhook, App} 5 | 6 | alias Espy.Adapter.{HTTPC} 7 | 8 | alias Espy.Watcher.Mock 9 | 10 | def list(conn, %{"id" => id }) do 11 | user_id = conn.assigns.current_user.id 12 | app = App.get!(id, user_id) 13 | webhooks = Webhook.list(app.id) 14 | changeset = Webhook.change(%Webhook{}) 15 | render(conn, "list.html", webhooks: webhooks, app: app,changeset: changeset) 16 | end 17 | 18 | def create(conn, %{"webhook" => %{"url" => url}} = params) do 19 | user_id = conn.assigns.current_user.id 20 | app = App.get!(Map.get(params, "id"), user_id) 21 | 22 | case Webhook.can_add(app) do 23 | :can_add -> 24 | webhook = %{app_id: app.id, url: url, deleted: false} 25 | case Webhook.create(webhook) do 26 | {:ok, _webhook} -> 27 | conn 28 | |> put_flash(:info, "Webhook created successfully") 29 | |> redirect(to: webhook_path(conn, :list, app.app_id )) 30 | {:error, %Ecto.Changeset{} = changeset} -> 31 | IO.inspect(changeset) 32 | conn 33 | |> put_flash(:error, "Please enter a valid webhook URL") 34 | |> redirect(to: webhook_path(conn, :list, app.app_id )) 35 | end 36 | error -> 37 | conn 38 | |> put_flash(:error, error) 39 | |> redirect(to: webhook_path(conn, :list, app.app_id )) 40 | end 41 | end 42 | 43 | def delete(conn, %{"id" => id, "webhook_id" => webhook_id}) do 44 | user_id = conn.assigns.current_user.id 45 | app = App.get!(id, user_id) 46 | 47 | case Webhook.can_delete(app) do 48 | :can_delete -> 49 | Webhook.delete(%{"hook_id": webhook_id, "app_id": app.id}) 50 | redirect(conn, to: webhook_path(conn, :list, app.app_id)) 51 | error -> 52 | conn 53 | |> put_flash(:error, error) 54 | |> redirect(to: webhook_path(conn, :list, app.app_id )) 55 | end 56 | 57 | end 58 | 59 | def trigger(conn, %{"id" => id, "webhook_id" => webhook_id}) do 60 | user_id = conn.assigns.current_user.id 61 | app = App.get!(id, user_id) 62 | webhook = Webhook.get!(webhook_id, app.id) 63 | case Hammer.check_rate("webhook_trigger:#{user_id}", 60_000, 5) do 64 | {:allow, _count} -> 65 | tx = Mock.transaction 66 | Task.Supervisor.start_child( 67 | Espy.Supervisor.HTTPC, 68 | HTTPC, 69 | :call, 70 | [%{ url: webhook.url, body: tx ,callback: nil}] 71 | ) 72 | conn 73 | |> put_flash(:info, "A sample POST request just sent to the webhook URL.") 74 | |> redirect(to: webhook_path(conn, :list, app.app_id)) 75 | {:deny, _limit} -> 76 | conn 77 | |> put_flash(:error, "Rate limit exceeded, please wait while and try again.") 78 | |> redirect(to: webhook_path(conn, :list, app.app_id )) 79 | end 80 | end 81 | 82 | end 83 | -------------------------------------------------------------------------------- /lib/web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :espy 3 | 4 | # socket "/socket", EspyWeb.UserSocket 5 | 6 | # Serve at "/" the static files from "priv/static" directory. 7 | # 8 | # You should set gzip to true if you are running phoenix.digest 9 | # when deploying your static files in production. 10 | plug Plug.Static, 11 | at: "/", from: :espy, gzip: true, 12 | only_matching: ~w(css img fonts assets images js favicon.ico robots.txt docs) 13 | 14 | # Code reloading can be explicitly enabled under the 15 | # :code_reloader configuration of your endpoint. 16 | if code_reloading? do 17 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 18 | plug Phoenix.LiveReloader 19 | plug Phoenix.CodeReloader 20 | end 21 | 22 | plug Plug.Logger 23 | 24 | plug Plug.Parsers, 25 | parsers: [:urlencoded, :multipart, :json], 26 | pass: ["*/*"], 27 | json_decoder: Poison 28 | 29 | plug Plug.MethodOverride 30 | plug Plug.Head 31 | 32 | # The session will be stored in the cookie and signed, 33 | # this means its contents can be read but not tampered with. 34 | # Set :encryption_salt if you would also like to encrypt it. 35 | plug Plug.Session, 36 | store: :cookie, 37 | key: "_espy_key", 38 | signing_salt: "23VfcC7o" 39 | 40 | plug EspyWeb.Router 41 | 42 | @doc """ 43 | Callback invoked for dynamically configuring the endpoint. 44 | 45 | It receives the endpoint configuration and checks if 46 | configuration should be loaded from the system environment. 47 | """ 48 | def init(_key, config) do 49 | if config[:load_from_system_env] do 50 | port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" 51 | {:ok, Keyword.put(config, :http, [:inet6, port: port])} 52 | else 53 | {:ok, config} 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import EspyWeb.Gettext 9 | 10 | # Simple translation 11 | gettext "Here is the string to translate" 12 | 13 | # Plural translation 14 | ngettext "Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3 17 | 18 | # Domain-based translation 19 | dgettext "errors", "Here is the error message to translate" 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :espy 24 | end 25 | -------------------------------------------------------------------------------- /lib/web/helper.ex: -------------------------------------------------------------------------------- 1 | 2 | defmodule EspyWeb.LinkHelper do 3 | @doc """ 4 | Calls `active_link/3` with a class of "active" 5 | """ 6 | def active_link(conn, controllers) do 7 | active_link(conn, controllers, "active") 8 | end 9 | 10 | @doc """ 11 | Returns the string in the 3rd argument if the expected controller 12 | matches the Phoenix controller that is extracted from conn. If no 3rd 13 | argument is passed in then it defaults to "active". 14 | 15 | The 2nd argument can also be an array of controllers that should 16 | return the active class. 17 | """ 18 | def active_link(conn, controllers, class) when is_list(controllers) do 19 | if Enum.member?(controllers, Phoenix.Controller.controller_module(conn)) do 20 | class 21 | else 22 | "" 23 | end 24 | end 25 | 26 | def active_link(conn, controller, class) do 27 | active_link(conn, [controller], class) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/web/plugs/api_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.Plugs.ApiAuthenticate do 2 | 3 | import Plug.Conn, only: [assign: 3 ,get_req_header: 2, put_status: 2, halt: 1] 4 | import Phoenix.Controller, only: [json: 2] 5 | 6 | alias Espy.Gateway.{App} 7 | 8 | def init( opts ), do: opts 9 | 10 | def call(conn, _) do 11 | case authorize conn do 12 | {:ok, app} -> 13 | Logger.metadata(app_id: app.id) 14 | conn 15 | |> assign(:app, app) 16 | {:error, message} -> 17 | conn 18 | |> put_status(:unauthorized) 19 | |> json(%{error: true, message: message}) 20 | |> halt 21 | 22 | end 23 | end 24 | 25 | defp authorize( conn ) do 26 | api_key = get_req_header( conn, "x-api-key" ) |> List.first 27 | api_secret = get_req_header( conn, "x-api-secret" ) |> List.first 28 | 29 | case is_nil(api_key) or is_nil(api_secret) do 30 | true -> {:error, "AUTH HEADERS REQUIRED"} 31 | _ -> authorize_tokens(api_key, api_secret) 32 | end 33 | end 34 | 35 | defp authorize_tokens(api_key, api_secret) do 36 | case App.get_by_token(api_key, api_secret) do 37 | nil -> {:error , "UNAUTORIZED ACCESS"} 38 | app -> 39 | case app.active and !app.deleted do 40 | true -> {:ok, app } 41 | false -> {:error , "APP NOTFOUND"} 42 | end 43 | end 44 | end 45 | 46 | 47 | end 48 | -------------------------------------------------------------------------------- /lib/web/plugs/protected.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.Plugs.Protected do 2 | 3 | import Plug.Conn, only: [ halt: 1] 4 | import Phoenix.Controller, only: [ redirect: 2, put_flash: 3] 5 | 6 | def init( opts ), do: opts 7 | 8 | def call(conn, _) do 9 | case authorize conn do 10 | false -> 11 | conn 12 | |> put_flash(:error, "You need to login to access this page.") 13 | |> redirect(to: "/") 14 | |> halt 15 | true -> conn 16 | end 17 | end 18 | 19 | defp authorize( conn ) do 20 | conn.assigns.user_signed_in? 21 | end 22 | end 23 | 24 | -------------------------------------------------------------------------------- /lib/web/plugs/web_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.Plugs.WebAuthenticate do 2 | import Plug.Conn 3 | 4 | alias Espy.Repo 5 | alias Espy.Account.User 6 | 7 | def init(_params) do 8 | end 9 | 10 | def call(conn, _params) do 11 | user_id = Plug.Conn.get_session(conn, :current_user_id) 12 | 13 | cond do 14 | current_user = user_id && Repo.get(User, user_id) -> 15 | Logger.metadata(user_id: current_user.id) 16 | conn 17 | |> assign(:current_user, current_user) 18 | |> assign(:user_signed_in?, true) 19 | true -> 20 | conn 21 | |> assign(:current_user, nil) 22 | |> assign(:user_signed_in?, false) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.Router do 2 | use EspyWeb, :router 3 | 4 | # -----------------pipeline ---------------- 5 | 6 | pipeline :browser do 7 | plug :accepts, ["html"] 8 | plug :fetch_session 9 | plug :fetch_flash 10 | plug :protect_from_forgery 11 | plug :put_secure_browser_headers 12 | plug EspyWeb.Plugs.WebAuthenticate 13 | end 14 | 15 | pipeline :api do 16 | plug :accepts, ["json"] 17 | plug EspyWeb.Plugs.ApiAuthenticate 18 | end 19 | 20 | 21 | pipeline :protected do 22 | plug EspyWeb.Plugs.Protected 23 | end 24 | 25 | 26 | # ----------------- scope route ---------------- 27 | 28 | scope "/", EspyWeb do 29 | pipe_through [:browser] 30 | 31 | get "/", PageController, :index 32 | get "/docs", PageController, :docs 33 | get "/login", PageController, :login 34 | 35 | # oauth 36 | get "/auth/:provider", AuthController, :request 37 | get "/auth/:provider/callback", AuthController, :callback 38 | get "/signout", AuthController, :delete 39 | end 40 | 41 | 42 | # Definitely logged in scope 43 | scope "/app", EspyWeb do 44 | pipe_through [:browser, :protected] 45 | 46 | # App controllers 47 | get "/dashboard", AppController, :dashboard 48 | get "/new", AppController, :create 49 | post "/new", AppController, :create 50 | get "/:id/details", AppController, :show 51 | put "/:id/details", AppController, :update 52 | get "/:id/regenerate", AppController, :regenerate 53 | get "/:id/logs", AppController, :logs 54 | get "/:id/logs/:page", AppController, :logs 55 | 56 | # Webhook controllers 57 | get "/:id/webhooks", WebhookController, :list 58 | post "/:id/webhooks", WebhookController, :create 59 | get "/:id/webhooks/:webhook_id/delete", WebhookController, :delete 60 | get "/:id/webhooks/:webhook_id/trigger", WebhookController, :trigger 61 | 62 | # Webhook controllers 63 | get "/:id/subscriptions", SubscriptionController, :list 64 | post "/:id/subscriptions", SubscriptionController, :create 65 | get "/:id/subscriptions/:subscription_id/delete", SubscriptionController, :delete 66 | 67 | end 68 | 69 | 70 | scope "/api/v1", EspyWeb, as: :api do 71 | pipe_through :api 72 | 73 | post "/webhooks", Api.WebhookController, :create 74 | get "/webhooks", Api.WebhookController, :list 75 | delete "/webhooks/:webhook_id", Api.WebhookController, :delete 76 | 77 | post "/subscriptions", Api.SubscriptionController, :create 78 | get "/subscriptions", Api.SubscriptionController, :list 79 | delete "/subscriptions/:subscription_id", Api.SubscriptionController, :delete 80 | 81 | 82 | end 83 | 84 | 85 | end 86 | -------------------------------------------------------------------------------- /lib/web/templates/app/create_app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

New App

15 |

To setup Webhook endpoints and monitor XRPL accounts to receive hooks, you'll have to create an App.

16 |

After creating the App, you can continue setting up your hooks.

17 |
18 | 19 |
20 | <%= form_for @changeset, "/app/new", [class: "create-app-form"], fn f -> %> 21 | 22 |
23 | <%= input f, :name, using: :text_input , placeholder: "App name" %> 24 | <%= error_tag f, :name %> 25 |
26 | 27 |
28 | <%= input f, :url, using: :text_input, placeholder: "App url" %> 29 | <%= error_tag f, :url %> 30 |
31 | 32 |
33 | <%= input f, :description, using: :textarea, placeholder: "App description" %> 34 | <%= error_tag f, :description %> 35 |
36 | 37 | 38 | 39 | <% end %> 40 |
41 |
42 | -------------------------------------------------------------------------------- /lib/web/templates/app/dashboard.html.eex: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 |
10 |
11 | Create App 12 |
13 |
14 |
15 |
16 |
17 | <%= if not Enum.empty?(@apps) do %> 18 | 19 | 20 | <%= for app <- @apps do %> 21 | 22 | 27 | 28 | 41 | 42 | 43 | <% end %> 44 | 45 |
23 |

24 | <%= app.name %> 25 |

26 |
App Id
<%= app.app_id %>
29 | 39 | Details 40 |
46 | <%= else %> 47 | 48 |
49 |
50 | 51 | No Apps have been created yet. 52 |
53 |
54 | <%= end %> 55 | 56 |
57 |
58 | 59 | -------------------------------------------------------------------------------- /lib/web/templates/app/logs.html.eex: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 |
11 |
12 | <%= if not Enum.empty?(@logs) do %> 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | <%= for log <- @logs do %> 28 | 33 | 34 | 35 | 36 | 37 | 44 | 45 | 46 | 47 | 48 | <% end %> 49 | 50 |
IDObject IDWebhook IDURLStatusResponse TimeRetriesLogged at
#<%= log.id %><%= String.slice(log.object_id, 0, 15) %>...<%= log.webhook.hook_id %><%= log.webhook.url %> 38 | <%= if div(log.response_status, 100) != 2 do %> 39 |  <%= log.response_status %> 40 | <% else %> 41 | <%= log.response_status %> 42 | <% end %> 43 | <%= log.response_time / 1000000%> sec<%= log.retry_count %><%= log.inserted_at %>
51 |
52 | <%= if @count > 20 do %> 53 | 70 | <% end %> 71 | <%= else %> 72 | 73 |
74 |
75 | 76 | No logs have been created yet 77 |
78 |
79 | <%= end %> 80 | 81 |
82 |
83 | 84 | -------------------------------------------------------------------------------- /lib/web/templates/app/show.html.eex: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 |
11 |
12 | 20 |
21 |
22 | 23 |
24 |
25 |

App details

26 |
Set your App details for your own administration (and potential review)
27 |
28 | <%= form_for @changeset, app_path(@conn, :show, @app.app_id), [class: "update-app-form"], fn f -> %> 29 | 30 |
31 | <%= input f, :name, using: :text_input %> 32 | <%= error_tag f, :name %> 33 |
34 | 35 |
36 | <%= input f, :url, using: :text_input %> 37 | <%= error_tag f, :url %> 38 |
39 | 40 |
41 | <%= input f, :description, using: :textarea %> 42 | <%= error_tag f, :description %> 43 |
44 | 45 | 46 | 47 | <% end %> 48 | 49 |
50 |
51 |
52 |
53 |

App API keys

54 |
The keys below are required when calling the API.
55 |
56 |
Consumer API keys
57 |
    58 |
  • 59 |
    API Key
    60 |

    <%= @app.api_key %>

    61 |
  • 62 |
  • 63 |
    API Secret
    64 |

    <%= @app.api_secret %>

    65 |
  • 66 |
67 |
68 |

Regenerate

69 |
If your keys have been compromised, you can re-generate your keys. Please update your application after re-generating your keys.
70 | Regenerate 71 |
72 |
73 |
74 |
75 | 76 | 77 |
78 |
79 | -------------------------------------------------------------------------------- /lib/web/templates/auth/request.html: -------------------------------------------------------------------------------- 1 | <%= form_tag @callback_url, method: "post" do %> 2 |
3 | 4 | 5 |
6 | 7 |
8 | 9 | 10 |
11 | 12 |
13 | 14 | 15 |
16 | 17 |
18 | 19 | 20 |
21 | 22 |
23 | 24 | 25 |
26 | 27 |
28 | 29 | 30 |
31 | 32 |
33 | 34 | 35 |
36 | 37 |
38 | 39 | 40 |
41 | 42 |
43 | 44 | 45 |
46 | 47 |
48 | 49 |
50 | <% end %> 51 | -------------------------------------------------------------------------------- /lib/web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | XRPL Webhooks 10 | 11 | "> 12 | 13 | 14 | 15 | 16 | 71 | 72 | 73 | 74 |
75 | <%= if get_flash(@conn, :info) do %> 76 | 82 | <% end %> 83 | <%= if get_flash(@conn, :error) do %> 84 | 90 | <% end %> 91 | 92 | 93 | <%= render @view_module, @view_template, assigns %> 94 |
95 | 96 |
97 |
98 |

99 | © 2019 XRPL Labs    100 |

101 |
102 |
103 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /lib/web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |

XRPL Transaction Webhooks

4 |

Get notified with a HTTP POST request on payments 🎉

5 |

6 | Setup an App and generate your API keys to continue. 7 |

8 |

9 | Continue 10 |

11 |
12 | 13 |

Getting started

14 |
15 |
16 |
17 |
18 |
1 - Create App
19 |

Integrating with nearly all of the XRPL Webhook APIs and register Webhooks and Subscriptions requires the creation of a Hook app and the generation of consumer keys and access tokens.

20 |
21 |
22 |
23 |
24 |
2 - Create Webhook
25 |

This is the URL all HTTP Posts will be send to, for subscribe an XRPL account creating an Webhook for your app is required.

26 |
27 |
28 |
29 |
30 |
3 - Subscribe Account
31 |

In the end all you need is Subscribe your desire XRPL account and you will get notified by HTTP Post on your Webhook URL's

32 |
33 |
34 |
35 | 36 |
37 | -------------------------------------------------------------------------------- /lib/web/templates/subscription/list.html.eex: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |
11 | <%= form_for @changeset, subscription_path(@conn, :list, @app.app_id ), [class: "ml-auto"], fn f -> %> 12 |

Add subscription

13 |
To receive HTTP POST calls when a monitored account receives a payment, you need to subscribe to activity for one or more XRPL accounts.
14 | 15 |
16 |
17 | 18 | Must be a valid XRPL account address, eg. rXXXXXXX.... 19 | 20 | <%= input f, :address, using: :text_input , label: [class: "sr-only"], input: [placeholder: "XRPL account address"]%> 21 | <%= error_tag f, :url %> 22 |
23 |
24 | 25 |
26 |
27 | <% end %> 28 |
29 |
30 |
31 |
32 |
33 |
34 | <%= if not Enum.empty?(@subscriptions) do %> 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | <%= for subscription <- @subscriptions do %> 46 | 47 | 48 | 49 | 50 | 60 | 61 | <% end %> 62 | 63 | 64 | 65 | 66 | 67 | 68 |
IDAddressDate Added
<%= subscription.subscription_id %><%= subscription.address %><%= subscription.inserted_at %> 51 | 59 |
69 | <% else %> 70 |
71 |

No subscriptions added yet

72 | <% end %> 73 |
74 |
75 | -------------------------------------------------------------------------------- /lib/web/templates/webhook/list.html.eex: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |
11 | <%= form_for @changeset, webhook_path(@conn, :list, @app.app_id ), [class: "ml-auto"], fn f -> %> 12 |
13 |
14 |

Add webhook

15 |
To receive HTTP POST calls when a monitored account receives a payment, you need to register one or more HTTP endpoints that will receive a POST call when a payment is made to one of the subscribed accounts.
16 | 17 | Must be entered with protocole, eg. https://myapp.com 18 | 19 | <%= input f, :url, using: :text_input , label: [class: "sr-only"], input: [placeholder: "URL"]%> 20 | <%= error_tag f, :url %> 21 |
22 | 23 |
24 |
25 |
26 | <% end %> 27 |
28 |
29 |
30 |
31 |
32 | <%= if not Enum.empty?(@webhooks) do %> 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | <%= for webhook <- @webhooks do %> 44 | 45 | 46 | 47 | 54 | 55 | 66 | 67 | <% end %> 68 | 69 | 70 | 71 | 72 | 73 | 74 |
IDURLActiveDate created
<%= webhook.hook_id %><%= webhook.url %> 48 | <%= if webhook.deactivated do %> 49 | 50 | <% else %> 51 | 52 | <% end %> 53 | <%= webhook.inserted_at %> 56 | 65 |
75 | <% else %> 76 |
77 |

No webhooks registered yet

78 | <% end %> 79 |
80 |
81 | -------------------------------------------------------------------------------- /lib/web/views/api/subscription_view.ex: -------------------------------------------------------------------------------- 1 | 2 | defmodule EspyWeb.Api.SubscriptionView do 3 | use EspyWeb, :view 4 | alias EspyWeb.Api.SubscriptionView 5 | 6 | def render("index.json", %{app_id: app_id, subscriptions: subscriptions}) do 7 | %{ 8 | app_id: app_id, 9 | subscriptions: render_many(subscriptions, SubscriptionView, "subscription.json") 10 | } 11 | end 12 | 13 | def render("show.json", %{subscription: subscription}) do 14 | render_one(subscription, SubscriptionView, "subscription.json") 15 | end 16 | 17 | def render("subscription.json", %{subscription: subscription}) do 18 | %{id: subscription.subscription_id, 19 | address: subscription.address, 20 | created_at: subscription.inserted_at 21 | } 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/web/views/api/webhook_view.ex: -------------------------------------------------------------------------------- 1 | 2 | defmodule EspyWeb.Api.WebhookView do 3 | use EspyWeb, :view 4 | alias EspyWeb.Api.WebhookView 5 | 6 | def render("index.json", %{app_id: app_id, webhooks: webhooks}) do 7 | %{ 8 | app_id: app_id, 9 | webhooks: render_many(webhooks, WebhookView, "webhook.json") 10 | } 11 | end 12 | 13 | def render("show.json", %{webhook: webhook}) do 14 | %{data: render_one(webhook, WebhookView, "webhook.json")} 15 | end 16 | 17 | def render("webhook.json", %{webhook: webhook}) do 18 | %{id: webhook.hook_id, 19 | url: webhook.url, 20 | active: !webhook.deactivated, 21 | created_at: webhook.inserted_at 22 | } 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/web/views/app_view.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.AppView do 2 | use EspyWeb, :view 3 | 4 | end 5 | -------------------------------------------------------------------------------- /lib/web/views/auth_view.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.AuthView do 2 | use EspyWeb, :view 3 | 4 | end 5 | -------------------------------------------------------------------------------- /lib/web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn (error) -> 13 | content_tag :div, translate_error(error), class: "invalid-feedback" 14 | end) 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # When using gettext, we typically pass the strings we want 22 | # to translate as a static argument: 23 | # 24 | # # Translate "is invalid" in the "errors" domain 25 | # dgettext "errors", "is invalid" 26 | # 27 | # # Translate the number of files with plural rules 28 | # dngettext "errors", "1 file", "%{count} files", count 29 | # 30 | # Because the error messages we show in our forms and APIs 31 | # are defined inside Ecto, we need to translate them dynamically. 32 | # This requires us to call the Gettext module passing our gettext 33 | # backend as first argument. 34 | # 35 | # Note we use the "errors" domain, which means translations 36 | # should be written to the errors.po file. The :count option is 37 | # set by Ecto and indicates we should also apply plural rules. 38 | if count = opts[:count] do 39 | Gettext.dngettext(EspyWeb.Gettext, "errors", msg, msg, count, opts) 40 | else 41 | Gettext.dgettext(EspyWeb.Gettext, "errors", msg, opts) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.ErrorView do 2 | use EspyWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | 17 | def translate_errors(changeset) do 18 | Ecto.Changeset.traverse_errors(changeset, &translate_error/1) 19 | end 20 | 21 | def render("error.json", %{changeset: changeset}) do 22 | # When encoded, the changeset returns its errors 23 | # as a JSON object. So we just pass it forward. 24 | %{errors: translate_errors(changeset)} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/web/views/input_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.InputHelper do 2 | use Phoenix.HTML 3 | require IEx 4 | 5 | def input(form, field, opts \\ []) do 6 | type = opts[:using] || Phoenix.HTML.Form.input_type(form, field) 7 | input = opts[:input] || [] 8 | 9 | wrapper_opts = [class: "form-group"] 10 | label_opts = opts[:label] || [class: "control-label"] 11 | input_opts = [class: "form-control #{input_state_class(form, field)}"] 12 | 13 | content_tag :div, wrapper_opts do 14 | label = label(form, field, humanize(field), label_opts) 15 | input = input(type, form, field, input_opts ++ input) 16 | error = EspyWeb.ErrorHelpers.error_tag(form, field) 17 | [label, input, error || ""] 18 | end 19 | end 20 | 21 | defp input_state_class(form, field) do 22 | cond do 23 | # The form was not yet submitted 24 | !form.source.action -> "" 25 | form.errors[field] -> "is-invalid" 26 | true -> "is-valid" 27 | end 28 | end 29 | 30 | # Implement clauses below for custom inputs. 31 | # defp input(:datepicker, form, field, input_opts) do 32 | # raise "not yet implemented" 33 | # end 34 | 35 | defp input(type, form, field, input_opts) do 36 | apply(Phoenix.HTML.Form, type, [form, field, input_opts]) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.LayoutView do 2 | use EspyWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.PageView do 2 | use EspyWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/web/views/subscription_view.ex: -------------------------------------------------------------------------------- 1 | 2 | defmodule EspyWeb.SubscriptionView do 3 | use EspyWeb, :view 4 | alias EspyWeb.SubscriptionView 5 | 6 | end 7 | -------------------------------------------------------------------------------- /lib/web/views/webhook_view.ex: -------------------------------------------------------------------------------- 1 | 2 | defmodule EspyWeb.WebhookView do 3 | use EspyWeb, :view 4 | 5 | end 6 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Espy.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :espy, 7 | version: "0.0.1", 8 | elixir: "~> 1.4", 9 | elixirc_paths: elixirc_paths(Mix.env), 10 | compilers: [:phoenix, :gettext] ++ Mix.compilers, 11 | start_permanent: Mix.env == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {Espy.Application, []}, 23 | extra_applications: [:logger, :runtime_tools, :timex, :edeliver] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.4.0"}, 37 | {:phoenix_pubsub, "~> 1.0"}, 38 | {:phoenix_ecto, "~> 4.0.0"}, 39 | {:ecto_sql, "~> 3.0-rc"}, 40 | {:plug_cowboy, "~> 2.0"}, 41 | {:postgrex, ">= 0.14.1"}, 42 | {:blue_bird, "~> 0.4.0"}, 43 | {:websockex, "~> 0.4.2"}, 44 | {:phoenix_html, "~> 2.10"}, 45 | {:ex_machina, "~> 2.2", only: :test}, 46 | {:phoenix_live_reload, "~> 1.2.0", only: :dev}, 47 | {:poison, ">= 0.0.0"}, 48 | {:binary, "0.0.4"}, 49 | {:gen_stage, "~> 0.11"}, 50 | {:gettext, "~> 0.11"}, 51 | {:cowboy, "~> 2.6.1"}, 52 | {:secure_random, "~> 0.5"}, 53 | {:comeonin, "~> 4.0"}, 54 | {:hammer, "~> 6.0"}, 55 | {:bcrypt_elixir, "~> 1.0"}, 56 | {:ueberauth, "~> 0.3"}, 57 | {:ueberauth_github, "~> 0.4"}, 58 | {:ueberauth_twitter, "~> 0.2"}, 59 | {:oauth, github: "tim/erlang-oauth"}, 60 | {:timex, "~> 3.0"}, 61 | {:logger_file_backend, "~> 0.0.10"}, 62 | {:edeliver, ">= 1.6.0"}, 63 | {:distillery, "~> 2.0", warn_missing: false} 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 | def blue_bird_info do 82 | [ 83 | host: "https://webhook.xrpayments.co", 84 | title: "XRPL Webhook API", 85 | description: """ 86 | API requires authorization. All requests must have valid 87 | `x-api-key` and `x-api-secret`. 88 | """ 89 | ] 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "artificery": {:hex, :artificery, "0.4.0", "e0b8d3eb9dfe8f42c08a620f90a2aa9cef5dba9fcdfcecad5c2be451df159a77", [:mix], [], "hexpm"}, 3 | "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, 4 | "bcrypt_elixir": {:hex, :bcrypt_elixir, "1.1.1", "6b5560e47a02196ce5f0ab3f1d8265db79a23868c137e973b27afef928ed8006", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "binary": {:hex, :binary, "0.0.4", "dd077db70c0ded3e85c132b802338e14b80694684a7e2277666bfa4004b7fa66", [:mix], [], "hexpm"}, 6 | "blue_bird": {:hex, :blue_bird, "0.4.0", "1f4fb5790a26cf4da1a0f44ddd57fc7b2f4c664d99ea262b0866c02f432a6079", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.4.3", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "bureaucrat": {:hex, :bureaucrat, "0.2.5", "7b6f607fb731bdb6ef43a71b777034f5b3da78c63f510190acf499d0afff2b9a", [:mix], [{:inflex, ">= 1.10.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.2.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 1.5 or ~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, 8 | "certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, 10 | "comeonin": {:hex, :comeonin, "4.1.2", "3eb5620fd8e35508991664b4c2b04dd41e52f1620b36957be837c1d7784b7592", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm"}, 11 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 12 | "cowboy": {:hex, :cowboy, "2.6.1", "f2e06f757c337b3b311f9437e6e072b678fcd71545a7b2865bdaa154d078593f", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "cowlib": {:hex, :cowlib, "2.7.0", "3ef16e77562f9855a2605900cedb15c1462d76fb1be6a32fc3ae91973ee543d2", [:rebar3], [], "hexpm"}, 14 | "db_connection": {:hex, :db_connection, "2.0.3", "b4e8aa43c100e16f122ccd6798cd51c48c79fd391c39d411f42b3cd765daccb0", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, 15 | "decimal": {:hex, :decimal, "1.6.0", "bfd84d90ff966e1f5d4370bdd3943432d8f65f07d3bab48001aebd7030590dcc", [:mix], [], "hexpm"}, 16 | "distillery": {:hex, :distillery, "2.0.12", "6e78fe042df82610ac3fa50bd7d2d8190ad287d120d3cd1682d83a44e8b34dfb", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"}, 17 | "ecto": {:hex, :ecto, "3.0.6", "d33ab5b3f7553a41507d4b0ad5bf192d533119c4ad08f3a5d63d85aa12117dc9", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, 18 | "ecto_sql": {:hex, :ecto_sql, "3.0.4", "e7a0feb0b2484b90981c56d5cd03c52122c1c31ded0b95ed213b7c5c07ae6737", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, 19 | "edeliver": {:hex, :edeliver, "1.6.0", "8bfdde1b7ff57deb12fa604c4087b4e15a7aa72d7b3d3d310602ca92c038f3ff", [:mix], [{:distillery, "~> 2.0", [hex: :distillery, repo: "hexpm", optional: true]}], "hexpm"}, 20 | "elixir_make": {:hex, :elixir_make, "0.4.2", "332c649d08c18bc1ecc73b1befc68c647136de4f340b548844efc796405743bf", [:mix], [], "hexpm"}, 21 | "ex_machina": {:hex, :ex_machina, "2.2.2", "d84217a6fb7840ff771d2561b8aa6d74a0d8968e4b10ecc0d7e9890dc8fb1c6a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm"}, 22 | "file_system": {:hex, :file_system, "0.2.6", "fd4dc3af89b9ab1dc8ccbcc214a0e60c41f34be251d9307920748a14bf41f1d3", [:mix], [], "hexpm"}, 23 | "gen_stage": {:hex, :gen_stage, "0.14.1", "9d46723fda072d4f4bb31a102560013f7960f5d80ea44dcb96fd6304ed61e7a4", [:mix], [], "hexpm"}, 24 | "gettext": {:hex, :gettext, "0.16.1", "e2130b25eebcbe02bb343b119a07ae2c7e28bd4b146c4a154da2ffb2b3507af2", [:mix], [], "hexpm"}, 25 | "guardian": {:hex, :guardian, "1.2.1", "bdc8dd3dbf0fb7216cb6f91c11831faa1a64d39cdaed9a611e37f2413e584983", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm"}, 26 | "hackney": {:hex, :hackney, "1.15.0", "287a5d2304d516f63e56c469511c42b016423bcb167e61b611f6bad47e3ca60e", [:rebar3], [{:certifi, "2.4.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 27 | "hammer": {:hex, :hammer, "6.0.0", "72ec6fff10e9d63856968988a22ee04c4d6d5248071ddccfbda50aa6c455c1d7", [:mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm"}, 28 | "httpoison": {:hex, :httpoison, "0.13.0", "bfaf44d9f133a6599886720f3937a7699466d23bb0cd7a88b6ba011f53c6f562", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 29 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 30 | "inflex": {:hex, :inflex, "1.10.0", "8366a7696e70e1813aca102e61274addf85d99f4a072b2f9c7984054ea1b9d29", [:mix], [], "hexpm"}, 31 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 32 | "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, 33 | "logger_file_backend": {:hex, :logger_file_backend, "0.0.10", "876f9f84ae110781207c54321ffbb62bebe02946fe3c13f0d7c5f5d8ad4fa910", [:mix], [], "hexpm"}, 34 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 35 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, 36 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 37 | "oauth": {:git, "https://github.com/tim/erlang-oauth.git", "bd19896e31125f99ff45bb5850b1c0e74b996743", []}, 38 | "oauth2": {:hex, :oauth2, "0.9.4", "632e8e8826a45e33ac2ea5ac66dcc019ba6bb5a0d2ba77e342d33e3b7b252c6e", [:mix], [{:hackney, "~> 1.7", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 39 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, 40 | "phoenix": {:hex, :phoenix, "1.4.0", "56fe9a809e0e735f3e3b9b31c1b749d4b436e466d8da627b8d82f90eaae714d2", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, 41 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 42 | "phoenix_html": {:hex, :phoenix_html, "2.13.1", "fa8f034b5328e2dfa0e4131b5569379003f34bc1fafdaa84985b0b9d2f12e68b", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 43 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.0", "3bb31a9fbd40ffe8652e60c8660dffd72dd231efcdf49b744fb75b9ef7db5dd2", [: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"}, 44 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.1", "6668d787e602981f24f17a5fbb69cc98f8ab085114ebfac6cc36e10a90c8e93c", [:mix], [], "hexpm"}, 45 | "plug": {:hex, :plug, "1.7.1", "8516d565fb84a6a8b2ca722e74e2cd25ca0fc9d64f364ec9dbec09d33eb78ccd", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, 46 | "plug_cowboy": {:hex, :plug_cowboy, "2.0.1", "d798f8ee5acc86b7d42dbe4450b8b0dadf665ce588236eb0a751a132417a980e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 47 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 48 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 49 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, 50 | "postgrex": {:hex, :postgrex, "0.14.1", "63247d4a5ad6b9de57a0bac5d807e1c32d41e39c04b8a4156a26c63bcd8a2e49", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, 51 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, 52 | "secure_random": {:hex, :secure_random, "0.5.1", "c5532b37c89d175c328f5196a0c2a5680b15ebce3e654da37129a9fe40ebf51b", [:mix], [], "hexpm"}, 53 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, 54 | "telemetry": {:hex, :telemetry, "0.3.0", "099a7f3ce31e4780f971b4630a3c22ec66d22208bc090fe33a2a3a6a67754a73", [:rebar3], [], "hexpm"}, 55 | "timex": {:hex, :timex, "3.5.0", "b0a23167da02d0fe4f1a4e104d1f929a00d348502b52432c05de875d0b9cffa5", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, 56 | "tub": {:git, "https://github.com/narrowtux/Tube.git", "b80892f5d7fbb11493a44052d3ec50b9764e9b05", []}, 57 | "tube": {:git, "https://github.com/narrowtux/Tube.git", "b80892f5d7fbb11493a44052d3ec50b9764e9b05", []}, 58 | "tzdata": {:hex, :tzdata, "0.5.19", "7962a3997bf06303b7d1772988ede22260f3dae1bf897408ebdac2b4435f4e6a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 59 | "ueberauth": {:hex, :ueberauth, "0.5.0", "4570ec94d7f784dc4c4aa94c83391dbd9b9bd7b66baa30e95a666c5ec1b168b1", [:mix], [{:plug, "~> 1.2", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 60 | "ueberauth_github": {:hex, :ueberauth_github, "0.7.0", "637067c5500f7b13c18caca3db66d09eba661524e0d0e9518b54151e99484bad", [:mix], [{:oauth2, "~> 0.9", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.4", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm"}, 61 | "ueberauth_twitter": {:hex, :ueberauth_twitter, "0.2.3", "e601d6d9c610f7a1cf747a0de92da939ea6246ca8fcfe541cfdf5f2f35b9517b", [:mix], [{:httpoison, "~> 0.7", [hex: :httpoison, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.2", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm"}, 62 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, 63 | "websocket_client": {:hex, :websocket_client, "1.3.0", "2275d7daaa1cdacebf2068891c9844b15f4fdc3de3ec2602420c2fb486db59b6", [:rebar3], [], "hexpm"}, 64 | "websockex": {:hex, :websockex, "0.4.2", "9a3b7dc25655517ecd3f8ff7109a77fce94956096b942836cdcfbc7c86603ecc", [:mix], [], "hexpm"}, 65 | } 66 | -------------------------------------------------------------------------------- /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 file 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 as 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 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180704001416_create_user.exs: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.Repo.Migrations.CreateUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create_if_not_exists table(:user) do 6 | add :provider, :string 7 | add :uid, :string 8 | add :token, :string 9 | add :name, :string 10 | add :is_active, :boolean, default: true, null: false 11 | add :email, :string 12 | add :avatar, :string 13 | add :level, :string, default: "free" 14 | 15 | timestamps() 16 | end 17 | 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190122221618_create_apps.exs: -------------------------------------------------------------------------------- 1 | defmodule Espy.Repo.Migrations.CreateApps do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create_if_not_exists table(:apps) do 6 | add :name, :string 7 | add :app_id, :integer, null: false 8 | add :description, :text 9 | add :url, :string 10 | add :active, :boolean, default: false, null: false 11 | add :deleted, :boolean, default: false, null: false 12 | add :api_key, :string 13 | add :api_secret, :string 14 | add :user_id, references(:user, on_delete: :nothing) 15 | 16 | timestamps() 17 | end 18 | 19 | create_if_not_exists index(:apps, [:user_id]) 20 | create_if_not_exists index(:apps, [:app_id]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190123224516_create_webhook.exs: -------------------------------------------------------------------------------- 1 | defmodule Espy.Repo.Migrations.CreateWebhook do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create_if_not_exists table(:webhooks) do 6 | add :hook_id, :integer 7 | add :url, :string 8 | add :failed_count, :integer 9 | add :deactivated, :boolean, default: false, null: false 10 | add :deactivate_reason, :string 11 | add :deleted, :boolean, default: false, null: false 12 | add :app_id, references(:apps, on_delete: :nothing) 13 | 14 | timestamps() 15 | end 16 | 17 | create_if_not_exists index(:webhooks, [:app_id]) 18 | create_if_not_exists index(:webhooks, [:hook_id]) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190123224517_create_subsciptions.exs: -------------------------------------------------------------------------------- 1 | defmodule Espy.Repo.Migrations.CreateSubscriptions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create_if_not_exists table(:subscriptions) do 6 | add :subscription_id, :integer 7 | add :address, :string 8 | add :app_id, references(:apps, on_delete: :nothing) 9 | 10 | timestamps() 11 | end 12 | 13 | create_if_not_exists index(:subscriptions, [:app_id]) 14 | create_if_not_exists index(:subscriptions, [:subscription_id]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190123224518_create_logging.exs: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.Repo.Migrations.CreateLogging do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create_if_not_exists table(:logging) do 6 | add :response_time, :integer 7 | add :response_status, :integer 8 | add :retry_count, :integer 9 | add :object_id, :string 10 | add :webhook_id, references(:webhooks, on_delete: :nothing) 11 | add :app_id, references(:apps, on_delete: :nothing) 12 | 13 | timestamps() 14 | end 15 | 16 | create_if_not_exists index(:logging, [:webhook_id]) 17 | create_if_not_exists index(:logging, [:app_id]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Monitoring.Repo.insert!(%Monitoring.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /rel/config.exs: -------------------------------------------------------------------------------- 1 | # Import all plugins from `rel/plugins` 2 | # They can then be used by adding `plugin MyPlugin` to 3 | # either an environment, or release definition, where 4 | # `MyPlugin` is the name of the plugin module. 5 | ~w(rel plugins *.exs) 6 | |> Path.join() 7 | |> Path.wildcard() 8 | |> Enum.map(&Code.eval_file(&1)) 9 | 10 | use Mix.Releases.Config, 11 | # This sets the default release built by `mix release` 12 | default_release: :default, 13 | # This sets the default environment used by `mix release` 14 | default_environment: Mix.env() 15 | 16 | # For a full list of config options for both releases 17 | # and environments, visit https://hexdocs.pm/distillery/config/distillery.html 18 | 19 | 20 | # You may define one or more environments in this file, 21 | # an environment's settings will override those of a release 22 | # when building in that environment, this combination of release 23 | # and environment configuration is called a profile 24 | 25 | environment :dev do 26 | # If you are running Phoenix, you should make sure that 27 | # server: true is set and the code reloader is disabled, 28 | # even in dev mode. 29 | # It is recommended that you build with MIX_ENV=prod and pass 30 | # the --env flag to Distillery explicitly if you want to use 31 | # dev mode. 32 | set dev_mode: true 33 | set include_erts: false 34 | set cookie: :"Tu/w=>oFQKV44X}erMMM$K5f9h`I@127.0.0.1 7 | 8 | ## Cookie for distributed erlang 9 | -setcookie <%= release.profile.cookie %> 10 | 11 | ## Heartbeat management; auto-restarts VM if it dies or becomes unresponsive 12 | ## (Disabled by default..use with caution!) 13 | ##-heart 14 | 15 | ## Enable kernel poll and a few async threads 16 | ##+K true 17 | ##+A 5 18 | ## For OTP21+, the +A flag is not used anymore, 19 | ## +SDio replace it to use dirty schedulers 20 | ##+SDio 5 21 | 22 | ## Increase number of concurrent ports/sockets 23 | ##-env ERL_MAX_PORTS 4096 24 | 25 | ## Tweak GC to run more often 26 | ##-env ERL_FULLSWEEP_AFTER 10 27 | 28 | # Enable SMP automatically based on availability 29 | # On OTP21+, this is not needed anymore. 30 | -smp auto 31 | -------------------------------------------------------------------------------- /test/espy_web/api_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.ApiTest do 2 | use EspyWeb.ConnCase 3 | 4 | import EspyWeb.Factory 5 | 6 | setup do 7 | Supervisor.terminate_child(Espy.Supervisor, ConCache) 8 | Supervisor.restart_child(Espy.Supervisor, ConCache) 9 | :ok 10 | end 11 | 12 | test "/api/v1/webhooks", %{conn: conn} do 13 | app1 = insert(:app) 14 | 15 | # CREATE WEBHOOK INVALID URL 16 | conn1 = conn 17 | |> put_req_header("x-api-key", app1.api_key) 18 | |> put_req_header("x-api-secret", app1.api_secret) 19 | |> put_req_header("content-type", "application/json; charset=utf-8") 20 | |> post(api_webhook_path(conn, :create), url: "https://invalid-url") 21 | |> BlueBird.ConnLogger.save() 22 | 23 | # CREATE WEBHOOK 24 | conn1 = conn 25 | |> put_req_header("x-api-key", app1.api_key) 26 | |> put_req_header("x-api-secret", app1.api_secret) 27 | |> put_req_header("content-type", "application/json; charset=utf-8") 28 | |> post(api_webhook_path(conn, :create), url: "https://myapp.com/webhook") 29 | |> BlueBird.ConnLogger.save() 30 | 31 | response_create = json_response(conn1, 200) 32 | webhook_id = Map.get(response_create, "webhook_id") 33 | 34 | # GET LIST OF WEBHOOKS 35 | conn2 = conn 36 | |> put_req_header("x-api-key", app1.api_key) 37 | |> put_req_header("x-api-secret", app1.api_secret) 38 | |> get(api_webhook_path(conn, :list)) 39 | |> BlueBird.ConnLogger.save() 40 | 41 | response_list = json_response(conn2, 200) 42 | 43 | # DELETE WEBHOOK 44 | conn3 = conn 45 | |> put_req_header("x-api-key", app1.api_key) 46 | |> put_req_header("x-api-secret", app1.api_secret) 47 | |> put_req_header("content-type", "application/json; charset=utf-8") 48 | |> delete(api_webhook_path(conn, :delete, webhook_id)) 49 | |> BlueBird.ConnLogger.save() 50 | 51 | end 52 | 53 | 54 | 55 | test "/api/v1/subscriptions", %{conn: conn} do 56 | app1 = insert(:app) 57 | 58 | 59 | # Invalid address 60 | conn1 = conn 61 | |> put_req_header("x-api-key", app1.api_key) 62 | |> put_req_header("x-api-secret", app1.api_secret) 63 | |> put_req_header("content-type", "application/json; charset=utf-8") 64 | |> post(api_subscription_path(conn, :create), address: "invalid_address") 65 | |> BlueBird.ConnLogger.save() 66 | 67 | 68 | # Subscription an address 69 | conn1 = conn 70 | |> put_req_header("x-api-key", app1.api_key) 71 | |> put_req_header("x-api-secret", app1.api_secret) 72 | |> put_req_header("content-type", "application/json; charset=utf-8") 73 | |> post(api_subscription_path(conn, :create), address: "rqAiBkWakRA9Jr5TCahtKrPS23KBYUZhj") 74 | |> BlueBird.ConnLogger.save() 75 | 76 | response_create = json_response(conn1, 200) 77 | subscription_id = Map.get(response_create, "subscription_id") 78 | 79 | # GET LIST OF SUBSCRIPTIONS 80 | conn2 = conn 81 | |> put_req_header("x-api-key", app1.api_key) 82 | |> put_req_header("x-api-secret", app1.api_secret) 83 | |> get(api_subscription_path(conn, :list)) 84 | |> BlueBird.ConnLogger.save() 85 | 86 | response_list = json_response(conn2, 200) 87 | 88 | # DEACTIVATE SUBSCRIPTION 89 | conn3 = conn 90 | |> put_req_header("x-api-key", app1.api_key) 91 | |> put_req_header("x-api-secret", app1.api_secret) 92 | |> put_req_header("content-type", "application/json; charset=utf-8") 93 | |> delete(api_subscription_path(conn, :delete, subscription_id)) 94 | |> BlueBird.ConnLogger.save() 95 | 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint EspyWeb.Endpoint 25 | end 26 | end 27 | 28 | 29 | setup tags do 30 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Espy.Repo) 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(Espy.Repo, {:shared, self()}) 33 | end 34 | :ok 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule EspyWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | import EspyWeb.Router.Helpers 23 | 24 | # The default endpoint for testing 25 | @endpoint EspyWeb.Endpoint 26 | end 27 | end 28 | 29 | 30 | setup tags do 31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Espy.Repo) 32 | unless tags[:async] do 33 | Ecto.Adapters.SQL.Sandbox.mode(Espy.Repo, {:shared, self()}) 34 | end 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Espy.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias Espy.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import Espy.DataCase 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Espy.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(Espy.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | @doc """ 39 | A helper that transform changeset errors to a map of messages. 40 | 41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 42 | assert "password is too short" in errors_on(changeset).password 43 | assert %{password: ["password is too short"]} = errors_on(changeset) 44 | 45 | """ 46 | def errors_on(changeset) do 47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 48 | Enum.reduce(opts, message, fn {key, value}, acc -> 49 | String.replace(acc, "%{#{key}}", to_string(value)) 50 | end) 51 | end) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | 2 | defmodule EspyWeb.Factory do 3 | use ExMachina.Ecto, repo: Espy.Repo 4 | 5 | alias Espy.Gateway.{App, Webhook} 6 | 7 | def webhook_factory do 8 | %Webhook{ 9 | url: "http://localhost/webhook", 10 | } 11 | end 12 | 13 | def app_factory do 14 | %App{ 15 | app_id: "314014556", 16 | name: "app_test", 17 | url: "https://test.app", 18 | description: "app desc", 19 | api_key: "69682cbf-85bd-4507-8cbd-f4ee63fed1dc", 20 | api_secret: "bWR3ajJSMWxwR3R5Nmd4cmtCaXNBQT09" 21 | } 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | BlueBird.start() 2 | ExUnit.start(formatters: [ExUnit.CLIFormatter, BlueBird.Formatter]) 3 | 4 | Ecto.Adapters.SQL.Sandbox.mode(Espy.Repo, :manual) 5 | --------------------------------------------------------------------------------