├── .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 | 
4 |
5 |
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 | 
166 |
167 |
168 | Or visit _any_ endpoint that includes `.svg` in the url,
169 | e.g: http://localhost:4000/yourname/project.svg
170 |
171 | 
172 |
173 | Refresh the page a few times and watch the count go up!
174 |
175 | 
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 | 
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 |
382 | " alt="Phoenix Framework Logo"/>
383 |
384 |
385 |
386 |
387 |
<%= get_flash(@conn, :info) %>
388 |
<%= get_flash(@conn, :error) %>
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 | 
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.
483 |
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 |
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
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 | 
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 | 
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 | 
1104 |
1105 | 
1106 | 
1107 | 
1108 |
1109 | Plenty of logos to chose from at:
1110 | https://simpleicons.org
1111 |
1112 |
1113 | # tl;dr
1114 |
1115 | 
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 |
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 |
69 | """
70 | end
71 |
72 | def svg_invalid_badge do
73 | """
74 |
75 |
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 |
--------------------------------------------------------------------------------
/lib/hits_web/templates/hit/badge_flat_square.svg:
--------------------------------------------------------------------------------
1 |
2 |
11 |
--------------------------------------------------------------------------------
/lib/hits_web/templates/hit/index.html.eex:
--------------------------------------------------------------------------------
1 |