├── .dockerignore ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── css │ ├── app.css │ └── phoenix.css ├── js │ ├── app.js │ └── socket.js └── static │ ├── favicon.ico │ ├── images │ └── phoenix.png │ └── robots.txt ├── config ├── config.exs ├── dev.exs ├── docs.exs ├── prod.exs ├── runtime.exs └── test.exs ├── coveralls.json ├── data ├── dummy_db_data.exs └── hit_data.exs ├── fly.toml ├── lib ├── hits.ex ├── hits │ ├── application.ex │ ├── hit.ex │ ├── release.ex │ ├── repo.ex │ ├── repository.ex │ ├── user.ex │ └── useragent.ex ├── hits_web.ex └── hits_web │ ├── channels │ ├── hit_channel.ex │ └── user_socket.ex │ ├── controllers │ ├── hit_controller.ex │ └── page_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── router.ex │ ├── templates │ ├── hit │ │ ├── badge_flat.svg │ │ ├── badge_flat_square.svg │ │ └── index.html.eex │ ├── layout │ │ ├── app.html.eex │ │ └── icons.html.heex │ └── page │ │ ├── error.html.eex │ │ └── index.html.eex │ └── views │ ├── error_helpers.ex │ ├── error_view.ex │ ├── hit_view.ex │ ├── layout_view.ex │ └── page_view.ex ├── migrate-data.md ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot ├── migrate-data.exs └── repo │ ├── migrations │ ├── .formatter.exs │ ├── 20190515211749_create_users.exs │ ├── 20190515211755_create_repositories.exs │ ├── 20190515211804_create_useragents.exs │ ├── 20190515211819_create_hits.exs │ ├── 20220203211516_create_unique_ip_name.exs │ ├── 20220203212822_create_unique_user_name.exs │ └── 20220204205611_create_unique_index_name_user_id_repository.exs │ └── seeds.exs ├── rel ├── env.bat.eex ├── env.sh.eex ├── remote.vm.args.eex └── vm.args.eex └── test ├── coverage_test.exs ├── hits_test.exs ├── hits_web ├── channels │ └── hit_channel_test.exs ├── controllers │ ├── hit_controller_test.exs │ └── page_controller_test.exs └── views │ ├── error_view_test.exs │ ├── layout_view_test.exs │ └── page_view_test.exs ├── repository_test.exs ├── support ├── channel_case.ex ├── conn_case.ex └── data_case.ex ├── test_helper.exs ├── user_test.exs └── useragent_test.exs /.dockerignore: -------------------------------------------------------------------------------- 1 | assets/node_modules/ 2 | deps/ -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "17:00" 8 | timezone: Europe/London 9 | ignore: 10 | # ignore all patch updates in dev dependencies ref: github.com/dwyl/technology-stack/issues/126 [alphabetical list] 11 | - dependency-name: "credo" 12 | update-types: ["version-update:semver-patch"] 13 | - dependency-name: "dialyxir" 14 | update-types: ["version-update:semver-patch"] 15 | - dependency-name: "excoveralls" 16 | update-types: ["version-update:semver-patch"] 17 | - dependency-name: "ex_doc" 18 | update-types: ["version-update:semver-patch"] 19 | - dependency-name: "esbuild" 20 | update-types: ["version-update:semver-patch"] 21 | - dependency-name: "floki" 22 | update-types: ["version-update:semver-patch"] 23 | - dependency-name: "gettext" 24 | update-types: ["version-update:semver-patch"] 25 | - dependency-name: "mock" 26 | update-types: ["version-update:semver-patch"] 27 | - dependency-name: "phoenix_live_dashboard" 28 | update-types: ["version-update:semver-patch"] 29 | - dependency-name: "phoenix_live_reload" 30 | update-types: ["version-update:semver-patch"] 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Elixir CI 3 | 4 | on: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | build: 12 | name: Build and test 13 | runs-on: ubuntu-latest 14 | services: 15 | postgres: 16 | image: postgres:16 17 | ports: ['5432:5432'] 18 | env: 19 | POSTGRES_PASSWORD: postgres 20 | options: >- 21 | --health-cmd pg_isready 22 | --health-interval 10s 23 | --health-timeout 5s 24 | --health-retries 5 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Set up Elixir 28 | uses: erlef/setup-beam@v1 # https://github.com/erlef/setup-beam 29 | with: 30 | elixir-version: '1.14.2' # Define the elixir version [required] 31 | otp-version: '24.3.4' # Define the OTP version [required] 32 | - name: Restore dependencies cache 33 | uses: actions/cache@v4 34 | with: 35 | path: deps 36 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 37 | restore-keys: ${{ runner.os }}-mix- 38 | - name: Install dependencies 39 | run: mix deps.get 40 | - name: Run Tests 41 | run: mix coveralls.json 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | MIX_ENV: test 45 | - name: Upload coverage to Codecov 46 | uses: codecov/codecov-action@v4 47 | with: 48 | token: ${{ secrets.CODECOV_TOKEN }} 49 | 50 | # Continuous Deployment to Fly.io 51 | # https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ 52 | deploy: 53 | name: Deploy app 54 | runs-on: ubuntu-latest 55 | needs: build 56 | # https://stackoverflow.com/questions/58139406/only-run-job-on-specific-branch-with-github-actions 57 | if: github.ref == 'refs/heads/main' 58 | env: 59 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 60 | steps: 61 | - uses: actions/checkout@v2 62 | - uses: superfly/flyctl-actions@1.1 63 | with: 64 | args: "deploy" 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | *.beam 22 | logs/ 23 | 24 | # people can generate docs locally if they want, they are useless on GitHub! 25 | docs/ 26 | 27 | # exclude node_modules which are required for pre-commit hook ... 28 | node_modules 29 | # see: github.com/dwyl/hits-elixir/issues/10 30 | 31 | # exclude osx files 32 | .DS_Store 33 | npm-debug.log 34 | 35 | # Since we are building assets from assets/, 36 | # we ignore priv/static. You may want to comment 37 | # this depending on your deployment strategy. 38 | /priv/static/ 39 | logs.tar.gz 40 | _logs/ 41 | 42 | 43 | id_rsa_fly 44 | id_rsa_fly-cert.pub 45 | .vscode 46 | backup.sql -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | language: elixir 3 | elixir: 4 | - 1.12.3 5 | otp_release: 6 | - 24.0 7 | services: 8 | - postgresql 9 | # https://travis-ci.community/t/test-against-postgres-12/6768 10 | # addons: 11 | # postgresql: '12' 12 | # apt: 13 | # packages: 14 | # - postgresql-12 15 | # - postgresql-client-12 16 | env: 17 | - MIX_ENV=test 18 | # - PGVER=12 19 | # - PGPORT=5433 20 | before_script: 21 | - mix ecto.setup 22 | script: 23 | - mix do deps.get, coveralls.json 24 | # See: github.com/dwyl/repo-badges#documentation 25 | after_script: 26 | - MIX_ENV=docs mix deps.get 27 | - MIX_ENV=docs mix inch.report 28 | after_success: 29 | - bash <(curl -s https://codecov.io/bash) 30 | cache: 31 | directories: 32 | - _build 33 | - deps 34 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at github@dwyl.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [https://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | **Please read** our 2 | [**contribution guide**](https://github.com/dwyl/contributing) 3 | (_thank you_!). 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### 2 | ### Fist Stage - Building the Release 3 | ### 4 | FROM hexpm/elixir:1.12.1-erlang-24.0.1-alpine-3.13.3 AS build 5 | 6 | # install build dependencies 7 | RUN apk add --no-cache build-base npm 8 | 9 | # prepare build dir 10 | WORKDIR /app 11 | 12 | # extend hex timeout 13 | ENV HEX_HTTP_TIMEOUT=20 14 | 15 | # install hex + rebar 16 | RUN mix local.hex --force && \ 17 | mix local.rebar --force 18 | 19 | # set build ENV as prod 20 | ENV MIX_ENV=prod 21 | ENV SECRET_KEY_BASE=nokey 22 | 23 | # Copy over the mix.exs and mix.lock files to load the dependencies. If those 24 | # files don't change, then we don't keep re-fetching and rebuilding the deps. 25 | COPY mix.exs mix.lock ./ 26 | COPY config config 27 | 28 | RUN mix deps.get --only prod && \ 29 | mix deps.compile 30 | 31 | # install npm dependencies 32 | # COPY assets/package.json assets/package-lock.json ./assets/ 33 | # RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error 34 | 35 | COPY priv priv 36 | COPY assets assets 37 | 38 | # NOTE: If using TailwindCSS, it uses a special "purge" step and that requires 39 | # the code in `lib` to see what is being used. Uncomment that here before 40 | # running the npm deploy script if that's the case. 41 | # COPY lib lib 42 | 43 | # build assets 44 | # RUN npm run --prefix ./assets deploy 45 | RUN mix assets.deploy 46 | RUN mix phx.digest 47 | 48 | # copy source here if not using TailwindCSS 49 | COPY lib lib 50 | 51 | # compile and build release 52 | COPY rel rel 53 | RUN mix do compile, release 54 | 55 | ### 56 | ### Second Stage - Setup the Runtime Environment 57 | ### 58 | 59 | # prepare release docker image 60 | FROM alpine:3.13.3 AS app 61 | RUN apk add --no-cache libstdc++ openssl ncurses-libs 62 | 63 | WORKDIR /app 64 | 65 | RUN chown nobody:nobody /app 66 | 67 | USER nobody:nobody 68 | 69 | COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/hits ./ 70 | 71 | ENV HOME=/app 72 | ENV MIX_ENV=prod 73 | ENV SECRET_KEY_BASE=nokey 74 | ENV PORT=4000 75 | 76 | CMD ["bin/hits", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hits 2 | 3 | ![hits-dwyl-teal-banner](https://user-images.githubusercontent.com/194400/30136430-d1b2c2b8-9356-11e7-9ed5-3d84f6e44066.png) 4 | 5 |
6 | 7 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dwyl/hits/ci.yml?label=build&style=flat-square&branch=main) 8 | [![codecov.io](https://img.shields.io/codecov/c/github/dwyl/hits/master.svg?style=flat-square)](https://codecov.io/github/dwyl/hits?branch=master) 9 | [![HitCount](https://hits.dwyl.com/dwyl/hits.svg)](https://github.com/dwyl/hits) 10 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat-square)](https://github.com/dwyl/hits/issues/74) 11 | 12 | 15 | 16 |
17 | 18 | 19 | ## Why? 20 | 21 | @dwyl we have a _few_ projects on GitHub ...
22 | 23 | We want to _instantly see_ the _popularity_ 24 | of _each_ of our repos 25 | to know what people are finding _useful_ and help us 26 | decide where we need to be investing our time. 27 | 28 | While GitHub has a _basic_ 29 | "[traffic](https://github.com/blog/1672-introducing-github-traffic-analytics)" 30 | [tab](https://github.com/dwyl/start-here/graphs/traffic) 31 | which displays page view stats, GitHub only records the data 32 | for the [_past 14 days_](https://github.com/dwyl/hits/issues/49) 33 | and then it gets reset. 34 | The data is not relayed to the "owner" in "***real time***" 35 | and you would need to use the API and "poll" for data ... 36 | _Manually_ checking who has viewed a 37 | project is _exceptionally_ tedious when you have 38 | more than a _handful_ of projects. 39 | 40 | 64 | 65 |
66 | 67 | ### Why Phoenix (Elixir + PostgreSQL/Ecto)? 68 | 69 | We wrote our MVP in `Node.js`, see: 70 | https://github.com/dwyl/hits-nodejs
71 | That worked quite well to test the idea while writing minimal code. 72 | 73 | We decided to re-write in `Elixir`/`Phoenix` because we want 74 | the reliability and fault tolerance of `Erlang`, 75 | built-in application monitoring 76 | ([`supervisor`](https://erlang.org/doc/man/supervisor.html)) 77 | and metrics ([`telemetry`](https://github.com/beam-telemetry/telemetry)) 78 | and the built-in support for _highly_ scalable WebSockets 79 | that will allow us to build an _awesome_ real-time UX! 80 | 81 | For more on "Why Elixir?" see: 82 | https://github.com/dwyl/learn-elixir/issues/102 83 | 84 | 85 |
86 | 87 | ## What? 88 | 89 | A _simple & easy_ way to see how many people 90 | have _viewed_ your GitHub Repository. 91 | 92 | There are already *many* "badges" that people use in their repos. 93 | See: [github.com/dwyl/**repo-badges**](https://github.com/dwyl/repo-badges)
94 | But we haven't seen one that gives a "***hit counter***" 95 | of the number of times a GitHub page has been viewed ...
96 | So, in today's mini project we're going to _create_ a _basic **Web Counter**_. 97 | https://en.wikipedia.org/wiki/Web_counter 98 | The counter is incremented only when the user agent or the ip addres is different. 99 | When testing the counter you can open a new browser to see the badge changed. 100 | 101 | #### A Fully Working Production Phoenix App _And_ Step-by-Step Tutorial? 102 | 103 | Yes, that's right! 104 | Not only is this a fully functioning web app 105 | that is serving _millions_ of requests per day 106 | in production _right_ now, 107 | it's also a step-by-step example/tutorial 108 | showing you _exactly_ 109 | how it's implemented. 110 | 111 |
112 | 113 | ## How? 114 | 115 | > If you simply want to display a "hit count badge" 116 | in your project's GitHub page, visit: 117 | https://hits.dwyl.com 118 | to get the Markdown! 119 | 120 | 121 | 122 | ### _Run_ the App on `localhost` 123 | 124 | To _run_ the app on your localhost follow these easy steps: 125 | 126 | #### 0. Ensure your `localhost` has Node.js & Phoenix installed 127 | 128 | see: [before you start](https://github.com/dwyl/phoenix-chat-example#0-pre-requisites-before-you-start) 129 | 130 | 131 | #### 1. Clone/Download the Code 132 | 133 | ``` 134 | git clone https://github.com/dwyl/hits.git && cd hits 135 | ``` 136 | 137 | #### 2. Install the Dependencies 138 | 139 | Install elixir/node dependencies 140 | and setup Webpack static asset compilation (_with hot reloading_): 141 | 142 | ``` 143 | mix deps.get 144 | cd assets && npm install 145 | node node_modules/webpack/bin/webpack.js --mode development && cd .. 146 | ``` 147 | 148 | #### 3. Create the database 149 | 150 | ``` 151 | mix ecto.create && mix ecto.migrate 152 | ``` 153 | 154 | ### 4. Run the App 155 | 156 | ``` 157 | mix phx.server 158 | ``` 159 | 160 | That's it!
161 | 162 | 163 | Visit: http://localhost:4000/ (_in your web browser_) 164 | 165 | ![hits-homepage-phoenix](https://user-images.githubusercontent.com/194400/57912373-0b2b9d00-7882-11e9-8dfd-df1021e9d076.png) 166 | 167 | 168 | Or visit _any_ endpoint that includes `.svg` in the url, 169 | e.g: http://localhost:4000/yourname/project.svg 170 | 171 | ![hits-example-badge](https://user-images.githubusercontent.com/194400/57980413-57faa980-7a23-11e9-91cd-cc9e106be1ee.png) 172 | 173 | Refresh the page a few times and watch the count go up! 174 | 175 | ![hit-count-42](https://user-images.githubusercontent.com/194400/57980416-62b53e80-7a23-11e9-948a-7c423ecb18c1.png) 176 | 177 | > note: the "Zoom" in chrome to 500% for _effect_. 178 | 179 | 180 | Now, take your time to peruse the code in `/test` and `/lib`, 181 | and _ask_ any questions by opening GitHub Issues: 182 | https://github.com/dwyl/hits/issues 183 | 184 | 185 | ### Run the Tests 186 | 187 | To run the tests on your localhost, 188 | execute the following command in your terminal: 189 | 190 | ```elixir 191 | mix test 192 | ``` 193 | 194 | To run the tests with coverage, 195 | run the following command 196 | in your terminal: 197 | 198 | ```elixir 199 | MIX_ENV=test mix cover 200 | ``` 201 | 202 | If you want to view the coverage in a web browser: 203 | 204 | ```elixir 205 | mix coveralls.html && open cover/excoveralls.html 206 | ``` 207 | 208 |

209 | 210 | 211 | # _Implementation_ 212 | 213 | This is a step-by-step guide 214 | to _building_ the Hits App 215 | from scratch 216 | in Phoenix. 217 | 218 | 219 | ### Assumptions / Prerequisites 220 | 221 | + [x] `Elixir` & `Phoenix` installed. 222 | see: [**_before_ you start**](https://github.com/dwyl/phoenix-chat-example#0-pre-requisites-before-you-start) 223 | + [x] Basic knowledge/understanding of `Elixir` syntax: 224 | https://github.com/dwyl/learn-elixir#how 225 | + [x] Basic understanding of `Phoenix`: 226 | https://github.com/dwyl/learn-phoenix-framework 227 | + [x] Basic PostgreSQL knowledge: 228 | [github.com/dwyl/**learn-postgresql**](https://github.com/dwyl/learn-postgresql) 229 | + [x] Test Driven Development (TDD): 230 | [github.com/dwyl/**learn-tdd**](https://github.com/dwyl/learn-tdd) 231 | 232 | ## Create New Phoenix App 233 | 234 | 235 | ```sh 236 | mix phx.new hits 237 | ``` 238 | When prompted to install the dependencies: 239 | ```sh 240 | Fetch and install dependencies? [Yn] 241 | ``` 242 | Type `Y` and the `Enter` key to install. 243 | 244 | You should see something like this in your terminal: 245 | ```sh 246 | * running mix deps.get 247 | * running cd assets && npm install && node node_modules/webpack/bin/webpack.js --mode development 248 | * running mix deps.compile 249 | 250 | We are almost there! The following steps are missing: 251 | 252 | $ cd hits 253 | 254 | Then configure your database in config/dev.exs and run: 255 | 256 | $ mix ecto.create 257 | 258 | Start your Phoenix app with: 259 | 260 | $ mix phx.server 261 | 262 | You can also run your app inside IEx (Interactive Elixir) as: 263 | 264 | $ iex -S mix phx.server 265 | ``` 266 | 267 | Follow the instructions (run the following commands) 268 | to create the PostgreSQL database for the app: 269 | 270 | ```sh 271 | cd hits 272 | mix ecto.create 273 | ``` 274 | 275 | You should see the following in your terminal: 276 | 277 | ```sh 278 | Compiling 13 files (.ex) 279 | Generated hits app 280 | The database for Hits.Repo has already been created 281 | ``` 282 | 283 | Run the default tests to confirm everything is working: 284 | 285 | ```sh 286 | mix test 287 | ``` 288 | You should see the following output 289 | ```sh 290 | Generated hits app 291 | ... 292 | 293 | Finished in 0.03 seconds 294 | 3 tests, 0 failures 295 | 296 | Randomized with seed 98214 297 | ``` 298 | 299 | 300 | Start the Phoenix server: 301 | ```sh 302 | mix phx.server 303 | ``` 304 | 305 | That spits out a bunch of data about Webpack compilation: 306 | ```sh 307 | [info] Running HitsWeb.Endpoint with cowboy 2.6.3 at 0.0.0.0:4000 (http) 308 | [info] Access HitsWeb.Endpoint at http://localhost:4000 309 | 310 | Webpack is watching the files… 311 | 312 | Hash: 1fc94cc9b786e491ad40 313 | Version: webpack 4.4.0 314 | Time: 609ms 315 | Built at: 05/05/2019 08:58:46 316 | Asset Size Chunks Chunk Names 317 | ../css/app.css 10.6 KiB ./js/app.js [emitted] ./js/app.js 318 | app.js 7.26 KiB ./js/app.js [emitted] ./js/app.js 319 | ../favicon.ico 1.23 KiB [emitted] 320 | ../robots.txt 202 bytes [emitted] 321 | ../images/phoenix.png 13.6 KiB [emitted] 322 | [0] multi ./js/app.js 28 bytes {./js/app.js} [built] 323 | [../deps/phoenix_html/priv/static/phoenix_html.js] 2.21 KiB {./js/app.js} [built] 324 | [./css/app.css] 39 bytes {./js/app.js} [built] 325 | [./js/app.js] 493 bytes {./js/app.js} [built] 326 | + 2 hidden modules 327 | Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!css/app.css: 328 | [./node_modules/css-loader/dist/cjs.js!./css/app.css] 284 bytes {mini-css-extract-plugin} [built] 329 | [./node_modules/css-loader/dist/cjs.js!./css/phoenix.css] 10.9 KiB {mini-css-extract-plugin} [built] 330 | + 1 hidden module 331 | ``` 332 | 333 | Visit the app in your web browser to confirm it's all working: 334 | http://localhost:4000 335 | ![phoenix-app-default-homepage](https://user-images.githubusercontent.com/194400/57190794-71293380-6f16-11e9-8df3-1fb87139e6a3.png) 336 | 337 | The default Phoenix App home page 338 | should be familiar to you 339 | if you followed our Chat example/tutorial 340 | [github.com/dwyl/**phoenix-chat-example**](https://github.com/dwyl/phoenix-chat-example) 341 | 342 | 343 | ## Create the _Static_ Home Page 344 | 345 | In order to help people understand what Hits is 346 | and how they can add a counter badge to their project, 347 | we have a simple (_static_) home page. 348 | In the interest of doing a "feature parity" migration 349 | from the Node.js MVP to the Phoenix version, 350 | we are just copying over the 351 | [`index.html`](https://github.com/dwyl/hits/blob/0a44edd692b5b765c20c85ed4057a50bbd872507/lib/index.html) 352 | at this stage; we can/will enhance it later. 353 | 354 | Phoenix has the concept of a Layout template 355 | which allows us to put all layout related 356 | code in a single file and 357 | then each subsequent page of content 358 | does not have to worry about static (CSS/JS) assets 359 | and metadata. 360 | Open the file 361 | `/lib/hits_web/templates/layout/app.html.eex` 362 | in your text editor. It should look like this: 363 | ```html 364 | 365 | 366 | 367 | 368 | 369 | 370 | Hits · Phoenix Framework 371 | "/> 372 | 373 | 374 |
375 |
376 | 381 | 384 |
385 |
386 |
387 | 388 | 389 | <%= render @view_module, @view_template, assigns %> 390 |
391 | 392 | 393 | 394 | ``` 395 | 396 | Let's remove the cruft and keep only the essential layout html: 397 | 398 | ```html 399 | 400 | 401 | 402 | 403 | 404 | 405 | Hits 406 | 407 | 408 | 409 | 410 |
411 | 412 | <%= render @view_module, @view_template, assigns %> 413 |
414 | 415 | 426 | 427 | 428 | ``` 429 | We removed the link to `app.css` 430 | and a couple of elements 431 | as we don't need them; 432 | we can always add them back later, 433 | that's the beauty of version control, 434 | nothing is ever "lost". 435 | 436 | If you refresh the page you should see the following: 437 | ![phoenix-homepage-no-style](https://user-images.githubusercontent.com/194400/57190961-4cce5680-6f18-11e9-8542-582c9120196f.png) 438 | 439 | Don't panic, this is _expected_! 440 | We just removed `app.css` in the layout template 441 | and Phoenix does not have/use any Tachyons classes 442 | so no styling is present. 443 | We'll fix it in the next step. 444 | 445 | Open the homepage template file in your editor: 446 | `lib/hits_web/templates/page/index.html.eex` 447 | 448 | You should see something like this: 449 | ```html 450 |
451 |

<%= gettext "Welcome to %{name}!", name: "Phoenix" %>

452 |

A productive web framework that
does not compromise speed or maintainability.

453 |
454 | 455 |
456 | 470 | 484 |
485 | ``` 486 | 487 | Notice how the page template only has the HTML code 488 | relevant to rendering _this_ page.
489 | Let's replace the code in the file 490 | with the markup relevant to the Hits homepage: 491 | 492 | ```html 493 |

494 | Hits! 495 | 496 | Hit Count 497 | 498 |

499 |

500 | The easy way to know how many people are 501 | viewing your GitHub projects! 502 |

503 | 504 |

How?

505 |
506 | 507 | 508 | 509 | 513 | 517 | 518 | 519 | 523 | 527 | 528 | 529 | 532 | 538 | 539 |
510 | Input your GitHub Username 511 | ( or org name): 512 | 514 | 516 |
520 | Input the GitHub Project/Repository 521 | name: 522 | 524 | 526 |
530 | Choose a style for your badge: 531 | 533 | 537 |
540 |
541 | 542 |

Your Badge Markdown:

543 |
 544 |   [![HitCount](https://hits.dwyl.com/{username}/{repo}.svg?style={style})](https://hits.dwyl.com/{username}/{repo})
 545 | 
546 | 547 |

548 | Using the above markdown as a template,
549 | Replace the {username} with your GitHub username
550 | Replace the {repo} with the repo name. 551 |

552 | 553 |

554 | Copy the markdown snippet and Paste it into your 555 | README.md file
556 | to start tracking the view count on your GitHub project! 557 |

558 | 559 |

Recently Viewed Projects (tracked by Hits)

560 |
561 |
Dummy Child Node for insertBefore to work
562 |
563 | ``` 564 | 565 | > _**Note**: we are using Tachyons (Functional) CSS 566 | for styling the page, 567 | if you haven't yet learned about Tachyons, 568 | we recommend reading_: 569 | [github.com/dwyl/**learn-tachyons**](https://github.com/dwyl/learn-tachyons) 570 | 571 | This is a fairly simple homepage. 572 | The only _interesting_ part are the Tachyons styles 573 | which are fairly straightforward. 574 | 575 | Finally we need to update 576 | `assets/js/app.js` 577 | to add the code to render a badge 578 | when people input their `username` and `repo` name. 579 | 580 | Open the `assets/js/app.js` which should look like this: 581 | 582 | ```js 583 | // We need to import the CSS so that webpack will load it. 584 | // The MiniCssExtractPlugin is used to separate it out into 585 | // its own CSS file. 586 | import css from "../css/app.css" 587 | 588 | // webpack automatically bundles all modules in your 589 | // entry points. Those entry points can be configured 590 | // in "webpack.config.js". 591 | // 592 | // Import dependencies 593 | // 594 | import "phoenix_html" 595 | 596 | // Import local files 597 | // 598 | // Local files can be imported directly using relative paths, for example: 599 | // import socket from "./socket" 600 | ``` 601 | 602 | Add the following lines to the end: 603 | ```js 604 | // Markdown Template 605 | var mt = document.getElementById('badge').innerHTML; 606 | 607 | function generate_markdown () { 608 | var user = document.getElementById("username").value || '{username}'; 609 | var repo = document.getElementById("repo").value || '{project}'; 610 | var style = document.getElementById("styles").value || '{style}'; 611 | // console.log('user: ', user, 'repo: ', repo); 612 | user = user.replace(/[.*+?^$<>()|[\]\\]/g, ''); // trim and escape 613 | repo = repo.replace(/[.*+?^$<>()|[\]\\]/g, ''); 614 | return mt.replace(/{username}/g, user).replace(/{repo}/g, repo).replace(/{style}/g, style); 615 | } 616 | 617 | function display_badge_markdown () { 618 | var md = generate_markdown() 619 | var pre = document.getElementById("badge").innerHTML = md; 620 | } 621 | 622 | setTimeout(function () { 623 | var how = document.getElementById("how"); 624 | // show form if JS available (progressive enhancement) 625 | if(how) { 626 | document.getElementById("how").classList.remove('dn'); 627 | document.getElementById("nojs").classList.add('dn'); 628 | display_badge_markdown(); // render initial markdown template 629 | var get = document.getElementsByTagName('input'); 630 | for (var i = 0; i < get.length; i++) { 631 | get[i].addEventListener('keyup', display_badge_markdown, false); 632 | get[i].addEventListener('keyup', display_badge_markdown, false); 633 | } 634 | 635 | // changing markdown preview whenever an option is selected 636 | document.getElementById("styles").onchange = function(e) { 637 | display_badge_markdown() 638 | } 639 | } 640 | }, 500); 641 | ``` 642 | 643 | Run the Phoenix server to see the static page: 644 | ``` 645 | mix phx.server 646 | ``` 647 | Now visit the route in your web browser: 648 | http://localhost:4000 649 | 650 | ![hits-static-homepage](https://user-images.githubusercontent.com/194400/57684208-3ff2e680-762d-11e9-89c4-0b0d04694f5a.png) 651 | 652 | Now that the static homepage is working, 653 | we can move on to the _interesting_ part of the Hits Application! 654 | 655 | > As always, if you have questions or got stuck at any point, 656 | please open an issue and we will help! 657 | https://github.com/dwyl/hits/issues 658 | 659 | ### _Fix_ The Failing Test 660 | 661 | Before moving on to building the app, 662 | let's make sure that the default tests are passing ... 663 | 664 | ``` 665 | mix test 666 | ``` 667 | ![failing-test](https://user-images.githubusercontent.com/194400/57686427-7f233680-7631-11e9-83ef-931016d7b68b.png) 668 | 669 | The reason for this failing test is pretty clear, 670 | the page no longer contains the words "Welcome to Phoenix!". 671 | 672 | Open the file `test/hits_web/controllers/page_controller_test.exs` 673 | and update the assertion text. 674 | 675 | From: 676 | 677 | ```elixir 678 | test "GET /", %{conn: conn} do 679 | conn = get(conn, "/") 680 | assert html_response(conn, 200) =~ "Welcome to Phoenix!" 681 | end 682 | ``` 683 | 684 | To: 685 | 686 | ```elixir 687 | test "GET /", %{conn: conn} do 688 | conn = get(conn, "/") 689 | assert html_response(conn, 200) =~ "Hits!" 690 | end 691 | ``` 692 | 693 | Re-run the test: 694 | 695 | ```sh 696 | mix test 697 | ``` 698 | 699 | ![hits-static-page-test-passing](https://user-images.githubusercontent.com/194400/57686862-46d02800-7632-11e9-8be0-76e46c4d1cd9.png) 700 | 701 | The test should now pass 702 | and we can crack on with creating the schemas! 703 | 704 | 705 | ## Create The Database for Storing Data 706 | 707 | As is typical of most Phoenix applications, 708 | we will be using a PostgreSQL database for storing data. 709 | 710 | In your terminal, run the create script: 711 | 712 | ```sh 713 | mix ecto.create 714 | ``` 715 | In your terminal you should see: 716 | 717 | ```sh 718 | Compiling 2 files (.ex) 719 | The database for Hits.Repo has been created 720 | ``` 721 | This tells you the PostgreSQL database **`hits_dev`** was successfully created. 722 | 723 | ### Note on Database Normalization 724 | 725 | In designing the Hits App database, 726 | we decided to normalize 727 | the database tables for efficient storage 728 | because we wanted to make the storage of an individual hit 729 | as minimal as possible. 730 | This means we have 4 schemas/tables to ensure there is no duplicate data 731 | and each bit of data is only stored _once_. 732 | We could have stored all the data in a _single_ table 733 | and on the surface this is appealing 734 | because it would only require one insert 735 | query and no "joins" when selecting/counting hits. 736 | But the initial benefit of a single table 737 | would be considerably outweighed 738 | by the wasted space of duplicate data. 739 | 740 | This is not the time or place 741 | to dive into the merits 742 | of database normalization and denormalisation. 743 | We will have a chance to explore it later 744 | when we need to optimise query performance. 745 | For now we are focussing on building the App 746 | with a database normalized to the third normal form (3NF) 747 | because it achieves a good balance of 748 | eliminating data duplication thus maximising storage efficiency 749 | while still having adequate query performance. 750 | 751 | You won't need to understand any of these concepts 752 | to follow along with building the Hits app. 753 | But if you are curious about any of these words, read the following pages: 754 | + https://en.wikipedia.org/wiki/Database_normalization 755 | + https://en.wikipedia.org/wiki/Denormalization 756 | + https://en.wikipedia.org/wiki/Third_normal_form 757 | 758 | ### Create the 4 Schemas 759 | 760 | + users - for simplicity sake we are assuming that 761 | all repositories belong to a "user" and not an organisation. 762 | + repositories - the projects on GitHub 763 | + useragents - the web browsers viewing the project pages 764 | + hits - the record of each "hit" (page view). 765 | 766 | ```sh 767 | mix phx.gen.schema User users name:string 768 | mix phx.gen.schema Repository repositories name:string user_id:references:users 769 | mix phx.gen.schema Useragent useragents name:string ip:string 770 | mix phx.gen.schema Hit hits repo_id:references:repositories useragent_id:references:useragents 771 | ``` 772 | In your terminal, 773 | you will see a suggestion in the terminal output similar to this: 774 | 775 | 776 | Before we can run the database migration, we must create the database. 777 | 778 | Now we can run the scripts to create the database tables: 779 | ``` 780 | mix ecto.migrate 781 | ``` 782 | 783 | In your terminal, you should see: 784 | 785 | ```sh 786 | Compiling 17 files (.ex) 787 | Generated hits app 788 | [info] == Running 20190515211749 Hits.Repo.Migrations.CreateUsers.change/0 forward 789 | [info] create table users 790 | [info] == Migrated 20190515211749 in 0.0s 791 | [info] == Running 20190515211755 Hits.Repo.Migrations.CreateRepositories.change/0 forward 792 | [info] create table repositories 793 | [info] create index repositories_user_id_index 794 | [info] == Migrated 20190515211755 in 0.0s 795 | [info] == Running 20190515211804 Hits.Repo.Migrations.CreateUseragents.change/0 forward 796 | [info] create table useragents 797 | [info] == Migrated 20190515211804 in 0.0s 798 | [info] == Running 20190515211819 Hits.Repo.Migrations.CreateHits.change/0 forward 799 | [info] create table hits 800 | [info] create index hits_repo_id_index 801 | [info] create index hits_useragent_id_index 802 | [info] == Migrated 20190515211819 in 0.0s 803 | ``` 804 | > _**Note**: the dates of your migration files will differ from these. 805 | The 14 digit number corresponds to the date and time 806 | in the format **`YYYYMMDDHHMMSS`**. 807 | This is helpful for knowing when the database schemas/fields 808 | were created or updated._ 809 | 810 | To make sure users, useragents and repositories are unique, 811 | three more migrations are created to add `unique_index`: 812 | 813 | For users we want the name to be unique 814 | ```elixir 815 | def change do 816 | create unique_index(:users, [:name]) 817 | end 818 | ``` 819 | 820 | For useragents, we want the name and the ip address unique 821 | 822 | ```elixir 823 | def change do 824 | create unique_index(:useragents, [:name, :ip]) 825 | end 826 | ``` 827 | 828 | Finally for repositories we want the name and the relation to the user to be 829 | unique 830 | 831 | ```elixir 832 | def change do 833 | create unique_index(:repositories, [:name, :user_id]) 834 | end 835 | ``` 836 | 837 | These unique indexes insure that no duplicates are created at the database level. 838 | 839 | 840 | We can now use the `upsert` Ecto/Postgres feature to only create new items 841 | or updating the existing items. 842 | 843 | 844 | For example with useragent: 845 | ```elixir 846 | Repo.insert!(changeset, 847 | on_conflict: [set: [ip: changeset.changes.ip, name: changeset.changes.name]], 848 | conflict_target: [:ip, :name] 849 | ) 850 | ``` 851 | 852 | - `conflict_target`: Define which fields to check for existing entry 853 | - `on_conflict`: Define what to do when there is a conflict. In our case 854 | we update the ip and name values. 855 | 856 | 857 | #### View the Entity Relationship (ER) Diagram 858 | 859 | Now that the Postgres database tables have been created, 860 | you can fire up your database client 861 | (_e.g: DBeaver in this case_) 862 | and view the Entity Relationship (ER) Diagram: 863 | 864 | ![hits-er-diagram](https://user-images.githubusercontent.com/194400/57219989-b1a9af80-6ff1-11e9-8968-e3b76428093d.png) 865 | 866 | This us shows us the four tables we created above 867 | and how they are related (_with foreign keys_). 868 | It also shows us that there is `schema_migrations` table, 869 | which is _unrelated_ to the tables we created for our app, 870 | but contains the log of the schema migrations that have been run 871 | and when they were applied to the database: 872 | 873 | ![hits-schema-migrations](https://user-images.githubusercontent.com/194400/57811257-b55fd380-7761-11e9-9ad3-cf06757a410b.png) 874 | 875 | The keen observer will note that the migration table data: 876 | ```sh 877 | version |inserted_at | 878 | --------------|-------------------| 879 | 20190515211749|2019-05-15 21:18:38| 880 | 20190515211755|2019-05-15 21:18:38| 881 | 20190515211804|2019-05-15 21:18:38| 882 | 20190515211819|2019-05-15 21:18:38| 883 | ``` 884 | The version column corresponds to the date timestamps 885 | in the migration file names: 886 | 887 | priv/repo/migrations/**20190515211749**_create_users.exs
888 | priv/repo/migrations/**20190515211755**_create_repositories.exs
889 | priv/repo/migrations/**20190515211804**_create_useragents.exs
890 | priv/repo/migrations/**20190515211819**_create_hits.exs
891 | 892 | 893 | ### _Run_ the Tests 894 | 895 | Once you have created the schemas and run the resulting migrations, 896 | it's time to run the tests! 897 | 898 | ```sh 899 | mix test 900 | ``` 901 | 902 | Everything should still pass because `phx.gen.schema` 903 | does not create any new tests 904 | and our previous tests are unaffected. 905 | 906 |
907 | 908 | ## SVG Badge Template 909 | 910 | We created the SVG badge template for our MVP 911 | [`template.svg`](https://github.com/dwyl/hits-nodejs/blob/master/lib/template.svg) 912 | and it still serves our needs 913 | so there's no need to change it. 914 | 915 | Create a new file `lib/hits_web/templates/hit/badge_flat_square.svg` 916 | and paste the following SVG code in it: 917 | 918 | ```svg 919 | 920 | 921 | 922 | 923 | 925 | hits 926 | {count} 927 | 928 | 929 | ``` 930 | The comments are there for beginner-friendliness, 931 | they are stripped out before sending the badge to the client 932 | to conserve bandwidth. 933 | 934 | # Alternative Badge Formats 🌈 935 | 936 | Several people have requested 937 | an alternative badge format. 938 | Rather than spend a lot of time 939 | customizing the badges ourselves, 940 | we are going to use 941 | [shields.io/endpoint](https://shields.io/endpoint) 942 | that allows full badge customization. 943 | ## Adding `JSON` Content Negotiation 944 | 945 | First thing we need to do 946 | is add the ability to return `JSON` 947 | instead of `SVG`. 948 | In `HTTP` this is referred to as 949 | Content Negotiation: 950 | [wikipedia.org/wiki/Content_negotiation](https://en.wikipedia.org/wiki/Content_negotiation) 951 | 952 | ### Installing `params` and `content` 953 | 954 | We are using 955 | [`params`](https://github.com/vic/params) 956 | to validate the query parameters 957 | and 958 | [`content`](https://github.com/dwyl/content) 959 | to add content negotiation on our endpoints. 960 | 961 | Let's install these 962 | by adding them to the `deps` 963 | section `mix.exs`: 964 | 965 | ```elixir 966 | defp deps do 967 | [ 968 | # For content negotiation 969 | {:content, "~> 1.3.0"}, 970 | 971 | # Query param schema validation 972 | {:params, "~> 2.0"}, 973 | ] 974 | end 975 | ``` 976 | 977 | ### Defining Validation Schema 978 | 979 | The schema **must be compatible with `shield.io`**. 980 | We make use of a `schema validator` 981 | so we know that the parameters 982 | passed by the users are valid. 983 | 984 | The possible values of each field 985 | were determined according to 986 | [shields.io/endpoint](https://shields.io/endpoint) 987 | 988 | The valid parameters are: 989 | 990 | ```elixir 991 | defparams schema_validator %{ 992 | user!: :string, 993 | repository!: :string, 994 | style: [ 995 | field: Ecto.Enum, 996 | values: [ 997 | plastic: "plastic", 998 | flat: "flat", 999 | flatSquare: "flat-square", 1000 | forTheBadge: "for-the-badge", 1001 | social: "social" 1002 | ], 1003 | default: :flat 1004 | ], 1005 | color: [field: :string, default: "lightgrey"], 1006 | show: [field: :string, default: nil], 1007 | } 1008 | ``` 1009 | 1010 | By default, each badge is `lightgrey` 1011 | and has a `flat` style. 1012 | 1013 | This `defparams` defintion is in the 1014 | `/lib/hits_web/controllers/hit_controller.ex` 1015 | file. 1016 | 1017 | ### Content negotiation 1018 | 1019 | Luckily, the `content` package 1020 | makes it relatively easy to differentiate 1021 | `HTTP` and `JSON` requests. 1022 | 1023 | The way we implement different behaviours 1024 | for `JSON` requests is made through 1025 | the following template: 1026 | 1027 | ```elixir 1028 | if Content.get_accept_header(conn) =~ "json" do 1029 | # return json 1030 | else 1031 | # render page 1032 | end 1033 | ``` 1034 | 1035 | You will notice this behaviour in 1036 | [`lib/hits_web/controllers/hit_controller.ex`](https://github.com/dwyl/hits/blob/37d3a91022f4aad25558f4c6f3e2bd01c933d63a/lib/hits_web/controllers/hit_controller.ex#L50-L54) 1037 | 1038 | After correct setup, 1039 | the returned JSON object 1040 | depends on the parameters the user defines. 1041 | 1042 | ```elixir 1043 | def render_json(conn, count, params) do 1044 | json_response = %{ 1045 | "schemaVersion" => "1", 1046 | "label" => "hits", 1047 | "style" => params.style, 1048 | "message" => count, 1049 | "color" => params.color 1050 | } 1051 | json(conn, json_response) 1052 | end 1053 | ``` 1054 | 1055 | This function effectively makes it so 1056 | the endpoint *returns* a `JSON` object 1057 | following Shields.io schema convention 1058 | which can later be used in 1059 | [shields.io/endpoint](https://shields.io/endpoint) 1060 | 1061 | ### Expected `JSON` response 1062 | 1063 | If you run `mix phx.server` 1064 | and open a separate terminal session, 1065 | paste the following `cURL` command and run: 1066 | 1067 | ```sh 1068 | curl -H "Accept: application/json" http://localhost:4000/user/repo\?color=blue 1069 | ``` 1070 | 1071 | The output will be the following. 1072 | 1073 | ```sh 1074 | {"color":"blue","label":"hits","message":6,"schemaVersion":"1","style":"flat"}% 1075 | ``` 1076 | 1077 | You can easily check the `JSON` in a web browser too. 1078 | Simply open Firefox and visit the URL: 1079 | http://localhost:4000/user/repo.json?color=blue 1080 | 1081 | ![json-in-browser](https://user-images.githubusercontent.com/194400/208438106-e2fc8528-d9d5-4906-9fa1-6f588d9d5b3e.png) 1082 | 1083 | And if you replace the `.json` in the URL with `.svg` 1084 | you will see the badge as expected: 1085 | http://localhost:4000/user/repo.svg 1086 | 1087 | ![svg-in-browser](https://user-images.githubusercontent.com/194400/208438454-0645cf7b-62f8-4b3d-9153-c6e66747456b.png) 1088 | 1089 | The **same endpoint** is used 1090 | for both `HTTP` requests 1091 | and also outputs a `JSON` object. 1092 | 1093 | Now for the fun part!! 1094 | 1095 | ## Using Shields to Create _Any_ Style of Button! 1096 | 1097 | ```md 1098 | https://img.shields.io/endpoint?url=https://hits.dwyl.com/dwyl/hits.json?style=flat-square&show=unique?color=orange 1099 | ``` 1100 | 1101 | Fully customizable: 1102 | 1103 | ![fully-custom](https://user-images.githubusercontent.com/194400/208462345-fdfa1dc4-561e-437a-9727-5e92f5853cca.png) 1104 | 1105 | ![Custom badge](https://img.shields.io/endpoint?color=red&label=amaze&logo=ducati&logoColor=pink&url=https%3A%2F%2Fhits.dwyl.com%2Fnelson%2Fhello.json%3Fstyle%3Dflat-square%26show%3Dunique%26color%3Dpink) 1106 | ![Custom badge](https://img.shields.io/endpoint?color=blue&label=amaze&logo=ducati&logoColor=blue&style=for-the-badge&url=https%3A%2F%2Fhits.dwyl.com%2Fnelson%2Fhello.json%3Fstyle%3Dflat-square%26show%3Dunique%26color%3Dpink) 1107 | ![Custom badge](https://img.shields.io/endpoint?color=%23ff00bf&label=amaze&logo=elixir&logoColor=%23ff00bf&style=for-the-badge&url=https%3A%2F%2Fhits.dwyl.com%2Fdwyl%2Fhits.json%3Fstyle%3Dflat-square%26show%3Dunique%26color%3Dpink) 1108 | 1109 | Plenty of logos to chose from at: 1110 | https://simpleicons.org 1111 | 1112 | 1113 | # tl;dr 1114 | 1115 | ![draw-the-dog](https://user-images.githubusercontent.com/194400/58163803-88895000-7c7c-11e9-82f1-8afe63b40f99.png) 1116 | 1117 | > But seriously, if you want a step-by-step tutorial, 1118 | leave a comment on: https://github.com/dwyl/hits/issues/74 1119 | 1120 | 1137 | 1138 | ## Add Channel 1139 | 1140 | If you are new to Phoenix Channels, please recap: 1141 | https://github.com/dwyl/phoenix-chat-example 1142 | 1143 | In your terminal, run the following command: 1144 | ```sh 1145 | mix phx.gen.channel Hit 1146 | ``` 1147 | You should see the following output: 1148 | 1149 | ``` 1150 | * creating lib/hits_web/channels/hit_channel.ex 1151 | * creating test/hits_web/channels/hit_channel_test.exs 1152 | 1153 | Add the channel to your `lib/hits_web/channels/user_socket.ex` handler, for example: 1154 | 1155 | channel "hit:lobby", HitsWeb.HitChannel 1156 | ``` 1157 | 1158 | > If you want to see the code required 1159 | to render the hits on the homepage in realtime, 1160 | please see: https://github.com/dwyl/hits/pull/80/files 1161 | 1162 | 1163 | 1164 | 1165 | ## Research & Background Reading 1166 | 1167 | If you found this repository useful, please ⭐️ it so we (and others) know you liked it! 1168 | 1169 | We found the following links/articles/posts _useful_ 1170 | when learning how to build this mini-project: 1171 | 1172 | + Plug Docs: https://hexdocs.pm/plug/readme.html (_the official Plug docs_) 1173 | + Plug Conn (_connection struct specific_) Docs: 1174 | https://hexdocs.pm/plug/Plug.Conn.html 1175 | (_the are feature-complete but no practical/usage examples!_) 1176 | + Understanding Plug (Phoenix Blog): https://hexdocs.pm/phoenix/plug.html 1177 | + Elixir School Plug: 1178 | https://elixirschool.com/en/lessons/specifics/plug/ 1179 | + Getting started with Plug in Elixir: 1180 | https://www.brianstorti.com/getting-started-with-plug-elixir 1181 | (_has a good/simple example of "Plug.Builder"_) 1182 | + Elixir Plug unveiled: 1183 | https://medium.com/@kansi/elixir-plug-unveiled-bf354e364641 1184 | + Building a web framework from scratch in Elixir: 1185 | https://codewords.recurse.com/issues/five/building-a-web-framework-from-scratch-in-elixir 1186 | + Testing Plugs: https://robots.thoughtbot.com/testing-elixir-plugs 1187 | + How to broadcast a message from a Phoenix Controller to a Channel? 1188 | https://stackoverflow.com/questions/33960207/how-to-broadcast-a-message-from-a-phoenix-controller-to-a-channel 1189 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | 3 | @import "./phoenix.css"; 4 | -------------------------------------------------------------------------------- /assets/css/phoenix.css: -------------------------------------------------------------------------------- 1 | /* Includes some default style for the starter application. 2 | * This can be safely deleted to start fresh. 3 | */ 4 | 5 | /* Milligram v1.3.0 https://milligram.github.io 6 | * Copyright (c) 2017 CJ Patoilo Licensed under the MIT license 7 | */ 8 | 9 | *,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem;width:100%}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{-ms-grid-row-align:center;align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem;text-align:left}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} 10 | 11 | /* General style */ 12 | h1{font-size: 3.6rem; line-height: 1.25} 13 | h2{font-size: 2.8rem; line-height: 1.3} 14 | h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35} 15 | h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5} 16 | h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4} 17 | h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2} 18 | 19 | .container{ 20 | margin: 0 auto; 21 | max-width: 80.0rem; 22 | padding: 0 2.0rem; 23 | position: relative; 24 | width: 100% 25 | } 26 | select { 27 | width: auto; 28 | } 29 | 30 | /* Alerts and form errors */ 31 | .alert { 32 | padding: 15px; 33 | margin-bottom: 20px; 34 | border: 1px solid transparent; 35 | border-radius: 4px; 36 | } 37 | .alert-info { 38 | color: #31708f; 39 | background-color: #d9edf7; 40 | border-color: #bce8f1; 41 | } 42 | .alert-warning { 43 | color: #8a6d3b; 44 | background-color: #fcf8e3; 45 | border-color: #faebcc; 46 | } 47 | .alert-danger { 48 | color: #a94442; 49 | background-color: #f2dede; 50 | border-color: #ebccd1; 51 | } 52 | .alert p { 53 | margin-bottom: 0; 54 | } 55 | .alert:empty { 56 | display: none; 57 | } 58 | .help-block { 59 | color: #a94442; 60 | display: block; 61 | margin: -1rem 0 2rem; 62 | } 63 | 64 | /* Phoenix promo and logo */ 65 | .phx-hero { 66 | text-align: center; 67 | border-bottom: 1px solid #e3e3e3; 68 | background: #eee; 69 | border-radius: 6px; 70 | padding: 3em; 71 | margin-bottom: 3rem; 72 | font-weight: 200; 73 | font-size: 120%; 74 | } 75 | .phx-hero p { 76 | margin: 0; 77 | } 78 | .phx-logo { 79 | min-width: 300px; 80 | margin: 1rem; 81 | display: block; 82 | } 83 | .phx-logo img { 84 | width: auto; 85 | display: block; 86 | } 87 | 88 | /* Headers */ 89 | header { 90 | width: 100%; 91 | background: #fdfdfd; 92 | border-bottom: 1px solid #eaeaea; 93 | margin-bottom: 2rem; 94 | } 95 | header section { 96 | align-items: center; 97 | display: flex; 98 | flex-direction: column; 99 | justify-content: space-between; 100 | } 101 | header section :first-child { 102 | order: 2; 103 | } 104 | header section :last-child { 105 | order: 1; 106 | } 107 | header nav ul, 108 | header nav li { 109 | margin: 0; 110 | padding: 0; 111 | display: block; 112 | text-align: right; 113 | white-space: nowrap; 114 | } 115 | header nav ul { 116 | margin: 1rem; 117 | margin-top: 0; 118 | } 119 | header nav a { 120 | display: block; 121 | } 122 | 123 | @media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */ 124 | header section { 125 | flex-direction: row; 126 | } 127 | header nav ul { 128 | margin: 1rem; 129 | } 130 | .phx-logo { 131 | flex-basis: 527px; 132 | margin: 2rem 1rem; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | import css from "../css/app.css" 5 | 6 | // webpack automatically bundles all modules in your 7 | // entry points. Those entry points can be configured 8 | // in "webpack.config.js". 9 | // 10 | // Import dependencies 11 | // 12 | import "phoenix_html" 13 | 14 | // Import local files 15 | import socket from "./socket" 16 | 17 | // Get Markdown Template from HTML: 18 | var mt = document.getElementById('badge').innerHTML; 19 | var mtu = document.getElementById('badge-unique').innerHTML; 20 | var mtendpoint = document.getElementById('badge-endpoint').innerHTML; 21 | var mtsheilds = document.getElementById('badge-shields').innerHTML; 22 | 23 | function generate_markdown (template) { 24 | var user = document.getElementById("username").value || '{username}'; 25 | var repo = document.getElementById("repo").value || '{project}'; 26 | var style = document.getElementById("styles").value || '{style}'; 27 | // console.log('user: ', user, 'repo: ', repo); 28 | user = user.replace(/[.*+?^$<>()|[\]\\]/g, ''); 29 | repo = repo.replace(/[.*+?^$<>()|[\]\\]/g, ''); 30 | return template.replace(/{username}/g, user).replace(/{repo}/g, repo).replace(/{style}/g, style); 31 | } 32 | 33 | function display_badge_markdown () { 34 | var md = generate_markdown(mt) 35 | var mdu = generate_markdown(mtu) 36 | var mdendpoint = generate_markdown(mtendpoint) 37 | var mdshields = generate_markdown(mtsheilds) 38 | 39 | document.getElementById("badge").innerHTML = md; 40 | document.getElementById("badge-unique").innerHTML = mdu; 41 | document.getElementById("badge-endpoint").innerHTML = mdendpoint; 42 | document.getElementById("badge-shields").innerHTML = mdshields; 43 | document.getElementById("badge-shields-svg").src = mdshields; 44 | 45 | } 46 | 47 | setTimeout(function () { 48 | var how = document.getElementById("how"); 49 | // show form if JS available (progressive enhancement) 50 | if(how) { 51 | document.getElementById("how").classList.remove('dn'); 52 | document.getElementById("nojs").classList.add('dn'); 53 | display_badge_markdown(); // render initial markdown template 54 | var get = document.getElementsByTagName('input'); 55 | for (var i = 0; i < get.length; i++) { 56 | get[i].addEventListener('keyup', display_badge_markdown, false); 57 | get[i].addEventListener('keyup', display_badge_markdown, false); 58 | } 59 | 60 | // changing markdown preview whenever an option is selected 61 | document.getElementById("styles").onchange = function(e) { 62 | display_badge_markdown() 63 | } 64 | } 65 | }, 500); 66 | 67 | // Now that you are connected, you can join channels with a topic: 68 | let channel = socket.channel("hit:lobby", {}) 69 | channel.join() 70 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 71 | .receive("error", resp => { console.log("Unable to join", resp) }) 72 | 73 | channel.on('hit', function (payload) { // listen to the 'shout' event 74 | console.log('hit', payload); 75 | append_hit(payload); 76 | // var li = document.createElement("li"); // creaet new list item DOM element 77 | // var name = payload.name || 'guest'; // get name from payload or set default 78 | // li.innerHTML = '' + name + ': ' + payload.message; 79 | // ul.appendChild(li); // append to list 80 | }); 81 | 82 | const root = document.getElementById("hits"); 83 | function append_hit (data) { 84 | const previous = root.childNodes[0]; 85 | root.insertBefore(div(data), previous); 86 | // remove default message if displayed 87 | // see https://github.com/dwyl/hits/issues/149 88 | const defaultMsg = document.getElementById('default-websockets-msg'); 89 | defaultMsg && defaultMsg.remove(); 90 | } 91 | 92 | function link (data) { 93 | const a = document.createElement('a'); 94 | const repo = data.repo.split('.svg')[0] 95 | const text = "/" + data.user + '/' + repo; 96 | const linkText = document.createTextNode(text); 97 | a.appendChild(linkText); 98 | a.title = text; 99 | a.href = "https://github.com/" + data.user + '/' + repo; 100 | return a; 101 | } 102 | 103 | // borrowed from: https://git.io/v536m 104 | function div(data) { 105 | let div = document.createElement('div'); 106 | div.id = Date.now(); 107 | div.appendChild(date_as_text()) 108 | div.appendChild(link(data)); 109 | div.appendChild(document.createTextNode(" " + data.count)) 110 | return div; 111 | } 112 | 113 | function date_as_text() { 114 | const DATE = new Date(); 115 | const time = DATE.toUTCString().replace('GMT', ''); 116 | return document.createTextNode(time); 117 | } 118 | 119 | 120 | -------------------------------------------------------------------------------- /assets/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "assets/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket, 5 | // and connect at the socket path in "lib/web/endpoint.ex". 6 | // 7 | // Pass the token on params as below. Or remove it 8 | // from the params if you are not using authentication. 9 | import {Socket} from "phoenix" 10 | 11 | console.log('attempting to connect to socket ...') 12 | let socket = new Socket("/socket") 13 | 14 | // Connect to the socket: 15 | socket.connect() 16 | 17 | export default socket 18 | -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/hits/bd1fd419f9eb34c3dbed5cc0ac5fdaff15d62c6a/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/hits/bd1fd419f9eb34c3dbed5cc0ac5fdaff15d62c6a/assets/static/images/phoenix.png -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :hits, 11 | ecto_repos: [Hits.Repo] 12 | 13 | # Configures the endpoint 14 | config :hits, HitsWeb.Endpoint, 15 | url: [host: "localhost"], 16 | secret_key_base: "HIj/88e7R4rzPELEk+0prDvPCVKZqNMsfJAdNxvV555++5GkfPjqxAoiqAmhI9a7", 17 | render_errors: [view: HitsWeb.ErrorView, accepts: ~w(html json)], 18 | pubsub_server: Hits.PubSub 19 | 20 | # pubsub: [name: Hits.PubSub, adapter: Phoenix.PubSub.PG2] 21 | 22 | # Configures Elixir's Logger 23 | config :logger, :console, 24 | format: "$time $metadata[$level] $message\n", 25 | metadata: [:request_id] 26 | 27 | # Use Jason for JSON parsing in Phoenix 28 | config :phoenix, :json_library, Jason 29 | 30 | # Import environment specific config. This must remain at the bottom 31 | # of this file so it overrides the configuration defined above. 32 | import_config "#{Mix.env()}.exs" 33 | 34 | config :esbuild, 35 | version: "0.13.4", 36 | default: [ 37 | args: ~w(js/app.js --bundle --target=es2016 --outdir=../priv/static/assets), 38 | cd: Path.expand("../assets", __DIR__), 39 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 40 | ] 41 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :hits, Hits.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "hits_dev", 8 | hostname: "localhost", 9 | show_sensitive_data_on_connection_error: true, 10 | pool_size: 10 11 | 12 | # For development, we disable any cache and enable 13 | # debugging and code reloading. 14 | # 15 | # The watchers configuration can be used to run external 16 | # watchers to your application. For example, we use it 17 | # with webpack to recompile .js and .css sources. 18 | config :hits, HitsWeb.Endpoint, 19 | http: [port: 4000], 20 | debug_errors: true, 21 | code_reloader: true, 22 | check_origin: false, 23 | watchers: [ 24 | # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) 25 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} 26 | ] 27 | 28 | # ## SSL Support 29 | # 30 | # In order to use HTTPS in development, a self-signed 31 | # certificate can be generated by running the following 32 | # Mix task: 33 | # 34 | # mix phx.gen.cert 35 | # 36 | # Note that this task requires Erlang/OTP 20 or later. 37 | # Run `mix help phx.gen.cert` for more information. 38 | # 39 | # The `http:` config above can be replaced with: 40 | # 41 | # https: [ 42 | # port: 4001, 43 | # cipher_suite: :strong, 44 | # keyfile: "priv/cert/selfsigned_key.pem", 45 | # certfile: "priv/cert/selfsigned.pem" 46 | # ], 47 | # 48 | # If desired, both `http:` and `https:` keys can be 49 | # configured to run both http and https servers on 50 | # different ports. 51 | 52 | # Watch static and templates for browser reloading. 53 | config :hits, HitsWeb.Endpoint, 54 | live_reload: [ 55 | patterns: [ 56 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 57 | ~r"priv/gettext/.*(po)$", 58 | ~r"lib/hits_web/{live,views}/.*(ex)$", 59 | ~r"lib/hits_web/templates/.*(eex)$" 60 | ] 61 | ] 62 | 63 | # Do not include metadata nor timestamps in development logs 64 | config :logger, :console, format: "[$level] $message\n" 65 | 66 | # Set a higher stacktrace during development. Avoid configuring such 67 | # in production as building large stacktraces may be expensive. 68 | config :phoenix, :stacktrace_depth, 20 69 | 70 | # Initialize plugs at runtime for faster development compilation 71 | config :phoenix, :plug_init_mode, :runtime 72 | -------------------------------------------------------------------------------- /config/docs.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :hits, Hits.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "hits_dev", 8 | hostname: "localhost", 9 | show_sensitive_data_on_connection_error: true, 10 | pool_size: 10 11 | 12 | # For development, we disable any cache and enable 13 | # debugging and code reloading. 14 | # 15 | # The watchers configuration can be used to run external 16 | # watchers to your application. For example, we use it 17 | # with webpack to recompile .js and .css sources. 18 | config :hits, HitsWeb.Endpoint, 19 | http: [port: 4000], 20 | debug_errors: true, 21 | code_reloader: true, 22 | check_origin: false, 23 | watchers: [ 24 | node: [ 25 | "node_modules/webpack/bin/webpack.js", 26 | "--mode", 27 | "development", 28 | "--watch-stdin", 29 | cd: Path.expand("../assets", __DIR__) 30 | ] 31 | ] 32 | 33 | # ## SSL Support 34 | # 35 | # In order to use HTTPS in development, a self-signed 36 | # certificate can be generated by running the following 37 | # Mix task: 38 | # 39 | # mix phx.gen.cert 40 | # 41 | # Note that this task requires Erlang/OTP 20 or later. 42 | # Run `mix help phx.gen.cert` for more information. 43 | # 44 | # The `http:` config above can be replaced with: 45 | # 46 | # https: [ 47 | # port: 4001, 48 | # cipher_suite: :strong, 49 | # keyfile: "priv/cert/selfsigned_key.pem", 50 | # certfile: "priv/cert/selfsigned.pem" 51 | # ], 52 | # 53 | # If desired, both `http:` and `https:` keys can be 54 | # configured to run both http and https servers on 55 | # different ports. 56 | 57 | # Watch static and templates for browser reloading. 58 | config :hits, HitsWeb.Endpoint, 59 | live_reload: [ 60 | patterns: [ 61 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 62 | ~r"priv/gettext/.*(po)$", 63 | ~r"lib/hits_web/{live,views}/.*(ex)$", 64 | ~r"lib/hits_web/templates/.*(eex)$" 65 | ] 66 | ] 67 | 68 | # Do not include metadata nor timestamps in development logs 69 | config :logger, :console, format: "[$level] $message\n" 70 | 71 | # Set a higher stacktrace during development. Avoid configuring such 72 | # in production as building large stacktraces may be expensive. 73 | config :phoenix, :stacktrace_depth, 20 74 | 75 | # Initialize plugs at runtime for faster development compilation 76 | config :phoenix, :plug_init_mode, :runtime 77 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :hits, HitsWeb.Endpoint, 13 | url: [host: "hits.dwyl.com", port: 80], 14 | cache_static_manifest: "priv/static/cache_manifest.json" 15 | 16 | # Do not print debug messages in production 17 | config :logger, level: :info 18 | 19 | # ## SSL Support 20 | # 21 | # To get SSL working, you will need to add the `https` key 22 | # to the previous section and set your `:url` port to 443: 23 | # 24 | # config :hits, HitsWeb.Endpoint, 25 | # ... 26 | # url: [host: "example.com", port: 443], 27 | # https: [ 28 | # :inet6, 29 | # port: 443, 30 | # cipher_suite: :strong, 31 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 32 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 33 | # ] 34 | # 35 | # The `cipher_suite` is set to `:strong` to support only the 36 | # latest and more secure SSL ciphers. This means old browsers 37 | # and clients may not be supported. You can set it to 38 | # `:compatible` for wider support. 39 | # 40 | # `:keyfile` and `:certfile` expect an absolute path to the key 41 | # and cert in disk or a relative path inside priv, for example 42 | # "priv/ssl/server.key". For all supported SSL configuration 43 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 44 | # 45 | # We also recommend setting `force_ssl` in your endpoint, ensuring 46 | # no data is ever sent via http, always redirecting to https: 47 | # 48 | # config :hits, HitsWeb.Endpoint, 49 | # force_ssl: [hsts: true] 50 | # 51 | # Check `Plug.SSL` for all available options in `force_ssl`. 52 | 53 | # ## Using releases (Elixir v1.9+) 54 | # 55 | # If you are doing OTP releases, you need to instruct Phoenix 56 | # to start each relevant endpoint: 57 | # 58 | # config :hits, HitsWeb.Endpoint, server: true 59 | # 60 | # Then you can assemble a release by calling `mix release`. 61 | # See `mix help release` for more information. 62 | 63 | # Finally import the config/prod.secret.exs which loads secrets 64 | # and configuration from environment variables. 65 | # import_config "prod.secret.exs" 66 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if config_env() == :prod do 4 | secret_key_base = 5 | System.get_env("SECRET_KEY_BASE") || 6 | raise """ 7 | environment variable SECRET_KEY_BASE is missing. 8 | You can generate one by calling: mix phx.gen.secret 9 | """ 10 | 11 | app_name = 12 | System.get_env("FLY_APP_NAME") || 13 | raise "FLY_APP_NAME not available" 14 | 15 | config :hits, HitsWeb.Endpoint, 16 | server: true, 17 | url: [host: "#{app_name}.dwyl.com", port: 80], 18 | http: [ 19 | port: String.to_integer(System.get_env("PORT") || "4000"), 20 | # IMPORTANT: support IPv6 addresses 21 | transport_options: [socket_opts: [:inet6]] 22 | ], 23 | secret_key_base: secret_key_base 24 | 25 | database_url = 26 | System.get_env("DATABASE_URL") || 27 | raise """ 28 | environment variable DATABASE_URL is missing. 29 | For example: ecto://USER:PASS@HOST/DATABASE 30 | """ 31 | 32 | config :hits, Hits.Repo, 33 | url: database_url, 34 | # IMPORTANT: Or it won't find the DB server 35 | socket_options: [:inet6], 36 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") 37 | end 38 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :hits, Hits.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "hits_test", 8 | hostname: "localhost", 9 | pool: Ecto.Adapters.SQL.Sandbox 10 | 11 | # We don't run a server during test. If one is required, 12 | # you can enable the server option below. 13 | config :hits, HitsWeb.Endpoint, 14 | http: [port: 4002], 15 | server: false 16 | 17 | # Print only warnings and errors during test 18 | config :logger, level: :warning 19 | 20 | # https://gist.github.com/chrismccord/2ab350f154235ad4a4d0f4de6decba7b#gistcomment-3944918 21 | # Initialize plugs at runtime for faster test compilation 22 | config :phoenix, :plug_init_mode, :runtime 23 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverage_options": { 3 | "minimum_coverage": 100 4 | }, 5 | "skip_files": [ 6 | "test/", 7 | "lib/hits_web.ex", 8 | "lib/hits_web/channels/user_socket.ex", 9 | "lib/hits/release.ex" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /data/dummy_db_data.exs: -------------------------------------------------------------------------------- 1 | # Create dummy data to help while creating the Ecto queries 2 | 3 | alias Hits.Repo 4 | 5 | useragents = [ 6 | %{ 7 | ip: "127.0.0.1", 8 | name: "Firefox", 9 | inserted_at: DateTime.utc_now(), 10 | updated_at: DateTime.utc_now() 11 | } 12 | # After adding the migration for unique_index on ip and name 13 | # This map won't be able to be created in Postgres as it is already saved 14 | # %{ 15 | # ip: "127.0.0.1", 16 | # name: "Firefox", 17 | # inserted_at: DateTime.utc_now(), 18 | # updated_at: DateTime.utc_now() 19 | # } 20 | ] 21 | 22 | Repo.insert_all("useragents", useragents) 23 | -------------------------------------------------------------------------------- /data/hit_data.exs: -------------------------------------------------------------------------------- 1 | alias Hits.Repo 2 | alias Hits.{User, Useragent, Repository} 3 | 4 | useragent = Useragent.insert(%{"ip" => "127.0.0.1", "name" => "Firefox"}) 5 | 6 | user = User.insert(%{"name" => "Simon"}) 7 | 8 | repo = Ecto.build_assoc(user, :repositories) 9 | 10 | # This doesn't insset the hit in the table, so not sure the build_assoc works here. 11 | # repo2 = 12 | # Ecto.build_assoc(useragent, :repositories, repo) 13 | # |> Repository.insert(%{"name" => "repobuildassoc"}) 14 | 15 | # repo1 = 16 | # Ecto.build_assoc(user, :repositories) 17 | # |> Ecto.Changeset.change() 18 | # |> Ecto.Changeset.put_assoc(:useragents, [useragent]) 19 | # |> Repository.insert(%{"name" => "repo1"}) 20 | 21 | # repo2 = 22 | # Ecto.build_assoc(user, :repositories) 23 | # |> Ecto.Changeset.change() 24 | # |> Ecto.Changeset.put_assoc(:useragents, [useragent]) 25 | # |> Repository.insert(%{"name" => "repo2"}) 26 | 27 | # get unique useagents hit on the repo id 2 28 | # Hits.Repo.get!(Hits.Repository, 2) |> Hits.Repo.preload([useragents: (from u in Hits.Useragent, distinct: u.id)]) 29 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for hits on 2021-10-30T15:45:18+01:00 2 | 3 | app = "hits" 4 | 5 | kill_signal = "SIGTERM" 6 | kill_timeout = 5 7 | # processes = [] 8 | 9 | # [build] 10 | # image = "flyio/hellofly:latest" 11 | 12 | [env] 13 | 14 | [deploy] 15 | release_command = "/app/bin/hits eval Hits.Release.migrate" 16 | 17 | # [experimental] 18 | # allowed_public_ports = [] 19 | # auto_rollback = true 20 | 21 | [[services]] 22 | # http_checks = [] 23 | internal_port = 4000 24 | # processes = ["app"] 25 | protocol = "tcp" 26 | # script_checks = [] 27 | 28 | [services.concurrency] 29 | hard_limit = 25 30 | soft_limit = 20 31 | # type = "connections" 32 | 33 | [[services.ports]] 34 | handlers = ["http"] 35 | port = 80 36 | 37 | [[services.ports]] 38 | handlers = ["tls", "http"] 39 | port = 443 40 | 41 | [[services.tcp_checks]] 42 | grace_period = "30s" 43 | interval = "15s" 44 | restart_limit = 6 45 | timeout = "2s" 46 | -------------------------------------------------------------------------------- /lib/hits.ex: -------------------------------------------------------------------------------- 1 | defmodule Hits do 2 | @moduledoc """ 3 | Hits 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 | 10 | @doc """ 11 | svg_badge_flat_square_template/0 opens the SVG template file for the flat square style. 12 | the function is single-purpose so that the template is cached. 13 | 14 | returns String of template. 15 | """ 16 | def svg_badge_flat_square_template do 17 | # Want to help optimize this? See: https://github.com/dwyl/hits/issues/70 18 | # File.read!("./lib/hits_web/templates/hit/badge_flat_square.svg") 19 | """ 20 | 21 | 22 | 23 | 24 | 26 | hits 27 | {count} 28 | 29 | 30 | """ 31 | end 32 | 33 | @doc """ 34 | svg_badge_flat_template/0 opens the SVG template file for the flat style. 35 | the function is single-purpose so that the template is cached. 36 | 37 | returns String of template. 38 | """ 39 | def svg_badge_flat_template do 40 | # Want to help optimize this? See: https://github.com/dwyl/hits/issues/70 41 | # File.read!("./lib/hits_web/templates/hit/badge_flat.svg") 42 | """ 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 59 | 62 | hits 63 | 66 | {count} 67 | 68 | 69 | """ 70 | end 71 | 72 | def svg_invalid_badge do 73 | """ 74 | 75 | 76 | hits: invalid url 77 | 78 | 79 | 80 | 82 | hits 83 | invalid url 84 | 85 | 86 | """ 87 | end 88 | 89 | @doc """ 90 | make_badge/1 from a given svg template style, substituting the count value. Default style is 'flat-square' 91 | 92 | ## Parameters 93 | 94 | - count: Number the view/hit count to be displayed in the badge. 95 | - style: The style wanted (can choose between 'flat' and 'flat-square') 96 | 97 | Returns the badge XML with the count. 98 | """ 99 | def make_badge(count \\ 1, style \\ "") do 100 | case style do 101 | "flat" -> 102 | String.replace(svg_badge_flat_template(), ~r/{count}/, to_string(count)) 103 | |> String.replace(~r//, "") 104 | 105 | _ -> 106 | String.replace(svg_badge_flat_square_template(), ~r/{count}/, to_string(count)) 107 | # stackoverflow.com/a/1084759 108 | |> String.replace(~r//, "") 109 | end 110 | end 111 | 112 | @doc """ 113 | get_user_agent_string/1 extracts user-agent, IP address and browser language 114 | from the Plug.Conn map see: https://hexdocs.pm/plug/Plug.Conn.html 115 | 116 | > there is probably a *much* better way of doing this ... PR v. welcome! 117 | 118 | ## Parameters 119 | 120 | - conn: Map the standard Plug.Conn info see: hexdocs.pm/plug/Plug.Conn.html 121 | 122 | Returns String with user-agent 123 | """ 124 | def get_user_agent_string(conn) do 125 | #  TODO: sanitise useragent string https://github.com/dwyl/fields/issues/19 126 | # extract user-agent from conn.req_headers: 127 | [{_, ua}] = 128 | Enum.filter(conn.req_headers, fn {k, _} -> 129 | k == "user-agent" 130 | end) 131 | 132 | ua 133 | end 134 | 135 | def get_user_ip_address(conn) do 136 | get_x_forwarded_for_ip(conn.req_headers) || 137 | Enum.join(Tuple.to_list(conn.remote_ip), ".") 138 | end 139 | 140 | defp get_x_forwarded_for_ip(headers) do 141 | case Enum.find(headers, fn {k, _} -> k == "x-forwarded-for" end) do 142 | # header doesn't exist 143 | nil -> 144 | nil 145 | 146 | # header exists and can contains multiple ips 147 | {_, ips} -> 148 | ips 149 | |> String.split(",") 150 | |> List.first() 151 | |> String.trim() 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/hits/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Hits.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | # List all child processes to be supervised 10 | children = [ 11 | # Start the Ecto repository 12 | Hits.Repo, 13 | {Phoenix.PubSub, name: Hits.PubSub}, 14 | # Start the endpoint when the application starts 15 | HitsWeb.Endpoint 16 | # Starts a worker by calling: Hits.Worker.start_link(arg) 17 | # {Hits.Worker, arg}, 18 | 19 | ] 20 | 21 | # See https://hexdocs.pm/elixir/Supervisor.html 22 | # for other strategies and supported options 23 | opts = [strategy: :one_for_one, name: Hits.Supervisor] 24 | Supervisor.start_link(children, opts) 25 | end 26 | 27 | # Tell Phoenix to update the endpoint configuration 28 | # whenever the application is updated. 29 | # see: https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#c:config_change/2 30 | # def config_change(changed, _new, removed) do 31 | # HitsWeb.Endpoint.config_change(changed, removed) 32 | # :ok 33 | # end 34 | 35 | # The config_change function is not being used for anything 36 | # but compilation fails if I remove it ... so this is a "dummy" 37 | def config_change(_changed, _new, _removed) do 38 | :ok 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/hits/hit.ex: -------------------------------------------------------------------------------- 1 | defmodule Hits.Hit do 2 | use Ecto.Schema 3 | import Ecto.Query 4 | alias Hits.Repo 5 | 6 | schema "hits" do 7 | belongs_to(:repo, Hits.Repository) 8 | belongs_to(:useragent, Hits.Useragent) 9 | 10 | timestamps() 11 | end 12 | 13 | def count_unique_hits(repository_id) do 14 | # see: github.com/dwyl/hits/issues/71 15 | repository_id 16 | |> get_aggregate_query_unique_hits() 17 | |> Repo.aggregate(:count, :id) 18 | end 19 | 20 | def count_hits(repository_id) do 21 | repository_id 22 | |> get_aggregate_query() 23 | |> Repo.aggregate(:count, :id) 24 | end 25 | 26 | defp get_aggregate_query_unique_hits(repository_id) do 27 | from(h in __MODULE__, 28 | distinct: h.useragent_id, 29 | where: h.repo_id == ^repository_id 30 | ) 31 | end 32 | 33 | defp get_aggregate_query(repository_id) do 34 | from(h in __MODULE__, 35 | where: h.repo_id == ^repository_id 36 | ) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/hits/release.ex: -------------------------------------------------------------------------------- 1 | defmodule Hits.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | @app :hits 7 | 8 | def migrate do 9 | load_app() 10 | 11 | for repo <- repos() do 12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 13 | end 14 | end 15 | 16 | def rollback(repo, version) do 17 | load_app() 18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/hits/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Hits.Repo do 2 | use Ecto.Repo, 3 | otp_app: :hits, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /lib/hits/repository.ex: -------------------------------------------------------------------------------- 1 | defmodule Hits.Repository do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias Hits.Repo 5 | 6 | schema "repositories" do 7 | field(:name, :string) 8 | belongs_to(:user, Hits.User) 9 | 10 | many_to_many(:useragents, Hits.Useragent, 11 | # see https://elixirforum.com/t/ecto-many-to-many-timestamps/13791 12 | join_through: Hits.Hit, 13 | join_keys: [repo_id: :id, useragent_id: :id] 14 | ) 15 | 16 | timestamps() 17 | end 18 | 19 | @doc false 20 | def changeset(repository, attrs) do 21 | repository 22 | |> cast(attrs, [:name]) 23 | |> validate_required([:name]) 24 | end 25 | 26 | @doc """ 27 | insert/1 inserts and returns the repository.id. 28 | 29 | ## Parameters 30 | 31 | - attrs: Map with the name of the person the repository belongs to. 32 | 33 | returns Int user.id 34 | """ 35 | def insert(repository, attrs) do 36 | #  TODO: sanitise repository string using github.com/dwyl/fields/issues/19 37 | # check if user exists 38 | cs = changeset(repository, attrs) 39 | 40 | Repo.insert!(cs, 41 | on_conflict: [set: [name: cs.changes.name]], 42 | conflict_target: [:name, :user_id] 43 | ) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/hits/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Hits.User do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias Hits.Repo 5 | 6 | schema "users" do 7 | field(:name, :string) 8 | has_many(:repositories, Hits.Repository) 9 | 10 | timestamps() 11 | end 12 | 13 | @doc false 14 | def changeset(user, attrs) do 15 | user 16 | |> cast(attrs, [:name]) 17 | |> validate_required([:name]) 18 | end 19 | 20 | @doc """ 21 | insert/1 inserts and returns the user for the request. 22 | 23 | ## Parameters 24 | 25 | - attrs: Map with the name of the person the repository belongs to. 26 | 27 | returns Int user.id 28 | """ 29 | def insert(attrs) do 30 | #  TODO: sanitise user string using github.com/dwyl/fields/issues/19 31 | cs = 32 | %__MODULE__{} 33 | |> changeset(attrs) 34 | 35 | Repo.insert!(cs, 36 | on_conflict: [set: [name: cs.changes.name]], 37 | conflict_target: [:name] 38 | ) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/hits/useragent.ex: -------------------------------------------------------------------------------- 1 | defmodule Hits.Useragent do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias Hits.Repo 5 | 6 | schema "useragents" do 7 | field(:ip, :string) 8 | field(:name, :string) 9 | 10 | many_to_many(:repositories, Hits.Repository, 11 | join_through: Hits.Hit, 12 | join_keys: [useragent_id: :id, repo_id: :id] 13 | ) 14 | 15 | timestamps() 16 | end 17 | 18 | @doc false 19 | def changeset(useragent, attrs \\ %{}) do 20 | useragent 21 | |> cast(attrs, [:name, :ip]) 22 | |> validate_required([:name, :ip]) 23 | end 24 | 25 | @doc """ 26 | insert_user_agent/1 inserts and returns the useragent for the request. 27 | 28 | ## Parameters 29 | 30 | - conn: Map the standard Plug.Conn info see: hexdocs.pm/plug/Plug.Conn.html 31 | 32 | returns Int useragent.id 33 | """ 34 | def insert(attrs) do 35 | cs = 36 | %__MODULE__{} 37 | |> changeset(attrs) 38 | 39 | Repo.insert!(cs, 40 | on_conflict: [set: [ip: cs.changes.ip, name: cs.changes.name]], 41 | conflict_target: [:ip, :name] 42 | ) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/hits_web.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb 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 HitsWeb, :controller 9 | use HitsWeb, :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: HitsWeb 23 | 24 | import Plug.Conn 25 | import HitsWeb.Gettext 26 | alias HitsWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/hits_web/templates", 34 | namespace: HitsWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1] 38 | 39 | # Use all HTML functionality (forms, tags, etc) 40 | use Phoenix.HTML 41 | 42 | import HitsWeb.ErrorHelpers 43 | import HitsWeb.Gettext 44 | alias HitsWeb.Router.Helpers, as: Routes 45 | end 46 | end 47 | 48 | def router do 49 | quote do 50 | use Phoenix.Router 51 | import Plug.Conn 52 | import Phoenix.Controller 53 | end 54 | end 55 | 56 | def channel do 57 | quote do 58 | use Phoenix.Channel 59 | import HitsWeb.Gettext 60 | end 61 | end 62 | 63 | @doc """ 64 | When used, dispatch to the appropriate controller/view/etc. 65 | IMPOSSIBLE to get coverage of this macro! 66 | see: https://elixirforum.com/t/why-how-when-to-use-the-using-macro/14001/3 67 | so simply skip it in coveralls.json!! 68 | """ 69 | defmacro __using__(which) when is_atom(which) do 70 | apply(__MODULE__, which, []) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/hits_web/channels/hit_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.HitChannel do 2 | use HitsWeb, :channel 3 | 4 | def join("hit:lobby", _payload, socket) do 5 | # if authorized?(payload) do 6 | {:ok, socket} 7 | # else 8 | # {:error, %{reason: "unauthorized"}} 9 | # end 10 | end 11 | 12 | # Channels can be used in a request/response fashion 13 | # by sending replies to requests from the client 14 | def handle_in("ping", payload, socket) do 15 | {:reply, {:ok, payload}, socket} 16 | end 17 | 18 | # It is also common to receive messages from the client and 19 | # broadcast to everyone in the current topic (hit:lobby). 20 | def handle_in("shout", payload, socket) do 21 | broadcast(socket, "shout", payload) 22 | {:noreply, socket} 23 | end 24 | 25 | # Commenting out this function as "true" is meaningless! 26 | # Add authorization logic here as required. 27 | # defp authorized?(_payload) do 28 | # true 29 | # end 30 | end 31 | -------------------------------------------------------------------------------- /lib/hits_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", HitsWeb.RoomChannel 6 | channel("hit:lobby", HitsWeb.HitChannel) 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | def connect(_params, socket, _connect_info) do 19 | {:ok, socket} 20 | end 21 | 22 | # Socket id's are topics that allow you to identify all sockets for a given user: 23 | # 24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 25 | # 26 | # Would allow you to broadcast a "disconnect" event and terminate 27 | # all active sockets and channels for a given user: 28 | # 29 | # HitsWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 30 | # 31 | # Returning `nil` makes this socket anonymous. 32 | def id(_socket), do: nil 33 | end 34 | -------------------------------------------------------------------------------- /lib/hits_web/controllers/hit_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.HitController do 2 | use HitsWeb, :controller 3 | # use Phoenix.Channel 4 | # import Ecto.Query 5 | alias Hits.{Hit, Repository, User, Useragent} 6 | 7 | use Params 8 | 9 | @doc """ 10 | Schema validator. 11 | The possible URL and query parameters are defined here and checked for validity. 12 | The possible values are fetched from https://shields.io/endpoint 13 | """ 14 | defparams schema_validator %{ 15 | user!: :string, 16 | repository!: :string, 17 | style: [ 18 | field: Ecto.Enum, 19 | values: [ 20 | plastic: "plastic", 21 | flat: "flat", 22 | flatSquare: "flat-square", 23 | forTheBadge: "for-the-badge", 24 | social: "social" 25 | ], 26 | default: :flat 27 | ], 28 | color: [field: :string, default: "lightgrey"], 29 | show: [field: :string, default: nil], 30 | } 31 | 32 | def index(conn, %{"user" => user, "repository" => repository} = params) do 33 | repo = String.replace_suffix(repository, ".svg", "") 34 | |> String.replace_suffix(".json", "") 35 | |> String.replace_suffix(".html", "") 36 | # Schema validation 37 | # Check https://github.com/vic/params#usage 38 | schema = schema_validator(params) 39 | params = Params.data(schema) 40 | params_map = Params.to_map(schema) 41 | 42 | if schema.valid? and user_valid?(user) and repository_valid?(repo) do 43 | # insert hit. Note: the .svg is for legacy reasons 🙄 44 | {_user_schema, _useragent_schema, repo} = insert_hit(conn, user, "#{repo}.svg") 45 | 46 | count = 47 | if params.show == "unique" do 48 | Hit.count_unique_hits(repo.id) 49 | else 50 | Hit.count_hits(repo.id) 51 | end 52 | 53 | # Send hit to connected clients via channel github.com/dwyl/hits/issues/79 54 | HitsWeb.Endpoint.broadcast("hit:lobby", "hit", %{ 55 | "user" => user, 56 | "repo" => repository, 57 | "count" => count 58 | }) 59 | 60 | # Render json object, html page or svg badge 61 | cond do 62 | Content.get_accept_header(conn) =~ "json" or String.ends_with?(repository, ".json") -> 63 | render_json(conn, count, params) 64 | String.ends_with?(repository, ".svg") -> 65 | render_badge(conn, count, params.style) 66 | true -> 67 | render(conn, "index.html", params_map) 68 | end 69 | else 70 | cond do 71 | Content.get_accept_header(conn) =~ "json" or String.ends_with?(repository, ".json") -> 72 | render_invalid_json(conn) 73 | String.ends_with?(repository, ".svg") -> 74 | render_invalid_badge(conn) 75 | true -> 76 | redirect(conn, to: "/error/#{user}/#{repository}") 77 | end 78 | end 79 | end 80 | 81 | @doc """ 82 | insert_hit/3 inserts user, useragent, repository and the 83 | hit entry which link the useragent to the repository 84 | 85 | ## Parameters 86 | 87 | - conn: Map the standard Plug.Conn info see: hexdocs.pm/plug/Plug.Conn.html 88 | - username: The Github user 89 | - repository: The Github repository 90 | - filter_count: define filter for count result 91 | 92 | Returns tuple {user, useragent, repository}. 93 | """ 94 | def insert_hit(conn, username, repository) do 95 | useragent = Hits.get_user_agent_string(conn) 96 | 97 | # remote_ip comes in as a Tuple {192, 168, 1, 42} >> 192.168.1.42 (dot quad) 98 | ip = Hits.get_user_ip_address(conn) 99 | # TODO: perform IP Geolocation lookup here so we can insert lat/lon for map! 100 | 101 | # insert the useragent: 102 | useragent = Useragent.insert(%{"name" => useragent, "ip" => ip}) 103 | 104 | # insert the user: 105 | user = User.insert(%{"name" => username}) 106 | 107 | # strip ".svg" from repo name and insert: 108 | repository_name = repository |> String.split(".svg") |> List.first() 109 | 110 | repository = 111 | Ecto.build_assoc(user, :repositories) 112 | |> Ecto.Changeset.change() 113 | # link useragent to repository to create hit entry 114 | |> Ecto.Changeset.put_assoc(:useragents, [useragent]) 115 | |> Repository.insert(%{"name" => repository_name}) 116 | 117 | {user, useragent, repository} 118 | end 119 | 120 | 121 | @doc """ 122 | render_json/1 outputs an encoded json related to a badge. 123 | 124 | ## Parameters 125 | 126 | - conn: Map the standard Plug.Conn info see: hexdocs.pm/plug/Plug.Conn.html 127 | 128 | Returns an encoded json that can be used with `shields.io` URL. 129 | See https://shields.io/endpoint 130 | """ 131 | def render_json(conn, count, params) do 132 | json_response = %{ 133 | "schemaVersion" => 1, 134 | "label" => "hits", 135 | "style" => params.style, 136 | "message" => "#{count}", 137 | "color" => params.color 138 | } 139 | json(conn, json_response) 140 | end 141 | 142 | @doc """ 143 | render_invalid_json/1 outputs an encoded json related to an invalid badge. 144 | 145 | ## Parameters 146 | 147 | - conn: Map the standard Plug.Conn info see: hexdocs.pm/plug/Plug.Conn.html 148 | 149 | Returns an encoded json that can be used with `shields.io` URL. 150 | See https://shields.io/endpoint 151 | """ 152 | def render_invalid_json(conn) do 153 | json_response = %{ 154 | "schemaVersion" => 1, 155 | "label" => "hits", 156 | "message" => "invalid url", 157 | } 158 | json(conn, json_response) 159 | end 160 | 161 | @doc """ 162 | render_badge/2 renders the badge for the url requested in conn 163 | 164 | ## Parameters 165 | 166 | - conn: Map the standard Plug.Conn info see: hexdocs.pm/plug/Plug.Conn.html 167 | - count: Number the view/hit count to be displayed in the badge. 168 | 169 | Returns Http response to end-user's browser with the svg (XML) of the badge. 170 | """ 171 | def render_badge(conn, count, style) do 172 | conn 173 | |> put_resp_content_type("image/svg+xml") 174 | |> send_resp(200, Hits.make_badge(count, style)) 175 | end 176 | 177 | def render_invalid_badge(conn) do 178 | conn 179 | |> put_resp_content_type("image/svg+xml") 180 | |> send_resp(404, Hits.svg_invalid_badge()) 181 | end 182 | 183 | @doc """ 184 | edgecase/2 handles the case where people did not follow the instructions 185 | for creating their badge ... 🙄 see: https://github.com/dwyl/hits/issues/67 186 | 187 | ## Parameters 188 | 189 | - conn: Map the standard Plug.Conn info see: hexdocs.pm/plug/Plug.Conn.html 190 | - params: the url path params %{"etc", "user", "repository"} 191 | 192 | Invokes the index function if ".svg" is present else returns "bad badge" 193 | """ 194 | def edgecase(conn, %{"repository" => repository} = params) do 195 | # note: we ignore the "etc" portion of the url which is usually 196 | # just the person's username ... see: github.com/dwyl/hits/issues/67 197 | # we cannot help you so you get a 404! 198 | if repository =~ ".svg" do 199 | index(conn, params) 200 | else 201 | conn 202 | |> put_resp_content_type("image/svg+xml") 203 | |> send_resp(404, Hits.make_badge(404, params["style"])) 204 | end 205 | end 206 | 207 | # see: https://github.com/dwyl/hits/issues/154 208 | # alphanumeric follow by one or zero "-" or just alphanumerics 209 | defp user_valid?(user), do: String.match?(user, ~r/^([[:alnum:]]+-)*[[:alnum:]]+$/) 210 | 211 | # ^[[:alnum:]-_.]+$ means the name is composed of 212 | # one or multiple alphanumeric character 213 | # or "-_." characters 214 | defp repository_valid?(repo), do: String.match?(repo, ~r/^[[:alnum:]-_.]+$/) 215 | end 216 | -------------------------------------------------------------------------------- /lib/hits_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.PageController do 2 | use HitsWeb, :controller 3 | 4 | def index(conn, _params) do 5 | render(conn, "index.html") 6 | end 7 | 8 | def error(conn, %{"user" => user, "repository" => repository}) do 9 | render(conn, "error.html", user: user, repository: repository) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/hits_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :hits 3 | 4 | socket("/socket", HitsWeb.UserSocket, 5 | websocket: true, 6 | longpoll: false 7 | ) 8 | 9 | # Serve at "/" the static files from "priv/static" directory. 10 | # 11 | # You should set gzip to true if you are running phx.digest 12 | # when deploying your static files in production. 13 | plug(Plug.Static, 14 | at: "/", 15 | from: :hits, 16 | gzip: false, 17 | only: ~w(assets fonts images favicon.ico robots.txt) 18 | ) 19 | 20 | # Code reloading can be explicitly enabled under the 21 | # :code_reloader configuration of your endpoint. 22 | if code_reloading? do 23 | socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) 24 | plug(Phoenix.LiveReloader) 25 | plug(Phoenix.CodeReloader) 26 | end 27 | 28 | plug(Plug.RequestId) 29 | plug(Plug.Logger) 30 | 31 | plug(Plug.Parsers, 32 | parsers: [:urlencoded, :multipart, :json], 33 | pass: ["*/*"], 34 | json_decoder: Phoenix.json_library() 35 | ) 36 | 37 | plug(Plug.MethodOverride) 38 | plug(Plug.Head) 39 | 40 | # The session will be stored in the cookie and signed, 41 | # this means its contents can be read but not tampered with. 42 | # Set :encryption_salt if you would also like to encrypt it. 43 | plug(Plug.Session, 44 | store: :cookie, 45 | key: "_hits_key", 46 | signing_salt: "hGmk8ZNx" 47 | ) 48 | 49 | plug(HitsWeb.Router) 50 | end 51 | -------------------------------------------------------------------------------- /lib/hits_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.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 HitsWeb.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.Backend, otp_app: :hits 24 | end 25 | -------------------------------------------------------------------------------- /lib/hits_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.Router do 2 | use HitsWeb, :router 3 | 4 | pipeline :any do 5 | plug :accepts, ["html", "json"] 6 | plug Content, %{html_plugs: [ 7 | &fetch_session/2, 8 | &fetch_flash/2, 9 | &protect_from_forgery/2, 10 | &put_secure_browser_headers/2 11 | ]} 12 | end 13 | 14 | # temporarily comment out API endpoint till we need it! 15 | # pipeline :api do 16 | # plug :accepts, ["json"] 17 | # end 18 | 19 | scope "/", HitsWeb do 20 | pipe_through(:any) 21 | 22 | get("/", PageController, :index) 23 | get("/error/:user/:repository", PageController, :error) 24 | 25 | get("/:user/:repository", HitController, :index) 26 | get("/:etc/:user/:repository", HitController, :edgecase) 27 | end 28 | 29 | # Other scopes may use custom stacks. 30 | # scope "/api", HitsWeb do 31 | # pipe_through :api 32 | # end 33 | end 34 | -------------------------------------------------------------------------------- /lib/hits_web/templates/hit/badge_flat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 17 | 20 | hits 21 | 24 | {count} 25 | 26 | -------------------------------------------------------------------------------- /lib/hits_web/templates/hit/badge_flat_square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | hits 8 | {count} 9 | 10 | 11 | -------------------------------------------------------------------------------- /lib/hits_web/templates/hit/index.html.eex: -------------------------------------------------------------------------------- 1 |

2 | Graphical dashboard coming soon! 3 |

4 | -------------------------------------------------------------------------------- /lib/hits_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hits 8 | 9 | <%= render("icons.html") %> 10 | 11 | 12 |
13 | <%= @inner_content %> 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /lib/hits_web/templates/layout/icons.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 61 | 67 | 73 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /lib/hits_web/templates/page/error.html.eex: -------------------------------------------------------------------------------- 1 |

2 | Validation Error! 3 |

4 | 5 |
6 |

Please make sure the username and the repository values are valid:

7 | 8 | 12 | 13 | <%= link "Create a new badge", to: "/", class: "f6 link dim br2 ph3 pv2 mb2 dib white bg-teal" %> 14 |
15 | 16 | 27 | -------------------------------------------------------------------------------- /lib/hits_web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 |

2 | Hits! 3 | Hit Count 4 | 5 |

6 |

7 | The easy way to know how many people are 8 | viewing your GitHub projects! 9 |

10 | 11 |

How?

12 |
13 | 14 | 15 | 16 | 20 | 24 | 25 | 26 | 30 | 34 | 35 | 36 | 39 | 45 | 46 |
17 | Type your GitHub Username 18 | ( or org name): 19 | 21 | 23 |
27 | Input the GitHub Project/Repository 28 | name: 29 | 31 | 33 |
37 | Choose a style for your badge: 38 | 40 | 44 |
47 |
48 | 49 |

Your Badge Markdown:

50 |
 51 |   [![HitCount](https://hits.dwyl.com/{username}/{repo}.svg?style={style})](http://hits.dwyl.com/{username}/{repo})
 52 | 
53 | 54 |

55 | Using the above markdown as a template,
56 | Replace the {username} with your GitHub username
57 | Replace the {repo} with the repo name. 58 |

59 | 60 |

61 | Copy the markdown snippet and Paste it into your 62 | README.md file
63 | to start tracking the view count on your GitHub project! 64 |

65 | 66 |

67 | If you want to display the unique count, use the following markdown: 68 |

69 | 70 |
 71 |   [![HitCount](https://hits.dwyl.com/{username}/{repo}.svg?style={style}&show=unique)](http://hits.dwyl.com/{username}/{repo})
 72 | 
73 | 74 |

JSON Endpoint

75 |

76 | shields.io 77 | provides a way for you to have other badge designs via 78 | shields.io/endpoint. 79 |

80 | 81 |

82 | You can fully customise your badge by copying the following JSON URL: 83 |

84 | 85 |
 86 |    https://hits.dwyl.com/{username}/{repo}.json
 87 | 
88 | 89 |

And pasting it into the url field at: 90 | shields.io/endpoint to get your custom badge. 91 | 92 |

93 | For example: 94 | 95 |

 96 |   https://img.shields.io/endpoint?url=https%3A%2F%2Fhits.dwyl.com%2F{username}%2F{repo}.json%3Fcolor%3Dpink
 97 |   
98 | 99 | Will give you: 100 | 101 | 103 |

104 | 105 | 106 | 107 |

Recently Viewed Projects (tracked by Hits)

108 |
109 |
Updates will appear here when WebSockets are working ...
110 |
111 | 112 | 126 | -------------------------------------------------------------------------------- /lib/hits_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | #  not using Phoenix HTML forms (yet) so commenting this out for now ... 7 | # use Phoenix.HTML 8 | # 9 | # @doc """ 10 | # Generates tag for inlined form input errors. 11 | # """ 12 | # def error_tag(form, field) do 13 | # Enum.map(Keyword.get_values(form.errors, field), fn error -> 14 | # content_tag(:span, translate_error(error), class: "help-block") 15 | # end) 16 | # end 17 | # 18 | # @doc """ 19 | # Translates an error message using gettext. 20 | # """ 21 | # def translate_error({msg, opts}) do 22 | # # When using gettext, we typically pass the strings we want 23 | # # to translate as a static argument: 24 | # # 25 | # # # Translate "is invalid" in the "errors" domain 26 | # # dgettext("errors", "is invalid") 27 | # # 28 | # # # Translate the number of files with plural rules 29 | # # dngettext("errors", "1 file", "%{count} files", count) 30 | # # 31 | # # Because the error messages we show in our forms and APIs 32 | # # are defined inside Ecto, we need to translate them dynamically. 33 | # # This requires us to call the Gettext module passing our gettext 34 | # # backend as first argument. 35 | # # 36 | # # Note we use the "errors" domain, which means translations 37 | # # should be written to the errors.po file. The :count option is 38 | # # set by Ecto and indicates we should also apply plural rules. 39 | # if count = opts[:count] do 40 | # Gettext.dngettext(HitsWeb.Gettext, "errors", msg, msg, count, opts) 41 | # else 42 | # Gettext.dgettext(HitsWeb.Gettext, "errors", msg, opts) 43 | # end 44 | # end 45 | end 46 | -------------------------------------------------------------------------------- /lib/hits_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.ErrorView do 2 | use HitsWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/hits_web/views/hit_view.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.HitView do 2 | use HitsWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/hits_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.LayoutView do 2 | use HitsWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/hits_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.PageView do 2 | use HitsWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /migrate-data.md: -------------------------------------------------------------------------------- 1 | # Migrate Hit Data from Filesystem to PostgreSQL 2 | 3 | The Node.js MVP saves data to the filesystem 4 | because that was the _simplest_ way 5 | of storing data without having to manage a database. 6 | See: [hits-nodejs/lib/db_filesystem.js#L26-L68](https://github.com/dwyl/hits-nodejs/blob/d896b0c1aae5f99054be67726c6186b4ff662cd3/lib/db_filesystem.js#L26-L68) 7 | https://github.com/dwyl/hits/issues/81 8 | 9 | + [x] Log-in to the Linode instance and inspect how many directories have been created 10 | (_this is the number of people - GitHub usernames - using the hits badge_) 11 | 12 | ```sh 13 | ssh root@178.79.141.232 14 | ``` 15 | 16 | + [x] Count the number of log files in the `/logs` directory: 17 | ```sh 18 | ls | wc -l 19 | 787 20 | ``` 21 | 22 | + [x] zip the data on Linode instance to get it off the instance - 23 | because I will be running the Elixir script on my `localhost`. 24 | https://unix.stackexchange.com/questions/93139/zip-an-entire-folder-using-gzip 25 | 26 | ```sh 27 | cd hits 28 | tar -zcvf logs.tar.gz logs/ 29 | ``` 30 | 31 | + [x] download the data archive to `localhost` 32 | 33 | https://stackoverflow.com/questions/9427553/how-to-download-a-file-from-server-using-ssh 34 | 35 | ```sh 36 | scp root@178.79.141.232:hits/logs.tar.gz ./logs.tar.gz 37 | ``` 38 | 39 | + [x] unzip data on `localhost` 40 | 41 | ```sh 42 | tar -zxvf logs.tar.gz 43 | ``` 44 | 45 | > **Note**: the zip function did not do what I needed, 46 | so I decided to attempt a recursive remote copy using `scp`: 47 | ```sh 48 | scp -r root@185.3.95.195:/root/hits/logs /Users/n/code/hits 49 | ``` 50 | see: 51 | https://stackoverflow.com/questions/11304895/copy-remote-folder-to-local-using-scp 52 | > But that failed half way through because my Mac went to sleep ... 53 | > So I decided to try [`rsync`](https://en.wikipedia.org/wiki/Rsync) instead: 54 | ``` 55 | rsync -vh root@185.3.95.195:/root/hits/logs/* /Users/n/code/hits 56 | // -vh means verbose & human-readable respectively 57 | ``` 58 | > That works fine because it's _only_ 489mb 59 | > ![logs-rsync](https://user-images.githubusercontent.com/194400/59149286-91637b00-8a0b-11e9-8401-3d2d0a6d6944.png) 60 | But attempting to get the agents is another story: 61 | ``` 62 | rsync -vh root@185.3.95.195:/root/hits/logs/agents/* /Users/n/code/hits/logs/agents 63 | ``` 64 | error: 65 | ``` 66 | bash: /usr/bin/rsync: Argument list too long 67 | ``` 68 | > https://www.tecmint.com/rsync-local-remote-f ile-synchronization-commands/ 69 | 70 | 71 | + [x] write the script 72 | + [ ] insert data into `hits_dev` PostgreSQL on `localhost` 73 | 74 | Path.wildcard("logs/*.log") 75 | 76 | + [ ] run the script 77 | 78 | ```sh 79 | MIX_ENV=dev mix run priv/migrate-data.exs 80 | ``` 81 | + [ ] export the data from `hits_dev` PostgreSQL on `localhost` 82 | 83 | + [ ] load the data on the remote server 84 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Hits.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :hits, 7 | version: "1.7.1", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps(), 14 | test_coverage: [tool: ExCoveralls], 15 | preferred_cli_env: [ 16 | c: :test, 17 | coveralls: :test, 18 | "coveralls.detail": :test, 19 | "coveralls.post": :test, 20 | "coveralls.html": :test 21 | ], 22 | package: package(), 23 | description: "Track page views on any GitHub page" 24 | ] 25 | end 26 | 27 | # Configuration for the OTP application. 28 | # 29 | # Type `mix help compile.app` for more information. 30 | def application do 31 | [ 32 | mod: {Hits.Application, []}, 33 | extra_applications: [:logger, :runtime_tools] 34 | ] 35 | end 36 | 37 | # Specifies which paths to compile per environment. 38 | defp elixirc_paths(:test), do: ["lib", "test/support"] 39 | defp elixirc_paths(_), do: ["lib"] 40 | 41 | # Specifies your project dependencies. 42 | # 43 | # Type `mix help deps` for examples and options. 44 | defp deps do 45 | [ 46 | {:phoenix, "~> 1.7.14"}, 47 | {:phoenix_ecto, "~> 4.4"}, 48 | {:ecto_sql, "~> 3.12.0"}, 49 | {:postgrex, ">= 0.15.13"}, 50 | {:phoenix_html, "~> 3.0"}, 51 | {:phoenix_live_view, "~> 0.17.5"}, 52 | # Required for Phoenix 1.7 53 | {:phoenix_view, "~> 2.0"}, 54 | {:phoenix_live_dashboard, "~> 0.6"}, 55 | {:telemetry_metrics, "~> 0.6"}, 56 | {:telemetry_poller, "~> 1.0"}, 57 | {:phoenix_live_reload, "~> 1.6.0", only: :dev}, 58 | {:gettext, "~> 0.26.1"}, 59 | {:jason, "~> 1.4.0"}, 60 | {:plug_cowboy, "~> 2.7.0"}, 61 | {:plug_crypto, "~> 2.1.0"}, 62 | 63 | # For content negotiation 64 | {:content, "~> 1.3.0"}, 65 | 66 | # Query param schema validation 67 | {:params, "~> 2.0"}, 68 | 69 | # The rest of the dependendencies are for testing/reporting 70 | # decode .json fixture in test 71 | {:poison, "~> 6.0.0"}, 72 | 73 | # track test coverage: hex.pm/packages/excoveralls 74 | {:excoveralls, "~> 0.18.3", only: [:test, :dev]}, 75 | # generate docs: hex.pm/packages/ex_doc 76 | {:ex_doc, "~> 0.38.1", only: [:dev, :docs]}, 77 | 78 | {:inch_ex, "~> 2.1.0-rc.1", only: :docs}, 79 | {:esbuild, "~> 0.9.0", runtime: Mix.env() == :dev} 80 | ] 81 | end 82 | 83 | # Aliases are shortcuts or tasks specific to the current project. 84 | # For example, to create, migrate and run the seeds file at once: 85 | # 86 | # $ mix ecto.setup 87 | # 88 | # See the documentation for `Mix` for more info on aliases. 89 | defp aliases do 90 | [ 91 | "assets.deploy": ["esbuild default --minify", "phx.digest"], 92 | cover: ["coveralls.json"], 93 | "cover.html": ["coveralls.html"], 94 | c: ["coveralls.html"], 95 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 96 | "ecto.reset": ["ecto.drop", "ecto.setup"], 97 | s: ["phx.server"], 98 | t: ["test"], 99 | test: ["ecto.create --quiet", "ecto.migrate", "test"] 100 | ] 101 | end 102 | 103 | defp package() do 104 | [ 105 | files: ~w(lib/ LICENSE mix.exs README.md), 106 | name: "hits", 107 | licenses: ["GNU GPL v2.0"], 108 | maintainers: ["dwyl"], 109 | links: %{"GitHub" => "https://github.com/dwyl/hits"} 110 | ] 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 3 | "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, 4 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 5 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 6 | "content": {:hex, :content, "1.3.0", "3f0b6df3acabd0e1fd5ab627f49310f3bf84b94b189a47c6f88bae8498e2054d", [:mix], [], "hexpm", "474fb5e80cb58272dc6cb77a7deaeb4259221a0e857d96c4e04c24349ce234c7"}, 7 | "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, 8 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 9 | "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, 10 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 11 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 12 | "earmark": {:hex, :earmark, "1.4.10", "bddce5e8ea37712a5bfb01541be8ba57d3b171d3fa4f80a0be9bcf1db417bcaf", [:mix], [{:earmark_parser, ">= 1.4.10", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "12dbfa80810478e521d3ffb941ad9fbfcbbd7debe94e1341b4c4a1b2411c1c27"}, 13 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 14 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, 15 | "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, 16 | "esbuild": {:hex, :esbuild, "0.9.0", "f043eeaca4932ca8e16e5429aebd90f7766f31ac160a25cbd9befe84f2bc068f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b415027f71d5ab57ef2be844b2a10d0c1b5a492d431727f43937adce22ba45ae"}, 17 | "ex_doc": {:hex, :ex_doc, "0.38.1", "bae0a0bd5b5925b1caef4987e3470902d072d03347114ffe03a55dbe206dd4c2", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "754636236d191b895e1e4de2ebb504c057fe1995fdfdd92e9d75c4b05633008b"}, 18 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 19 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, 20 | "expo": {:hex, :expo, "1.0.1", "f9e2f984f5b8d195815d52d0ba264798c12c8d2f2606f76fa4c60e8ebe39474d", [:mix], [], "hexpm", "f250b33274e3e56513644858c116f255d35c767c2b8e96a512fe7839ef9306a1"}, 21 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 22 | "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, 23 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 24 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 25 | "inch_ex": {:hex, :inch_ex, "2.1.0-rc.1", "7642a8902c0d2ed5d9b5754b2fc88fedf630500d630fc03db7caca2e92dedb36", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4ceee988760f9382d1c1d0b93ea5875727f6071693e89a0a3c49c456ef1be75d"}, 26 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 27 | "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"}, 28 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 29 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 30 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 31 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 32 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 33 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 34 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 35 | "params": {:hex, :params, "2.3.0", "dfa4c0023ec393828e882ad0eae67a3317756eb2298609f2222e0ebd384e4e4a", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "38e018b4c4774197791088734b7871a713e40374792e9821af1af81c482e3cb1"}, 36 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 37 | "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, 38 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"}, 39 | "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, 40 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"}, 41 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, 42 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.14", "5ec615d4d61bf9d4755f158bd6c80372b715533fe6d6219e12d74fb5eedbeac1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "afeb6ba43ce329a6f7fc1c9acdfc6d3039995345f025febb7f409a92f6faebd3"}, 43 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 44 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 45 | "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, 46 | "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, 47 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"}, 48 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 49 | "poison": {:hex, :poison, "6.0.0", "9bbe86722355e36ffb62c51a552719534257ba53f3271dacd20fbbd6621a583a", [:mix], [{:decimal, "~> 2.1", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "bb9064632b94775a3964642d6a78281c07b7be1319e0016e1643790704e739a2"}, 50 | "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, 51 | "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, 52 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 53 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 54 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, 55 | "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, 56 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 57 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 58 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 59 | } 60 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should be %{count} character(s)" 54 | msgid_plural "should be %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have %{count} item(s)" 59 | msgid_plural "should have %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at least %{count} character(s)" 64 | msgid_plural "should be at least %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at most %{count} character(s)" 74 | msgid_plural "should be at most %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should have at most %{count} item(s)" 79 | msgid_plural "should have at most %{count} item(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | ## From Ecto.Changeset.validate_number/3 84 | msgid "must be less than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than %{number}" 88 | msgstr "" 89 | 90 | msgid "must be less than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be greater than or equal to %{number}" 94 | msgstr "" 95 | 96 | msgid "must be equal to %{number}" 97 | msgstr "" 98 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /priv/migrate-data.exs: -------------------------------------------------------------------------------- 1 | defmodule Hits.Import do 2 | @moduledoc """ 3 | This is my work-in-progress import script. 4 | It doesn't do anything yet, so please ignore it. 5 | """ 6 | 7 | def import_data do 8 | IO.puts "Hello World!" 9 | 10 | System.get_env("MIX_ENV") 11 | |> IO.inspect(label: "MIX_ENV") 12 | base = "logs/logs/" 13 | files = Path.wildcard(base <> "*.log") 14 | 15 | first = files |> Enum.at(0) |> IO.inspect(label: "first") 16 | 17 | 18 | # lines |> Enum.at(1) |> IO.inspect 19 | Enum.each(files, fn(file) -> 20 | f = String.replace(file, base) 21 | IO.inspect(file, label: "file") 22 | 23 | # {:ok, contents} = File.read(file) 24 | # lines = contents |> String.split("\n", trim: true) 25 | # Enum.each(lines, fn(line) -> IO.inspect(line, label: "line") end) 26 | end) 27 | 28 | files 29 | |> Enum.count 30 | |> IO.inspect(label: "count") 31 | end 32 | end 33 | 34 | Hits.Import.import_data() 35 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190515211749_create_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Hits.Repo.Migrations.CreateUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :name, :string 7 | 8 | timestamps() 9 | end 10 | 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190515211755_create_repositories.exs: -------------------------------------------------------------------------------- 1 | defmodule Hits.Repo.Migrations.CreateRepositories do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:repositories) do 6 | add :name, :string 7 | add :user_id, references(:users, on_delete: :nothing) 8 | 9 | timestamps() 10 | end 11 | 12 | create index(:repositories, [:user_id]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190515211804_create_useragents.exs: -------------------------------------------------------------------------------- 1 | defmodule Hits.Repo.Migrations.CreateUseragents do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:useragents) do 6 | add :name, :string 7 | add :ip, :string 8 | 9 | timestamps() 10 | end 11 | 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190515211819_create_hits.exs: -------------------------------------------------------------------------------- 1 | defmodule Hits.Repo.Migrations.CreateHits do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:hits) do 6 | add :repo_id, references(:repositories, on_delete: :nothing) 7 | add :useragent_id, references(:useragents, on_delete: :nothing) 8 | 9 | timestamps() 10 | end 11 | 12 | create index(:hits, [:repo_id]) 13 | create index(:hits, [:useragent_id]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220203211516_create_unique_ip_name.exs: -------------------------------------------------------------------------------- 1 | defmodule Hits.Repo.Migrations.CreateUniqueIpName do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create unique_index(:useragents, [:name, :ip]) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220203212822_create_unique_user_name.exs: -------------------------------------------------------------------------------- 1 | defmodule Hits.Repo.Migrations.CreateUniqueUserName do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create unique_index(:users, [:name]) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220204205611_create_unique_index_name_user_id_repository.exs: -------------------------------------------------------------------------------- 1 | defmodule Hits.Repo.Migrations.CreateUniqueIndexNameUserIdRepository do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create unique_index(:repositories, [:name, :user_id]) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /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 | # Hits.Repo.insert!(%Hits.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/env.bat.eex: -------------------------------------------------------------------------------- 1 | @echo off 2 | rem Set the release to work across nodes. If using the long name format like 3 | rem the one below (my_app@127.0.0.1), you need to also uncomment the 4 | rem RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none". 5 | rem set RELEASE_DISTRIBUTION=name 6 | rem set RELEASE_NODE=<%= @release.name %>@127.0.0.1 7 | -------------------------------------------------------------------------------- /rel/env.sh.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # see: https://fly.io/docs/getting-started/elixir/#generate-release-config-files 4 | 5 | ip=$(grep fly-local-6pn /etc/hosts | cut -f 1) 6 | export RELEASE_DISTRIBUTION=name 7 | export RELEASE_NODE=$FLY_APP_NAME@$ip 8 | export ELIXIR_ERL_OPTIONS="-proto_dist inet6_tcp" 9 | 10 | 11 | # Sets and enables heart (recommended only in daemon mode) 12 | # case $RELEASE_COMMAND in 13 | # daemon*) 14 | # HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND" 15 | # export HEART_COMMAND 16 | # export ELIXIR_ERL_OPTIONS="-heart" 17 | # ;; 18 | # *) 19 | # ;; 20 | # esac 21 | 22 | # Set the release to work across nodes. If using the long name format like 23 | # the one below (my_app@127.0.0.1), you need to also uncomment the 24 | # RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none". 25 | # export RELEASE_DISTRIBUTION=name 26 | # export RELEASE_NODE=<%= @release.name %>@127.0.0.1 27 | -------------------------------------------------------------------------------- /rel/remote.vm.args.eex: -------------------------------------------------------------------------------- 1 | ## Customize flags given to the VM: https://erlang.org/doc/man/erl.html 2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here 3 | 4 | ## Number of dirty schedulers doing IO work (file, sockets, and others) 5 | ##+SDio 5 6 | 7 | ## Increase number of concurrent ports/sockets 8 | ##+Q 65536 9 | 10 | ## Tweak GC to run more often 11 | ##-env ERL_FULLSWEEP_AFTER 10 12 | -------------------------------------------------------------------------------- /rel/vm.args.eex: -------------------------------------------------------------------------------- 1 | ## Customize flags given to the VM: https://erlang.org/doc/man/erl.html 2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here 3 | 4 | ## Number of dirty schedulers doing IO work (file, sockets, and others) 5 | ##+SDio 5 6 | 7 | ## Increase number of concurrent ports/sockets 8 | ##+Q 65536 9 | 10 | ## Tweak GC to run more often 11 | ##-env ERL_FULLSWEEP_AFTER 10 12 | -------------------------------------------------------------------------------- /test/coverage_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CoverageTest do 2 | use ExUnit.Case 3 | # use HitsWeb.ConnCase 4 | 5 | # Annoyingly, Phoenix creates a bunch of functions that are not executed 6 | # during the tests for the app ... this file manually executes them. 7 | 8 | test "invoke config_change (dummy) function" do 9 | assert Hits.Application.config_change("hello", "world", "!") == :ok 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/hits_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HitsTest do 2 | use ExUnit.Case 3 | 4 | test "make_badge with default count 1" do 5 | badge = Hits.make_badge() 6 | assert badge =~ ~s(flat-square) 7 | end 8 | 9 | test "make_badge with default count 1 and flat style" do 10 | badge = Hits.make_badge(1, "flat") 11 | assert badge =~ ~s(flat) 12 | refute badge =~ ~s(flat-square) 13 | end 14 | 15 | test "make_badge with default count 1 and invalid style" do 16 | badge = Hits.make_badge(1, "invalid_style") 17 | assert badge =~ ~s(flat-square) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/hits_web/channels/hit_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.HitChannelTest do 2 | use HitsWeb.ChannelCase 3 | 4 | setup do 5 | {:ok, _, socket} = 6 | socket(HitsWeb.UserSocket, "user_id", %{some: :assign}) 7 | |> subscribe_and_join(HitsWeb.HitChannel, "hit:lobby") 8 | 9 | {:ok, socket: socket} 10 | end 11 | 12 | test "ping replies with status ok", %{socket: socket} do 13 | ref = push(socket, "ping", %{"hello" => "there"}) 14 | assert_reply(ref, :ok, %{"hello" => "there"}) 15 | end 16 | 17 | test "shout broadcasts to hit:lobby", %{socket: socket} do 18 | push(socket, "shout", %{"hello" => "all"}) 19 | assert_broadcast("shout", %{"hello" => "all"}) 20 | end 21 | 22 | test "broadcasts are pushed to the client", %{socket: socket} do 23 | broadcast_from!(socket, "broadcast", %{"some" => "data"}) 24 | assert_push("broadcast", %{"some" => "data"}) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/hits_web/controllers/hit_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.HitControllerTest do 2 | use HitsWeb.ConnCase 3 | 4 | # import HitsWeb.HitController 5 | 6 | test "GET /totes/amaze.svg", %{conn: conn} do 7 | res = 8 | put_req_header(conn, "user-agent", "Hackintosh") 9 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 10 | |> get("/totes/amaze.svg") 11 | 12 | assert res.resp_body =~ Hits.make_badge(1) 13 | end 14 | 15 | test "GET /user1/repo1.svg", %{conn: conn} do 16 | res = 17 | put_req_header(conn, "user-agent", "Hackintosh") 18 | |> put_req_header("x-forwarded-for", "127.0.0.1, 127.0.0.2") 19 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 20 | |> get("/user1/repo1.svg") 21 | 22 | assert res.resp_body =~ Hits.make_badge(1) 23 | end 24 | 25 | test "GET /user1/repo1 with json return", %{conn: conn} do 26 | res = 27 | put_req_header(conn, "user-agent", "Hackintosh") 28 | |> put_req_header("x-forwarded-for", "127.0.0.1, 127.0.0.2") 29 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 30 | |> put_req_header("accept", "application/json") 31 | |> get("/user1/repo1") 32 | 33 | expected = %{ 34 | "schemaVersion" => 1, 35 | "label" => "hits", 36 | "style" => "flat", 37 | "message" => "1", 38 | "color" => "lightgrey" 39 | } 40 | 41 | assert Jason.decode!(res.resp_body) == expected 42 | end 43 | 44 | test "GET /user1/repo1 unique hits with json return", %{conn: conn} do 45 | res = 46 | put_req_header(conn, "user-agent", "Hackintosh") 47 | |> put_req_header("x-forwarded-for", "127.0.0.1, 127.0.0.2") 48 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 49 | |> put_req_header("accept", "application/json") 50 | |> get("/user1/repo1?show=unique") 51 | 52 | expected = %{ 53 | "schemaVersion" => 1, 54 | "label" => "hits", 55 | "style" => "flat", 56 | "message" => "1", 57 | "color" => "lightgrey" 58 | } 59 | 60 | assert Jason.decode!(res.resp_body) == expected 61 | end 62 | 63 | test "test counter increments! GET /totes/amaze.svg", %{conn: conn} do 64 | put_req_header(conn, "user-agent", "Hackintosh") 65 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 66 | |> get("/totes/amaze.svg") 67 | 68 | put_req_header(conn, "user-agent", "Hackintosh1") 69 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 70 | |> get("/totes/amaze.svg") 71 | 72 | res3 = 73 | put_req_header(conn, "user-agent", "Hackintosh2") 74 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 75 | |> get("/totes/amaze.svg") 76 | 77 | assert res3.resp_body =~ Hits.make_badge(3) 78 | end 79 | 80 | test "test counter unique user agent GET /totes/amaze.svg?show=unique", %{conn: conn} do 81 | put_req_header(conn, "user-agent", "Hackintosh") 82 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 83 | |> get("/user/repo.svg?show=unique") 84 | 85 | res = 86 | put_req_header(conn, "user-agent", "Hackintosh") 87 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 88 | |> get("/user/repo.svg?show=unique") 89 | 90 | assert res.resp_body =~ Hits.make_badge(1) 91 | end 92 | 93 | test "GET /org/dashboard", %{conn: conn} do 94 | res = 95 | put_req_header(conn, "user-agent", "Hackintosh") 96 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 97 | |> get("/org/dashboard") 98 | 99 | assert res.resp_body =~ ~s(Graphical dashboard coming soon!) 100 | end 101 | 102 | test "make_badge with default count 1 and style flat-square" do 103 | badge = Hits.make_badge() 104 | assert badge =~ ~s(flat-square) 105 | end 106 | 107 | # URL edgecase github.com/dwyl/hits/issues/67#issuecomment-488970053 108 | test "GET /hhyo/hhyo/Archery", %{conn: conn} do 109 | res = 110 | put_req_header(conn, "user-agent", "Hackintosh") 111 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 112 | |> get("/hhyo/hhyo/Archery.svg") 113 | 114 | assert res.resp_body =~ Hits.make_badge(1) 115 | end 116 | 117 | # edge case where the person forgets to request an .svg file! 118 | test "GET /test/user/repo-no-svg", %{conn: conn} do 119 | res = 120 | put_req_header(conn, "user-agent", "Hackintosh") 121 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 122 | |> get("/test/user/repo-no-svg") 123 | 124 | assert res.resp_body =~ ~s(404) 125 | end 126 | 127 | test "GET /-user/repo.svg invalid user", %{conn: conn} do 128 | res = 129 | put_req_header(conn, "user-agent", "Hackintosh") 130 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 131 | |> get("/-user/repo.svg") 132 | 133 | assert res.resp_body =~ Hits.svg_invalid_badge() 134 | end 135 | 136 | test "GET /-user/repo.svg invalid user with json response", %{conn: conn} do 137 | res = 138 | put_req_header(conn, "user-agent", "Hackintosh") 139 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 140 | |> put_req_header("accept", "application/json") 141 | |> get("/-user/repo") 142 | 143 | expected = %{ 144 | "schemaVersion" => 1, 145 | "label" => "hits", 146 | "message" => "invalid url", 147 | } 148 | 149 | assert Jason.decode!(res.resp_body) == expected 150 | end 151 | 152 | test "GET /-user/invalidrepo invalid user with json response", %{conn: conn} do 153 | res = 154 | put_req_header(conn, "user-agent", "Hackintosh") 155 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 156 | |> put_req_header("accept", "application/json") 157 | |> get("/-user/rep?style=invalid") 158 | 159 | expected = %{ 160 | "schemaVersion" => 1, 161 | "label" => "hits", 162 | "message" => "invalid url", 163 | } 164 | 165 | assert Jason.decode!(res.resp_body) == expected 166 | end 167 | 168 | test "GET /user/repo{}!!.svg invalid repository", %{conn: conn} do 169 | res = 170 | put_req_header(conn, "user-agent", "Hackintosh") 171 | |> put_req_header("accept-language", "en-GB,en;q=0.5") 172 | |> get("/user/repo{}!!.svg") 173 | 174 | assert res.resp_body =~ Hits.svg_invalid_badge() 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /test/hits_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.PageControllerTest do 2 | use HitsWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "Hits!" 7 | end 8 | 9 | test "GET /--user/repo$", %{conn: conn} do 10 | conn = get(conn, "/--user/repo$") 11 | assert html_response(conn, 302) 12 | end 13 | 14 | test "GET /error/--user/repo$", %{conn: conn} do 15 | conn = get(conn, "/error/--user/repo$") 16 | assert html_response(conn, 200) =~ "Validation Error!" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/hits_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.ErrorViewTest do 2 | use HitsWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(HitsWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(HitsWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/hits_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.LayoutViewTest do 2 | use HitsWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/hits_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.PageViewTest do 2 | use HitsWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/repository_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RepoTest do 2 | use ExUnit.Case 3 | alias Hits.{User, Repository} 4 | 5 | setup do 6 | # Explicitly get a connection before each test 7 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Hits.Repo) 8 | end 9 | 10 | test "Repository.insert" do 11 | user = User.insert(%{"name" => "alex"}) 12 | repo = Ecto.build_assoc(user, :repositories) 13 | attrs = %{"name" => "totes-amaze"} 14 | 15 | repository = Repository.insert(repo, attrs) 16 | assert repository.id > 0 17 | 18 | # attempting to insert the same repo will simply return the same id: 19 | repository2 = Repository.insert(repo, attrs) 20 | assert repository == repository2 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | import Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint HitsWeb.Endpoint 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Hits.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(Hits.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule HitsWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | import Plug.Conn 22 | import Phoenix.ConnTest 23 | alias HitsWeb.Router.Helpers, as: Routes 24 | 25 | # The default endpoint for testing 26 | @endpoint HitsWeb.Endpoint 27 | end 28 | end 29 | 30 | setup tags do 31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Hits.Repo) 32 | 33 | unless tags[:async] do 34 | Ecto.Adapters.SQL.Sandbox.mode(Hits.Repo, {:shared, self()}) 35 | end 36 | 37 | {:ok, conn: Phoenix.ConnTest.build_conn()} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Hits.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 Hits.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import Hits.DataCase 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Hits.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(Hits.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | @doc """ 39 | A helper that transforms changeset errors into a map of messages. 40 | 41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 42 | assert "password is too short" in errors_on(changeset).password 43 | assert %{password: ["password is too short"]} = errors_on(changeset) 44 | 45 | """ 46 | def errors_on(changeset) do 47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 48 | Enum.reduce(opts, message, fn {key, value}, acc -> 49 | String.replace(acc, "%{#{key}}", to_string(value)) 50 | end) 51 | end) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Hits.Repo, :manual) 3 | -------------------------------------------------------------------------------- /test/user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule UserTest do 2 | use ExUnit.Case 3 | alias Hits.User 4 | 5 | setup do 6 | # Explicitly get a connection before each test 7 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Hits.Repo) 8 | end 9 | 10 | test "User.insert" do 11 | attrs = %{"name" => "jimmy"} 12 | user_id = User.insert(attrs) 13 | assert user_id > 0 14 | 15 | # attempting to insert the same user will simply return the same id: 16 | user_id2 = User.insert(attrs) 17 | assert user_id == user_id2 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/useragent_test.exs: -------------------------------------------------------------------------------- 1 | defmodule UseragentTest do 2 | use ExUnit.Case 3 | alias Hits.Useragent 4 | 5 | setup do 6 | # Explicitly get a connection before each test 7 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Hits.Repo) 8 | end 9 | 10 | test "Useragent.insert" do 11 | useragent = "Mozilla/5.0 (Windows; Windows NT 5.0; rv:1.1) Gecko/20020826" 12 | ip = "127.0.0.1" 13 | attrs = %{"name" => useragent, "ip" => ip} 14 | useragent_id = Useragent.insert(attrs) 15 | assert useragent_id > 0 16 | 17 | # attempting to insert the same useragent will simply return the same id: 18 | agent_id_2 = Useragent.insert(attrs) 19 | assert useragent_id == agent_id_2 20 | end 21 | end 22 | --------------------------------------------------------------------------------