├── .credo.exs
├── .env.example
├── .formatter.exs
├── .github
├── issue_template.md
└── workflows
│ └── ci.yml
├── .gitignore
├── .iex.exs
├── .projections.json
├── .tool-versions
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE.md
├── Makefile
├── Procfile
├── README.md
├── USAGE.md
├── app.json
├── assets
├── css
│ ├── app.css
│ ├── components.css
│ ├── icons.css
│ ├── layout.css
│ ├── normalize.css
│ ├── pages.css
│ ├── reset.css
│ ├── site_nav.css
│ └── variables.css
├── js
│ ├── app.js
│ ├── google_analytics.js
│ ├── image_uploader.js
│ ├── like.js
│ ├── post_form.js
│ ├── socket.js
│ ├── text_conversion.js
│ ├── tilex.js
│ └── twitter_button.js
├── package-lock.json
├── package.json
└── vendor
│ ├── css
│ └── prism.css
│ ├── js
│ └── prism.js
│ └── topbar.js
├── config
├── config.exs
├── dev.exs
├── prod.exs
├── runtime.exs
└── test.exs
├── coveralls.json
├── elixir_buildpack.config
├── lib
├── mix
│ └── tasks
│ │ ├── deploy.ex
│ │ ├── ecto.twiki.ex
│ │ └── tilex
│ │ ├── hdb.ex
│ │ ├── page_views.ex
│ │ └── streaks.ex
├── test
│ └── notifications
│ │ └── notifiers
│ │ ├── slack.ex
│ │ └── twitter.ex
├── tilex.ex
├── tilex
│ ├── application.ex
│ ├── auth
│ │ ├── error_handler.ex
│ │ └── guardian.ex
│ ├── blog
│ │ ├── channel.ex
│ │ ├── developer.ex
│ │ ├── post.ex
│ │ ├── post_scrubber.ex
│ │ └── request.ex
│ ├── cache.ex
│ ├── liking.ex
│ ├── mailer.ex
│ ├── markdown.ex
│ ├── notifications
│ │ ├── notifications.ex
│ │ ├── notifiers
│ │ │ ├── notifier.ex
│ │ │ ├── slack.ex
│ │ │ └── twitter.ex
│ │ └── notifiers_supervisor.ex
│ ├── page_views_report.ex
│ ├── pageable.ex
│ ├── plug
│ │ ├── format_injector.ex
│ │ ├── rate_limiter.ex
│ │ ├── request_rejector.ex
│ │ └── set_canonical_url.ex
│ ├── posts.ex
│ ├── query_helpers.ex
│ ├── rate_limiter.ex
│ ├── repo.ex
│ ├── stats.ex
│ └── tracking.ex
├── tilex_web.ex
└── tilex_web
│ ├── channels
│ ├── text_converter_channel.ex
│ └── user_socket.ex
│ ├── controllers
│ ├── api
│ │ ├── developer_post_controller.ex
│ │ └── post_controller.ex
│ ├── auth_controller.ex
│ ├── channel_controller.ex
│ ├── developer_controller.ex
│ ├── feed_controller.ex
│ ├── pixel_controller.ex
│ ├── post_controller.ex
│ ├── sitemap_controller.ex
│ ├── stats_controller.ex
│ ├── test
│ │ └── auth_controller.ex
│ └── web_manifest_controller.ex
│ ├── endpoint.ex
│ ├── gettext.ex
│ ├── router.ex
│ ├── telemetry.ex
│ ├── templates
│ ├── channel
│ │ └── show.html.eex
│ ├── developer
│ │ ├── edit.html.eex
│ │ └── show.html.eex
│ ├── error
│ │ ├── 404.html.eex
│ │ └── 500.html.eex
│ ├── feed
│ │ └── index.xml.eex
│ ├── layout
│ │ ├── app.html.heex
│ │ ├── error.html.heex
│ │ ├── live.html.heex
│ │ ├── root.html.heex
│ │ └── site_nav.html.eex
│ ├── post
│ │ ├── edit.html.eex
│ │ ├── form.html.eex
│ │ ├── index.html.eex
│ │ ├── new.html.eex
│ │ ├── search_results.html.eex
│ │ ├── show.html.eex
│ │ └── show.md.eex
│ ├── shared
│ │ ├── _elixir.html.eex
│ │ ├── _javascript.html.eex
│ │ ├── _pagination.html.eex
│ │ ├── _posts_list.html.eex
│ │ ├── _rails.html.eex
│ │ ├── _react.html.eex
│ │ ├── _ruby.html.eex
│ │ ├── _sql.html.eex
│ │ ├── _vim.html.eex
│ │ └── post.html.eex
│ ├── sitemap
│ │ └── sitemap.xml.eex
│ └── stats
│ │ ├── developer.html.eex
│ │ └── index.html.eex
│ └── views
│ ├── api
│ ├── developer_post_view.ex
│ └── post_view.ex
│ ├── channel_view.ex
│ ├── developer_view.ex
│ ├── error_helpers.ex
│ ├── error_view.ex
│ ├── feed_view.ex
│ ├── html_helpers.ex
│ ├── icon.ex
│ ├── layout_view.ex
│ ├── post_view.ex
│ ├── shared_view.ex
│ ├── sitemap_view.ex
│ ├── stats_view.ex
│ ├── structured_data_view.ex
│ └── web_manifest_view.ex
├── mix.exs
├── mix.lock
├── phoenix_static_buildpack.config
├── priv
├── gettext
│ ├── en
│ │ └── LC_MESSAGES
│ │ │ └── errors.po
│ └── errors.pot
├── repo
│ ├── migrations
│ │ ├── .formatter.exs
│ │ ├── 20161118221207_create_post.exs
│ │ ├── 20161228153637_create_channel.exs
│ │ ├── 20161228161102_posts_belong_to_channels.exs
│ │ ├── 20161229180247_add_index_to_channels.exs
│ │ ├── 20170106200911_add_slug_to_post.exs
│ │ ├── 20170120203217_add_likes_to_post.exs
│ │ ├── 20170317204314_add_max_likes_to_posts.exs
│ │ ├── 20170317205716_add_max_likes_constraints_to_posts.exs
│ │ ├── 20170317210210_add_published_at_to_posts.exs
│ │ ├── 20170317210552_add_tweeted_to_posts.exs
│ │ ├── 20170317210749_channel_id_not_null_on_posts.exs
│ │ ├── 20170317214042_create_developers_table.exs
│ │ ├── 20170319161606_add_developer_id_to_posts.exs
│ │ ├── 20170518173833_add_tweeted_at_to_posts.exs
│ │ ├── 20170518183105_add_twitter_handle_to_developers.exs
│ │ ├── 20170601120632_add_admin_to_developers.exs
│ │ ├── 20170602200233_add_editor_to_developers.exs
│ │ ├── 20170726192554_remove_default_from_twitter_handle_column.exs
│ │ ├── 20170823194255_update_text_editor_on_developers.exs
│ │ ├── 20171208181757_remove_google_id.exs
│ │ ├── 20180908171609_create_tracking.exs
│ │ ├── 20190827182708_add_index_to_requests.exs
│ │ ├── 20200518184142_add_index_to_request_time_on_requests.exs
│ │ ├── 20220425135720_add_request_time_in_chicago_tz_index_to_requests.exs
│ │ └── 20220429184256_add_request_time_in_application_timezone_index_to_requests.exs
│ ├── seeds.exs
│ └── structure.sql
└── static
│ ├── apple-touch-icon-120x120.png
│ ├── apple-touch-icon-precomposed.png
│ ├── apple-touch-icon.png
│ ├── favicon.ico
│ ├── favicon.png
│ ├── fonts
│ ├── Lora-Bold.woff2
│ └── Lora-Regular.woff2
│ ├── images
│ ├── command_line_twitter_card.png
│ ├── elixir_twitter_card.png
│ ├── elm_twitter_card.png
│ ├── git_twitter_card.png
│ ├── html_css_twitter_card.png
│ ├── icons.svg
│ ├── javascript_twitter_card.png
│ ├── python_twitter_card.png
│ ├── rails_twitter_card.png
│ ├── react_twitter_card.png
│ ├── reasonml_twitter_card.png
│ ├── ruby_twitter_card.png
│ ├── sql_twitter_card.png
│ ├── til-logo-144x144.png
│ ├── til-logo-512x512.png
│ ├── til_twitter_card.png
│ ├── vim_twitter_card.png
│ └── workflow_twitter_card.png
│ └── robots.txt
└── test
├── controllers
├── api
│ ├── developer_post_controller_test.exs
│ └── post_controller_test.exs
├── auth_controller_test.exs
├── post_controller_test.exs
├── stats_controller_test.exs
└── web_manifest_controller_test.exs
├── features
├── admin_edits_post_test.exs
├── developer_creates_post_test.exs
├── developer_edits_post_test.exs
├── developer_edits_profile_test.exs
├── developer_sees_navigation_bar_test.exs
├── developer_signs_out_test.exs
├── developer_views_stats_test.exs
├── robot_sees_sitemap.exs
├── visitor_searches_posts_test.exs
├── visitor_views_channel_test.exs
├── visitor_views_developer_test.exs
├── visitor_views_error_page_test.exs
├── visitor_views_post_test.exs
├── visitor_views_rss_feed_test.exs
├── visitor_views_stats_test.exs
└── visitor_visits_homepage_test.exs
├── lib
└── tilex
│ ├── auth
│ └── error_handler_test.exs
│ ├── markdown_test.exs
│ ├── notifications
│ └── notifications_test.exs
│ ├── plug
│ ├── request_rejector_test.exs
│ └── set_canonical_url_test.exs
│ ├── rate_limiter_test.exs
│ └── tracking_test.exs
├── support
├── conn_case.ex
├── data_case.ex
├── date_time_mock.ex
├── factory.ex
├── integration_case.ex
├── pages
│ ├── create_post_page.ex
│ ├── index_page.ex
│ ├── navigation.ex
│ ├── post_form.ex
│ └── post_show_page.ex
└── wallaby_test_helpers.ex
├── test_helper.exs
├── tilex
├── blog
│ ├── channel_test.exs
│ ├── developer_test.exs
│ └── post_test.exs
└── notifications
│ └── notifiers
│ └── slack_test.exs
└── views
├── error_view_test.exs
├── shared_view_test.exs
└── structured_data_view_test.exs
/.env.example:
--------------------------------------------------------------------------------
1 | export APPSIGNAL_APP_ENV=
2 | export APPSIGNAL_APP_NAME=
3 | export APPSIGNAL_PUSH_API_KEY=
4 | export BASIC_AUTH_PASSWORD=
5 | export BASIC_AUTH_USERNAME=
6 | export CANONICAL_DOMAIN=https://example.com
7 | export DATE_DISPLAY_TZ=America/Chicago
8 | export DEFAULT_TWITTER_HANDLE=
9 | export ENABLE_BASIC_AUTH=
10 | export GOOGLE_CLIENT_ID=
11 | export GOOGLE_CLIENT_SECRET=
12 | export GUEST_AUTHOR_ALLOWLIST=
13 | export HOSTED_DOMAIN=
14 | export IMGUR_CLIENT_ID=
15 | export ORGANIZATION_NAME=
16 | export slack_post_endpoint=
17 | export twitter_access_token=
18 | export twitter_access_token_secret=
19 | export twitter_consumer_key=
20 | export twitter_consumer_secret=
21 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto, :phoenix],
3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
4 | subdirectories: ["priv/*/migrations"]
5 | ]
6 |
--------------------------------------------------------------------------------
/.github/issue_template.md:
--------------------------------------------------------------------------------
1 | ### Steps to reproduce
2 |
3 | 1.
4 |
5 | ### Expected behavior
6 |
7 | Tell us what should happen
8 |
9 | ### Actual behavior
10 |
11 | Tell us what happens instead
12 |
13 | ### System configuration
14 |
15 | **Operating System**:
16 |
17 | **Browser**:
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | *.ez
3 | /_build/
4 | /.elixir_ls
5 | /.fetch
6 | /assets/node_modules/
7 | /cover/
8 | /deps/
9 | /doc/
10 | /priv/static/assets/
11 | /priv/static/cache_manifest.json
12 | /screenshots
13 | erl_crash.dump
14 | npm-debug.log
15 | tilex-*.tar
16 |
--------------------------------------------------------------------------------
/.iex.exs:
--------------------------------------------------------------------------------
1 | import_file_if_available("~/.iex.exs")
2 |
3 | alias Tilex.Blog.Channel
4 | alias Tilex.Blog.Developer
5 | alias Tilex.Liking
6 | alias Tilex.Blog.Post
7 | alias Tilex.Rep
8 |
9 | # Allow developer to reload IEx session with `R.reload!`.
10 | defmodule R do
11 | def reload! do
12 | Mix.Task.reenable("compile.elixir")
13 | Application.stop(Mix.Project.config()[:app])
14 | Mix.Task.run("compile.elixir")
15 | Application.start(Mix.Project.config()[:app], :permanent)
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | elixir 1.17.2-otp-27
2 | erlang 27.0.1
3 | nodejs 19.0.0
4 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # CONTRIBUTING
2 |
3 | Bug reports and pull requests are welcome on GitHub at
4 | https://github.com/hashrocket/tilex.
5 |
6 | ### Issues
7 |
8 | To open a new issue, visit our [Github Issues page][issues].
9 |
10 | ### Pull Requests
11 |
12 | To open a pull request, please follow these steps:
13 |
14 | 1. [Fork][fork] it
15 | 2. Create your feature branch (`git checkout -b my-new-feature`)
16 | 3. Make some changes with accompanying tests
17 | 4. Ensure the entire test suite passes (`mix test`)
18 | 5. Stage the relevant changes (`git add --patch`)
19 | 6. Commit your changes (`git commit -m 'Add some feature'`)
20 | 7. Push to the branch (`git push origin my-new-feature`)
21 | 8. Create a new Pull Request 🎉
22 |
23 | All pull requests are checked for style using the Elixir autoformatter and
24 | [Credo][credo]. Run both to confirm that your code will pass:
25 |
26 | ```
27 | $ mix format
28 | $ mix credo
29 | ```
30 |
31 | Adding a database migration? Ensure it can be rolled back with this command:
32 |
33 | ```
34 | $ mix ecto.twiki
35 | ```
36 |
37 | [credo]: https://github.com/rrrene/credo
38 | [fork]: https://help.github.com/articles/fork-a-repo/
39 | [issues]: https://github.com/hashrocket/tilex/issues
40 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Hashrocket
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | #!make
2 | include .env
3 |
4 | .PHONY: help console outdated setup server test update
5 |
6 | .env:
7 | cp .env.example .env
8 |
9 | help: ## Shows this help.
10 | @grep ": \#" ${MAKEFILE_LIST} | column -t -s ':' | sort
11 |
12 | console: ## Opens the App console.
13 | iex -S mix
14 |
15 | outdated: ## Shows outdated packages.
16 | mix hex.outdated
17 |
18 | setup: ## Setup the App.
19 | mix local.hex --force
20 | mix setup
21 | mix gettext.extract --merge --no-fuzzy
22 |
23 | server: ## Start the App server.
24 | npm install --prefix assets/
25 | mix phx.server
26 |
27 | test: ## Run the test suite.
28 | mix format
29 | mix credo
30 | echo "chromedriver => `chromedriver --version`"
31 | echo "chrome => `/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version`"
32 | rm -f screenshots/*
33 | mkdir -p screenshots/
34 | mix test --trace
35 |
36 | update: ## Update dependencies.
37 | mix deps.update --all
38 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: MIX_ENV=prod elixir --cookie $OTP_COOKIE --name server@127.0.0.1 --erl '-kernel inet_dist_listen_min 9000' --erl '-kernel inet_dist_listen_max 9000' -S mix phx.server
2 |
--------------------------------------------------------------------------------
/USAGE.md:
--------------------------------------------------------------------------------
1 | # Usage
2 |
3 | We love seeing forks of Today I Learned in production! Here's a basic guide for
4 | customizing your version of the site.
5 |
6 | ### Style
7 |
8 | Feel free to get creative! The layout, colors, fonts, assets, and meta tags
9 | we've chosen should only serve as a starting point.
10 |
11 | ### Attribution
12 |
13 | Please link to Hashrocket somewhere on the main page! Today I Learned is an
14 | open-source project, and support from the community helps inspire continued
15 | development.
16 |
17 | Here's an example of an easy way to link to us:
18 |
19 | ```elixir
20 | # lib/your_app/templates/layout/app.html.eex
21 |
22 | Today I Learned is an open-source project by Hashrocket .
23 | Check out the source code to make your own!
24 | ```
25 |
26 | ### Forks in Production
27 |
28 | Have you deployed TIL to production? Please open a pull request or contact
29 | dev@hashrocket.com to be recognized. Here are a few examples:
30 |
31 | - https://til.energizedwork.com/
32 | - https://til.brianemory.com/
33 | - https://selleo.com/til
34 | - https://til.simplebet.io
35 |
36 | Thank you to everybody who has forked and contributed.
37 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tilex",
3 | "scripts": {
4 | "postdeploy": "mix ecto.migrate && POOL_SIZE=1 mix run priv/repo/seeds.exs"
5 | },
6 | "env": {
7 | "BASIC_AUTH_PASSWORD": {
8 | "required": true
9 | },
10 | "BASIC_AUTH_USERNAME": {
11 | "required": true
12 | },
13 | "CANONICAL_DOMAIN": {
14 | "required": true
15 | },
16 | "ENABLE_BASIC_AUTH": {
17 | "required": true
18 | },
19 | "GOOGLE_CLIENT_ID": {
20 | "required": true
21 | },
22 | "GOOGLE_CLIENT_SECRET": {
23 | "required": true
24 | },
25 | "ORGANIZATION_NAME": {
26 | "required": true
27 | },
28 | "POOL_SIZE": {
29 | "required": true
30 | },
31 | "SECRET_KEY_BASE": {
32 | "required": true
33 | },
34 | "IMGUR_CLIENT_ID": {
35 | "required": true
36 | },
37 | "DATE_DISPLAY_TZ": {
38 | "required": true
39 | },
40 | "OTP_COOKIE": {
41 | "description": "A cookie used for remote node connections",
42 | "generator": "secret",
43 | "required": true
44 | }
45 | },
46 | "formation": {},
47 | "addons": ["heroku-postgresql"],
48 | "buildpacks": [
49 | {
50 | "url": "https://github.com/HashNuke/heroku-buildpack-elixir.git"
51 | },
52 | {
53 | "url": "https://github.com/gjaldon/heroku-buildpack-phoenix-static.git"
54 | }
55 | ]
56 | }
57 |
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | /* This file is for your main application CSS */
2 | @import "../vendor/css/prism.css";
3 | @import "./variables.css";
4 | @import "./normalize.css";
5 | @import "./reset.css";
6 | @import "./layout.css";
7 | @import "./site_nav.css";
8 | @import "./components.css";
9 | @import "./pages.css";
10 | @import "./icons.css";
11 |
--------------------------------------------------------------------------------
/assets/css/icons.css:
--------------------------------------------------------------------------------
1 | .icon-small {
2 | height: 1.8rem;
3 | width: 1.8rem;
4 | }
5 |
6 | .icon-medium {
7 | height: 2.4rem;
8 | width: 2.4rem;
9 | }
10 |
11 | .icon-large {
12 | height: 3.2rem;
13 | width: 3.2rem;
14 | }
15 |
16 | .icon-hashrocket {
17 | color: var(--hashrocket-color);
18 | }
19 |
20 | .icon-twitter {
21 | color: var(--twitter-color);
22 | }
23 |
24 | .icon-github {
25 | color: var(--github-color);
26 | }
27 |
--------------------------------------------------------------------------------
/assets/css/reset.css:
--------------------------------------------------------------------------------
1 | /* Tantek Celik's Whitespace Reset */
2 | /* Author: Tantek Celik, Shane Riley */
3 | /* Version: (CC) 2010 Some Rights Reserved - http://creativecommons.org/licenses/by/2.0 */
4 | /* Description: Resets default styling of common browsers to a common base */
5 |
6 | ul, ol {
7 | list-style: none;
8 | }
9 |
10 | h1, h2, h3, h4, h5, h6, pre, code {
11 | font-size: 1em;
12 | }
13 |
14 | ul, ol, li, h1, h2, h3, h4, h5, h6, pre, form, body, html, p, blockquote, fieldset, input, dl, dt, dd, figure, figcaption, button {
15 | margin: 0;
16 | padding: 0;
17 | }
18 |
19 | a img, :link img, :visited img, fieldset {
20 | border: none;
21 | }
22 |
23 | address {
24 | font-style: normal;
25 | }
26 |
27 | header, section, article, nav, footer, hgroup, details, summary, figure, main {
28 | display: block;
29 | }
30 |
31 | mark {
32 | color: inherit;
33 | background: transparent;
34 | }
35 |
36 | abbr {
37 | border: none;
38 | }
39 |
40 | summary::-webkit-details-marker {
41 | display: none;
42 | }
43 |
--------------------------------------------------------------------------------
/assets/css/variables.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --primary-typeface: "Lora", "Helvetica Neue", Helvetica, sans-serif;
3 | --secondary-typeface: "Raleway", "Helvetica Neue", Helvetica, sans-serif;
4 | --news-typeface: "Limelight", sans-serif;
5 |
6 | --light: #d5e9f5;
7 | --light-ish: #acd3eb;
8 | --med: #92c4d1;
9 | --dark: #414347;
10 |
11 | --navy: #082736;
12 | --navy-light: rgba(8, 39, 54, 0.2);
13 | --red: #ae1f23;
14 | --purple: #7b185d;
15 | --pink: #ee7567;
16 | --green: #008800;
17 | --blue: #007dae;
18 | --blue-light: #00a2e1;
19 |
20 | --hashrocket-color: #ae1f23;
21 | --twitter-color: #006da3;
22 | --github-color: #4b4b4b;
23 | }
24 |
--------------------------------------------------------------------------------
/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // We import the CSS which is extracted to its own file by esbuild.
2 | // Remove this line if you add a your own CSS build pipeline (e.g postcss).
3 | import "../css/app.css"
4 |
5 | // If you want to use Phoenix channels, run `mix help phx.gen.channel`
6 | // to get started and then uncomment the line below.
7 | // import "./user_socket.js"
8 |
9 | // You can include dependencies in two ways.
10 | //
11 | // The simplest option is to put them in assets/vendor and
12 | // import them using relative paths:
13 | //
14 | // import "../vendor/some-package.js"
15 | //
16 | // Alternatively, you can `npm install some-package --prefix assets` and import
17 | // them using a path starting with the package name:
18 | //
19 | // import "some-package"
20 | //
21 |
22 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
23 | import "phoenix_html"
24 | // Establish Phoenix Socket and LiveView configuration.
25 | import {Socket} from "phoenix"
26 | import {LiveSocket} from "phoenix_live_view"
27 | import topbar from "../vendor/topbar"
28 |
29 | import './tilex';
30 | import './like';
31 | import './twitter_button';
32 | import '../vendor/js/prism';
33 |
34 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
35 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
36 |
37 | // Show progress bar on live navigation and form submits
38 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
39 | window.addEventListener("phx:page-loading-start", info => topbar.show())
40 | window.addEventListener("phx:page-loading-stop", info => topbar.hide())
41 |
42 | // connect if there are any LiveViews on the page
43 | liveSocket.connect()
44 |
45 | // expose liveSocket on window for web console debug logs and latency simulation:
46 | // >> liveSocket.enableDebug()
47 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
48 | // >> liveSocket.disableLatencySim()
49 | window.liveSocket = liveSocket
50 |
--------------------------------------------------------------------------------
/assets/js/google_analytics.js:
--------------------------------------------------------------------------------
1 | (function(i, s, o, g, r, a, m) {
2 | i['GoogleAnalyticsObject'] = r;
3 | (i[r] =
4 | i[r] ||
5 | function() {
6 | (i[r].q = i[r].q || []).push(arguments);
7 | }), (i[r].l = 1 * new Date());
8 | (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]);
9 | a.async = 1;
10 | a.src = g;
11 | m.parentNode.insertBefore(a, m);
12 | })(
13 | window,
14 | document,
15 | 'script',
16 | 'https://www.google-analytics.com/analytics.js',
17 | 'ga'
18 | );
19 |
20 | ga('create', 'UA-7742210-18', 'auto');
21 | ga('send', 'pageview');
22 |
--------------------------------------------------------------------------------
/assets/js/image_uploader.js:
--------------------------------------------------------------------------------
1 | export function uploadImage(file, onSuccess, onError) {
2 | const { imgurApiKey } = window.Tilex.clientConfig;
3 | if (!imgurApiKey) {
4 | onError({ showAlert: false });
5 | throw new Error(
6 | 'Imgur API Key is not set. Please update your environment variables.'
7 | );
8 | }
9 |
10 | const xhr = new XMLHttpRequest();
11 | xhr.open('POST', 'https://api.imgur.com/3/image', true);
12 | xhr.setRequestHeader('Authorization', `Client-id ${imgurApiKey}`);
13 | xhr.onload = () => {
14 | const parsedResponse = JSON.parse(xhr.response);
15 |
16 | if (xhr.status > 201 || parsedResponse.data.error) {
17 | onError();
18 | return;
19 | }
20 |
21 | const url = parsedResponse.data.link;
22 |
23 | onSuccess(url);
24 | };
25 |
26 | xhr.onerror = onError;
27 |
28 | const fd = new FormData();
29 | fd.append('image', file);
30 | xhr.send(fd);
31 | }
32 |
--------------------------------------------------------------------------------
/assets/js/like.js:
--------------------------------------------------------------------------------
1 | import $ from 'jquery';
2 | import 'jquery.cookie';
3 |
4 | $(function(){
5 | var csrf = document.querySelector('meta[name=csrf-token]').content;
6 |
7 | function LikeButton(el) {
8 | this.id = el.id;
9 | this.$el = $(el);
10 | this.$count = this.$el.find('.post__like-count')
11 | this.$el.on("click", $.proxy(this.toggle, this));
12 | this.updateClass();
13 | };
14 |
15 | LikeButton.prototype.toggle = function(e) {
16 | e.preventDefault();
17 | this.isLiked() ? this.unlike() : this.like();
18 | };
19 |
20 | LikeButton.prototype.like = function() {
21 | var lb = this;
22 | $.ajax({
23 | type: "POST",
24 | url: "/posts/" + lb.id + "/like.json",
25 | data: {},
26 | success: function(result) {
27 | $.cookie(lb.likeSlug(), 'liked', { path: '/', expires: 3600 });
28 | lb.updateText(result);
29 | lb.updateClass();
30 | },
31 | headers: {
32 | "X-CSRF-TOKEN": csrf
33 | }
34 | });
35 | };
36 |
37 | LikeButton.prototype.unlike = function() {
38 | var lb = this;
39 | $.ajax({
40 | type: "POST",
41 | url: "/posts/" + lb.id + "/unlike.json",
42 | data: {},
43 | success: function(result){
44 | $.removeCookie(lb.likeSlug(), { path: '/', expires: 3600 });
45 | lb.updateText(result);
46 | lb.updateClass();
47 | },
48 | headers: {
49 | "X-CSRF-TOKEN": csrf
50 | }
51 | });
52 | };
53 |
54 | LikeButton.prototype.updateClass = function() {
55 | this.$el.toggleClass('liked', this.isLiked());
56 | }
57 |
58 | LikeButton.prototype.updateText = function(result) {
59 | this.$count.text(result.likes);
60 | };
61 |
62 | LikeButton.prototype.isLiked = function() {
63 | return !!$.cookie(this.likeSlug());
64 | };
65 |
66 | LikeButton.prototype.likeSlug = function() {
67 | return 'liked -' + this.id;
68 | };
69 |
70 | $('.js-like-action').each(function() {
71 | new LikeButton(this);
72 | });
73 |
74 | $('header').attr('data-likes-loaded', 'true')
75 | });
76 |
--------------------------------------------------------------------------------
/assets/js/socket.js:
--------------------------------------------------------------------------------
1 | // NOTE: The contents of this file will only be executed if
2 | // you uncomment its entry in "web/static/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/my_app/endpoint.ex":
6 | import {Socket} from "phoenix"
7 |
8 | let socket = new Socket("/socket", {params: {token: window.userToken}})
9 |
10 | // When you connect, you'll often need to authenticate the client.
11 | // For example, imagine you have an authentication plug, `MyAuth`,
12 | // which authenticates the session and assigns a `:current_user`.
13 | // If the current user exists you can assign the user's token in
14 | // the connection for use in the layout.
15 | //
16 | // In your "web/router.ex":
17 | //
18 | // pipeline :browser do
19 | // ...
20 | // plug MyAuth
21 | // plug :put_user_token
22 | // end
23 | //
24 | // defp put_user_token(conn, _) do
25 | // if current_user = conn.assigns[:current_user] do
26 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id)
27 | // assign(conn, :user_token, token)
28 | // else
29 | // conn
30 | // end
31 | // end
32 | //
33 | // Now you need to pass this token to JavaScript. You can do so
34 | // inside a script tag in "web/templates/layout/app.html.eex":
35 | //
36 | //
37 | //
38 | // You will need to verify the user token in the "connect/2" function
39 | // in "web/channels/user_socket.ex":
40 | //
41 | // def connect(%{"token" => token}, socket) do
42 | // # max_age: 1209600 is equivalent to two weeks in seconds
43 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
44 | // {:ok, user_id} ->
45 | // {:ok, assign(socket, :user, user_id)}
46 | // {:error, reason} ->
47 | // :error
48 | // end
49 | // end
50 | //
51 | // Finally, pass the token on connect as below. Or remove it
52 | // from connect if you don't care about authentication.
53 |
54 | socket.connect()
55 |
56 | // Now that you are connected, you can join channels with a topic:
57 | let channel = socket.channel("topic:subtopic", {})
58 | channel.join()
59 | .receive("ok", resp => { console.log("Joined successfully", resp) })
60 | .receive("error", resp => { console.log("Unable to join", resp) })
61 |
62 | export default socket
63 |
--------------------------------------------------------------------------------
/assets/js/text_conversion.js:
--------------------------------------------------------------------------------
1 | import {Socket} from "phoenix"
2 |
3 | export default class TextConversion {
4 | constructor(properties) {
5 | this.convertedTextCallback = properties.convertedTextCallback
6 | this.socket = new Socket("/socket")
7 | this.channel = this.socket.channel("text_converter", {})
8 | }
9 |
10 | init() {
11 | this.socket.connect()
12 | this.channel.join()
13 | this.observeChannelResponse()
14 | }
15 |
16 | convert(text, format) {
17 | let conversionObject = {}
18 | conversionObject[format] = text
19 | this.channel.push("convert", conversionObject)
20 | }
21 |
22 | observeChannelResponse() {
23 | this.channel.on("converted", payload => {
24 | this.convertedTextCallback(payload.html);
25 | })
26 | }
27 | }
28 |
29 |
--------------------------------------------------------------------------------
/assets/js/tilex.js:
--------------------------------------------------------------------------------
1 | import $ from 'jquery';
2 | import PostForm from './post_form';
3 |
4 | $(function() {
5 | $('#flash p').on(
6 | 'load',
7 | (function() {
8 | setTimeout(function() {
9 | $('#flash p').fadeOut(200);
10 | }, 10000);
11 | })()
12 | );
13 |
14 | $(document.body).on('click', '#flash p', function(e) {
15 | e.preventDefault();
16 | $(this).fadeOut(200);
17 | });
18 |
19 | $('.site_nav__search, .site_nav__about').on(
20 | 'click',
21 | '.site_nav__link',
22 | function(e) {
23 | e.preventDefault();
24 |
25 | // Close all OTHER links to prevent overlap
26 | const site_nav_links = document.getElementsByClassName('site_nav__link')
27 | for (let i = 0; i < site_nav_links.length; i++) {
28 | if ($(this).parent()[0] != $(site_nav_links[i]).parent()[0]) {
29 | $(site_nav_links[i]).closest('li').removeClass('site_nav--open')
30 | }
31 | }
32 |
33 | $(this)
34 | .closest('li')
35 | .toggleClass('site_nav--open')
36 | .find(':input:visible')
37 | .eq(0)
38 | .focus();
39 | }
40 | );
41 |
42 | new PostForm({
43 | postBodyInput: $('textarea#post_body'),
44 | postBodyPreview: $('.content_preview'),
45 | wordCountContainer: $('.word_count'),
46 | bodyWordLimitContainer: $('.word_limit'),
47 | bodyWordLimit: $('.word_limit').data('limit'),
48 | titleInput: $('input#post_title'),
49 | titleCharacterLimitContainer: $('.character_limit'),
50 | titleCharacterLimit: $('.character_limit').data('limit'),
51 | previewTitleContainer: $('.title_preview'),
52 | loadingIndicator: document.querySelector('.loading-indicator'),
53 | }).init();
54 | });
55 |
--------------------------------------------------------------------------------
/assets/js/twitter_button.js:
--------------------------------------------------------------------------------
1 | import $ from 'jquery';
2 |
3 | $(function() {
4 | !(function(d, s, id) {
5 | var js,
6 | fjs = d.getElementsByTagName(s)[0],
7 | p = /^http:/.test(d.location) ? "http" : "https";
8 | if (!d.getElementById(id)) {
9 | js = d.createElement(s);
10 | js.id = id;
11 | js.src = p + "://platform.twitter.com/widgets.js";
12 | fjs.parentNode.insertBefore(js, fjs);
13 | }
14 | })(document, "script", "twitter-wjs");
15 | });
16 |
--------------------------------------------------------------------------------
/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "repository": "https://github.com/hashrocket/tilex",
3 | "description": "TIL is an open-source project by Hashrocket that exists to catalogue the sharing & accumulation of knowledge as it happens day-to-day.",
4 | "license": "MIT",
5 | "scripts": {
6 | "deploy": "cd .. && mix assets.deploy && rm -f _build/esbuild*"
7 | },
8 | "dependencies": {
9 | "autosize": "^5.0.1",
10 | "codemirror": "^5.65.9",
11 | "codemirror-mode-elixir": "^1.1.1",
12 | "jquery": "^3.6.1",
13 | "jquery.cookie": "^1.4.1"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | config :tilex, Tilex.Repo,
5 | username: System.get_env("POSTGRES_USER", "postgres"),
6 | password: System.get_env("POSTGRES_PASSWORD", ""),
7 | hostname: "localhost",
8 | database: "tilex_dev",
9 | stacktrace: true,
10 | show_sensitive_data_on_connection_error: true,
11 | pool_size: 10
12 |
13 | # For development, we disable any cache and enable
14 | # debugging and code reloading.
15 | #
16 | # The watchers configuration can be used to run external
17 | # watchers to your application. For example, we use it
18 | # with esbuild to bundle .js and .css sources.
19 | config :tilex, TilexWeb.Endpoint,
20 | # Binding to loopback ipv4 address prevents access from other machines.
21 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
22 | http: [ip: {127, 0, 0, 1}, port: 4000],
23 | check_origin: false,
24 | code_reloader: true,
25 | debug_errors: true,
26 | secret_key_base: "1oclGLYDynPdlBdmLiD7BAhVnr9rzsN/MuIkdAzoORECpsLXwCxzIICtxRg+AiS0",
27 | watchers: [
28 | # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
29 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
30 | ]
31 |
32 | # ## SSL Support
33 | #
34 | # In order to use HTTPS in development, a self-signed
35 | # certificate can be generated by running the following
36 | # Mix task:
37 | #
38 | # mix phx.gen.cert
39 | #
40 | # Note that this task requires Erlang/OTP 20 or later.
41 | # Run `mix help phx.gen.cert` for more information.
42 | #
43 | # The `http:` config above can be replaced with:
44 | #
45 | # https: [
46 | # port: 4001,
47 | # cipher_suite: :strong,
48 | # keyfile: "priv/cert/selfsigned_key.pem",
49 | # certfile: "priv/cert/selfsigned.pem"
50 | # ],
51 | #
52 | # If desired, both `http:` and `https:` keys can be
53 | # configured to run both http and https servers on
54 | # different ports.
55 |
56 | # Watch static and templates for browser reloading.
57 | config :tilex, TilexWeb.Endpoint,
58 | live_reload: [
59 | patterns: [
60 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
61 | ~r"priv/gettext/.*(po)$",
62 | ~r"lib/tilex_web/(live|views)/.*(ex)$",
63 | ~r"lib/tilex_web/templates/.*(eex)$"
64 | ]
65 | ]
66 |
67 | # Do not include metadata nor timestamps in development logs
68 | config :logger, :console, format: "[$level] $message\n"
69 |
70 | # Set a higher stacktrace during development. Avoid configuring such
71 | # in production as building large stacktraces may be expensive.
72 | config :phoenix, :stacktrace_depth, 20
73 |
74 | # Initialize plugs at runtime for faster development compilation
75 | config :phoenix, :plug_init_mode, :runtime
76 |
77 | config :tilex, :page_size, 50
78 | config :tilex, :cors_origin, "http://localhost:4000"
79 | config :tilex, :default_twitter_handle, "hashrocket"
80 | config :tilex, :request_tracking, true
81 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import 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 | static_url =
13 | System.get_env("EDGE_URL")
14 | |> Kernel.||(System.get_env("HOST"))
15 | |> Kernel.||("")
16 | |> URI.parse()
17 | |> Map.from_struct()
18 |
19 | config :tilex, TilexWeb.Endpoint,
20 | instrumenters: [Appsignal.Phoenix.Instrumenter],
21 | cache_static_manifest: "priv/static/cache_manifest.json",
22 | static_url: static_url
23 |
24 | # Do not print debug messages in production
25 | config :logger, level: :info
26 |
27 | # ## SSL Support
28 | #
29 | # To get SSL working, you will need to add the `https` key
30 | # to the previous section and set your `:url` port to 443:
31 | #
32 | # config :tilex, TilexWeb.Endpoint,
33 | # ...,
34 | # url: [host: "example.com", port: 443],
35 | # https: [
36 | # ...,
37 | # port: 443,
38 | # cipher_suite: :strong,
39 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
40 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
41 | # ]
42 | #
43 | # The `cipher_suite` is set to `:strong` to support only the
44 | # latest and more secure SSL ciphers. This means old browsers
45 | # and clients may not be supported. You can set it to
46 | # `:compatible` for wider support.
47 | #
48 | # `:keyfile` and `:certfile` expect an absolute path to the key
49 | # and cert in disk or a relative path inside priv, for example
50 | # "priv/ssl/server.key". For all supported SSL configuration
51 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
52 | #
53 | # We also recommend setting `force_ssl` in your endpoint, ensuring
54 | # no data is ever sent via http, always redirecting to https:
55 | #
56 | # config :tilex, TilexWeb.Endpoint,
57 | # force_ssl: [hsts: true]
58 | #
59 | config :tilex, TilexWeb.Endpoint, force_ssl: [rewrite_on: [:x_forwarded_proto]]
60 | #
61 | # Check `Plug.SSL` for all available options in `force_ssl`.
62 |
63 | # Finally import the config/prod.secret.exs
64 | # which should be versioned separately.
65 |
66 | if System.get_env("ENABLE_BASIC_AUTH") do
67 | config :tilex, :basic_auth,
68 | realm: "tilex",
69 | username: System.get_env("BASIC_AUTH_USERNAME"),
70 | password: System.get_env("BASIC_AUTH_PASSWORD")
71 | end
72 |
73 | config :tilex, :page_size, 50
74 | config :tilex, :ga_identifier, System.get_env("GA_IDENTIFIER")
75 | config :tilex, :request_tracking, System.get_env("REQUEST_TRACKING")
76 |
77 | config :appsignal, :config, active: true, ignore_errors: ["Ecto.NoResultsError"]
78 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | #
5 | # The MIX_TEST_PARTITION environment variable can be used
6 | # to provide built-in test partitioning in CI environment.
7 | # Run `mix help test` for more information.
8 | config :tilex, Tilex.Repo,
9 | username: System.get_env("POSTGRES_USER", "postgres"),
10 | password: System.get_env("POSTGRES_PASSWORD", ""),
11 | hostname: "localhost",
12 | database: "tilex_test#{System.get_env("MIX_TEST_PARTITION")}",
13 | pool: Ecto.Adapters.SQL.Sandbox,
14 | pool_size: 50,
15 | timeout: 30_000
16 |
17 | # We don't run a server during test. If one is required,
18 | # you can enable the server option below.
19 | config :tilex, TilexWeb.Endpoint,
20 | http: [ip: {127, 0, 0, 1}, port: 4002],
21 | secret_key_base: "Wu16BxBM2nOO3e0YEIcPs14fVuVXRZPew1CpIBVoG59QvlDRNKmuR/Pjh2LYE7bf",
22 | server: true
23 |
24 | # In test we don't send emails.
25 | config :tilex, Tilex.Mailer, adapter: Swoosh.Adapters.Test
26 |
27 | # Print only warnings and errors during test
28 | config :logger, level: :warn
29 |
30 | # Initialize plugs at runtime for faster test compilation
31 | config :phoenix, :plug_init_mode, :runtime
32 |
33 | config :tilex, :sandbox, Ecto.Adapters.SQL.Sandbox
34 |
35 | config :tilex, :organization_name, "Hashrocket"
36 | config :tilex, :canonical_domain, "https://til.hashrocket.com"
37 | config :tilex, :default_twitter_handle, "hashrocket"
38 | config :tilex, :hosted_domain, "hashrocket.com"
39 | config :tilex, :auth_controller, Test.AuthController
40 | config :tilex, :slack_notifier, Test.Notifications.Notifiers.Slack
41 | config :tilex, :twitter_notifier, Test.Notifications.Notifiers.Twitter
42 | config :tilex, :date_time_module, Tilex.DateTimeMock
43 | config :tilex, :date_display_tz, "America/Chicago"
44 | config :tilex, :slack_endpoint, "https://slack.test.com/abc/123"
45 |
46 | config :httpoison, timeout: 6000
47 |
48 | config :wallaby,
49 | otp_app: :tilex,
50 | screenshot_dir: "/screenshots",
51 | screenshot_on_failure: true,
52 | chromedriver: [headless: System.get_env("HEADLESS", "true") == "true"]
53 |
54 | config :tilex, :request_tracking, true
55 |
56 | config :appsignal, :config, active: false
57 |
--------------------------------------------------------------------------------
/coveralls.json:
--------------------------------------------------------------------------------
1 | {
2 | "skip_files": [
3 | "lib/mix/tasks",
4 | "lib/test",
5 | "lib/tilex/repo.ex",
6 | "lib/tilex_web.ex",
7 | "lib/tilex_web/channels/user_socket.ex",
8 | "lib/tilex_web/endpoint.ex",
9 | "lib/tilex_web/gettext.ex",
10 | "lib/tilex_web/router.ex",
11 | "test/support"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/elixir_buildpack.config:
--------------------------------------------------------------------------------
1 | config_vars_to_export=(BASIC_AUTH_PASSWORD BASIC_AUTH_USERNAME)
2 | elixir_version=1.14.1
3 | erlang_version=25.1.2
4 |
--------------------------------------------------------------------------------
/lib/mix/tasks/deploy.ex:
--------------------------------------------------------------------------------
1 | defmodule Mix.Tasks.Deploy do
2 | use Mix.Task
3 |
4 | @shortdoc "Deployment commands."
5 | @moduledoc """
6 | Deploys our environments.
7 | """
8 |
9 | @environments ~w(staging production)
10 |
11 | def run([env]) when env in @environments, do: do_run(env)
12 | def run(_), do: raise("Unsupported environment, try one of these: #{inspect(@environments)}")
13 |
14 | defp do_run(env) do
15 | System.cmd("git", ["fetch", "--tags"])
16 | System.cmd("git", ["tag", "-d", env])
17 | System.cmd("git", ["push", "origin", ":refs/tags/#{env}"])
18 | System.cmd("git", ["tag", env])
19 | System.cmd("git", ["push", "origin", "--tags"])
20 |
21 | System.cmd("git", ["push", env, "master"])
22 | System.cmd("heroku", ["run", "POOL_SIZE=2 mix ecto.migrate", "-r#{env}"])
23 | System.cmd("heroku", ["restart", "-r#{env}"])
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/mix/tasks/ecto.twiki.ex:
--------------------------------------------------------------------------------
1 | defmodule Mix.Tasks.Ecto.Twiki do
2 | use Mix.Task
3 |
4 | alias Ecto.Migrator
5 | alias Mix.Ecto
6 | alias Mix.Project
7 | alias Mix.EctoSQL
8 |
9 | @shortdoc "Ecto Migration: Up, Down, Up"
10 |
11 | @moduledoc """
12 | This will migrate the latest Ecto migration, roll it back, and then
13 | migrate it again. This is an easy way to ensure that migrations can go
14 | up and down.
15 | """
16 |
17 | def run(args) do
18 | Application.ensure_all_started(Project.config()[:app])
19 |
20 | repos = Ecto.parse_repo(args)
21 |
22 | twiki(repos)
23 | end
24 |
25 | defp twiki(repo) when is_atom(repo) do
26 | migration_dir =
27 | repo
28 | |> EctoSQL.source_repo_priv()
29 | |> Path.absname()
30 | |> Path.join("migrations")
31 |
32 | count = down_count(repo, migration_dir)
33 |
34 | Enum.each([:up, :down, :up], fn direction ->
35 | migrate(direction, repo, migration_dir, step: count)
36 | end)
37 | end
38 |
39 | defp twiki([repo]) do
40 | twiki(repo)
41 | end
42 |
43 | defp twiki([_repo | _more_repos] = repos) do
44 | Mix.shell().info("""
45 | Ecto.Twiki only supports migrating a single repo.
46 | However, we found multiple repos: #{inspect(repos)}
47 | """)
48 | end
49 |
50 | defp migrate(direction, _repo, _migration_dir, step: 0) do
51 | Mix.shell().info("Already #{direction}")
52 | []
53 | end
54 |
55 | defp migrate(direction, repo, migration_dir, opts) do
56 | Mix.shell().info("Migrating #{direction}")
57 | Migrator.run(repo, migration_dir, direction, opts)
58 | end
59 |
60 | defp down_count(repo, migration_dir) do
61 | direction_count(:down, repo, migration_dir)
62 | end
63 |
64 | defp direction_count(direction, repo, migration_dir) do
65 | repo
66 | |> Migrator.migrations(migration_dir)
67 | |> Enum.filter(fn {status, _, _} -> status == direction end)
68 | |> Enum.count()
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/lib/mix/tasks/tilex/page_views.ex:
--------------------------------------------------------------------------------
1 | defmodule Mix.Tasks.Tilex.PageViews do
2 | use Mix.Task
3 |
4 | @shortdoc "Tilex Page Views: Highlight page views stats for the last couple of weeks"
5 |
6 | @moduledoc """
7 | Run `mix tilex.page_views` to print a report that looks like this:
8 |
9 | Best Day Last Week: 3526 Wed 10/17
10 | Last Week: 19571 Mon 10/15
11 | Best Day Week Before: 3416 Tue 10/09
12 | Week Before: 18420 Mon 10/08
13 |
14 | Run `mix tilex.page_views notify` to also send that report to any notifiers
15 | """
16 |
17 | def run([]) do
18 | run(["no-notifications"])
19 | end
20 |
21 | def run([notification]) do
22 | Application.ensure_all_started(:tilex)
23 |
24 | {status, report} = Tilex.PageViewsReport.report()
25 |
26 | if status == :ok do
27 | IO.puts(report)
28 |
29 | if notification == "notify" do
30 | Tilex.Notifications.page_views_report(report)
31 | end
32 | else
33 | IO.puts("no data available")
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/mix/tasks/tilex/streaks.ex:
--------------------------------------------------------------------------------
1 | defmodule Mix.Tasks.Tilex.Streaks do
2 | use Mix.Task
3 | import Mix.Ecto, only: [parse_repo: 1]
4 |
5 | @shortdoc "Tilex Stats: Days in a row a til was posted"
6 |
7 | @moduledoc """
8 | Run `mix tilex.streaks` to get days in a row a til was posted.
9 | Run `mix tilex.streaks chriserin` to get days in a row a til was posted by chriserin.
10 | """
11 |
12 | def run([]), do: run(["%"])
13 |
14 | def run([username] = args) do
15 | repo =
16 | args
17 | |> parse_repo
18 | |> hd
19 |
20 | streaks_sql = """
21 | with days as (
22 | select generate_series('4-12-2015'::date, now()::date, '1 day'::interval)::date as series_date
23 | ), specific_posts as (
24 | select
25 | p.inserted_at,
26 | developers.username
27 | from posts p
28 | inner join developers on p.developer_id = developers.id
29 | where username like $1
30 | ), all_til_days as (
31 | select
32 | inserted_at::date post_inserted,
33 | d.series_date series_date,
34 | username
35 | from specific_posts p
36 | right join days d on d.series_date = p.inserted_at::date
37 | ), partitioned_days as (
38 | select
39 | post_inserted,
40 | username,
41 | (select max(sub.series_date) from all_til_days sub where sub.post_inserted is null and sub.series_date < orig.series_date) last_unposted_day
42 | from all_til_days orig where post_inserted is not null
43 | ), streaks as (
44 | select
45 | count(distinct post_inserted) streak_length,
46 | min(post_inserted) start_date,
47 | max(post_inserted) end_date,
48 | array_agg(distinct username)
49 | from partitioned_days
50 | group by last_unposted_day
51 | having count(distinct post_inserted) >= 5
52 | order by streak_length desc
53 | )
54 | select * from streaks;
55 | """
56 |
57 | Mix.Task.run("app.start")
58 |
59 | {:ok, result} = repo.query(streaks_sql, [username], log: false)
60 |
61 | Enum.each(result.rows, fn [streak_length, start_date, end_date, _people] ->
62 | formatted_start_date = Timex.format!(start_date, "%m/%d/%Y", :strftime)
63 | formatted_end_date = Timex.format!(end_date, "%m/%d/%Y", :strftime)
64 |
65 | IO.puts("#{streak_length} days from #{formatted_start_date} to #{formatted_end_date}")
66 | end)
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/lib/test/notifications/notifiers/slack.ex:
--------------------------------------------------------------------------------
1 | defmodule Test.Notifications.Notifiers.Slack do
2 | use Tilex.Notifications.Notifier
3 |
4 | def handle_post_created(_post, _developer, _channel, _url) do
5 | :ok
6 | end
7 |
8 | def handle_post_liked(_post, _developer, _url) do
9 | :ok
10 | end
11 |
12 | def handle_page_views_report(_report) do
13 | :ok
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/test/notifications/notifiers/twitter.ex:
--------------------------------------------------------------------------------
1 | defmodule Test.Notifications.Notifiers.Twitter do
2 | use Tilex.Notifications.Notifier
3 |
4 | def handle_post_created(_post, _developer, _channel, _url) do
5 | :ok
6 | end
7 |
8 | def handle_post_liked(_post, _developer, _url) do
9 | :ok
10 | end
11 |
12 | def handle_page_views_report(_report) do
13 | :ok
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/tilex.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex do
2 | end
3 |
--------------------------------------------------------------------------------
/lib/tilex/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.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 | @impl true
9 | def start(_type, _args) do
10 | children = [
11 | Tilex.Repo,
12 | TilexWeb.Telemetry,
13 | {Phoenix.PubSub, name: Tilex.PubSub},
14 | TilexWeb.Endpoint,
15 | {Cachex, name: :tilex_cache},
16 | Tilex.Notifications,
17 | Tilex.RateLimiter,
18 | Tilex.Notifications.NotifiersSupervisor
19 | ]
20 |
21 | :telemetry.attach(
22 | "appsignal-ecto",
23 | [:tilex, :repo, :query],
24 | &Appsignal.Ecto.handle_event/4,
25 | nil
26 | )
27 |
28 | # See https://hexdocs.pm/elixir/Supervisor.html
29 | # for other strategies and supported options
30 | opts = [strategy: :one_for_one, name: Tilex.Supervisor]
31 | Supervisor.start_link(children, opts)
32 | end
33 |
34 | # Tell Phoenix to update the endpoint configuration
35 | # whenever the application is updated.
36 | @impl true
37 | def config_change(changed, _new, removed) do
38 | TilexWeb.Endpoint.config_change(changed, removed)
39 | :ok
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/tilex/auth/error_handler.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Auth.ErrorHandler do
2 | import TilexWeb.Router.Helpers
3 | import Phoenix.Controller, only: [put_flash: 3, redirect: 2]
4 |
5 | @behaviour Guardian.Plug.ErrorHandler
6 |
7 | @impl Guardian.Plug.ErrorHandler
8 | def auth_error(conn, {_failure_type, _reason}, _opts) do
9 | conn
10 | |> Tilex.Auth.Guardian.Plug.sign_out()
11 | |> put_flash(:info, "Authentication required")
12 | |> redirect(to: post_path(conn, :index))
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/tilex/auth/guardian.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Auth.Guardian do
2 | use Guardian, otp_app: :tilex
3 |
4 | alias Tilex.Blog.Developer
5 | alias Tilex.Repo
6 |
7 | def subject_for_token(%Developer{} = developer, _claims), do: {:ok, "Developer:#{developer.id}"}
8 | def subject_for_token(_resource, _claims), do: {:error, "Unknown resource type"}
9 |
10 | def resource_from_claims(%{"sub" => "Developer:" <> id}), do: {:ok, Repo.get(Developer, id)}
11 | def resource_from_claims(_claims), do: {:error, "Unknown resource type"}
12 | end
13 |
--------------------------------------------------------------------------------
/lib/tilex/blog/channel.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Blog.Channel do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 | import Ecto.Query
5 |
6 | @type t :: module
7 |
8 | alias Tilex.Blog.Post
9 |
10 | schema "channels" do
11 | field(:name, :string)
12 | field(:twitter_hashtag, :string)
13 |
14 | has_many(:posts, Post)
15 |
16 | timestamps()
17 | end
18 |
19 | def changeset(struct, params \\ %{}) do
20 | struct
21 | |> cast(params, [:name, :twitter_hashtag])
22 | |> unique_constraint(:name)
23 | |> validate_required([:name, :twitter_hashtag])
24 | end
25 |
26 | def names_and_ids(query) do
27 | from(c in query, select: {c.name, c.id})
28 | end
29 |
30 | def alphabetized(query) do
31 | from(c in query, order_by: c.name)
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/tilex/blog/developer.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Blog.Developer do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | @type t :: module
6 |
7 | alias Tilex.Blog.Developer
8 | alias Tilex.Blog.Post
9 |
10 | schema "developers" do
11 | field(:email, :string)
12 | field(:username, :string)
13 | field(:twitter_handle, :string)
14 | field(:admin, :boolean)
15 | field(:editor, :string)
16 |
17 | has_many(:posts, Post)
18 |
19 | timestamps()
20 | end
21 |
22 | def changeset(struct, params \\ %{}) do
23 | struct
24 | |> cast(params, [:email, :username, :twitter_handle, :editor])
25 | |> validate_required([:email, :username])
26 | |> clean_twitter_handle
27 | end
28 |
29 | def find_or_create(repo, attrs) do
30 | email = Map.get(attrs, :email)
31 |
32 | case repo.get_by(Developer, email: email) do
33 | %Developer{} = developer ->
34 | {:ok, developer}
35 |
36 | _ ->
37 | %Developer{}
38 | |> changeset(attrs)
39 | |> repo.insert()
40 | end
41 | end
42 |
43 | def twitter_handle(%Developer{twitter_handle: twitter_handle}) do
44 | twitter_handle || Application.get_env(:tilex, :default_twitter_handle)
45 | end
46 |
47 | def format_username(name) when is_binary(name) do
48 | name
49 | |> String.downcase()
50 | |> String.replace(" ", "")
51 | end
52 |
53 | defp clean_twitter_handle(changeset) do
54 | twitter_handle = get_change(changeset, :twitter_handle)
55 |
56 | if twitter_handle do
57 | clean_twitter_handle = String.replace_leading(twitter_handle, "@", "")
58 |
59 | changeset
60 | |> put_change(:twitter_handle, clean_twitter_handle)
61 | else
62 | changeset
63 | end
64 | end
65 |
66 | defimpl Phoenix.Param, for: Developer do
67 | def to_param(%{username: username}) do
68 | username
69 | end
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/tilex/blog/post.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Blog.Post do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | @type t :: module
6 |
7 | alias Tilex.Blog.Developer
8 | alias Tilex.Blog.Channel
9 | alias Tilex.Blog.Post
10 |
11 | @body_max_words 200
12 | def body_max_words, do: @body_max_words
13 |
14 | @title_max_chars 50
15 | def title_max_chars, do: @title_max_chars
16 |
17 | @required_params ~w(body channel_id developer_id title)a
18 | @permitted_params @required_params ++ ~w(developer_id likes max_likes)a
19 |
20 | schema "posts" do
21 | field(:title, :string)
22 | field(:body, :string)
23 | field(:slug, :string)
24 | field(:likes, :integer, default: 1)
25 | field(:max_likes, :integer, default: 1)
26 | field(:tweeted_at, :utc_datetime)
27 |
28 | belongs_to(:channel, Channel)
29 | belongs_to(:developer, Developer)
30 |
31 | timestamps(type: :utc_datetime)
32 | end
33 |
34 | def slugified_title(title) do
35 | title
36 | |> String.downcase()
37 | |> String.replace(~r/[^A-Za-z0-9\s-]/, "")
38 | |> String.replace(~r/(\s|-)+/, "-")
39 | end
40 |
41 | defp validate_length_of_body(changeset) do
42 | body = get_field(changeset, :body)
43 | validate_length_of_body(changeset, body)
44 | end
45 |
46 | defp validate_length_of_body(changeset, nil), do: changeset
47 |
48 | defp validate_length_of_body(changeset, body) do
49 | if length(String.split(body, ~r/\s+/)) > body_max_words() do
50 | add_error(changeset, :body, "should be at most #{body_max_words()} word(s)")
51 | else
52 | changeset
53 | end
54 | end
55 |
56 | def generate_slug do
57 | 16
58 | |> :crypto.strong_rand_bytes()
59 | |> :base64.encode()
60 | |> String.downcase()
61 | |> String.replace(~r/[^a-z0-9]/, "")
62 | |> String.slice(0, 10)
63 | end
64 |
65 | def changeset(post, params \\ %{}) do
66 | post
67 | |> cast(params, @permitted_params)
68 | |> add_slug
69 | |> validate_required(@required_params)
70 | |> validate_length(:title, max: title_max_chars())
71 | |> validate_length_of_body
72 | |> validate_number(:likes, greater_than: 0)
73 | |> foreign_key_constraint(:channel_id)
74 | |> foreign_key_constraint(:developer_id)
75 | end
76 |
77 | defp add_slug(changeset) do
78 | case get_field(changeset, :slug) do
79 | nil ->
80 | generate_slug()
81 | |> (&put_change(changeset, :slug, &1)).()
82 |
83 | _ ->
84 | changeset
85 | end
86 | end
87 |
88 | defimpl Phoenix.Param, for: Post do
89 | def to_param(%{slug: slug, title: title}) do
90 | "#{slug}-#{Post.slugified_title(title)}"
91 | end
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/lib/tilex/blog/post_scrubber.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Blog.PostScrubber do
2 | @moduledoc """
3 | PostScrubber is mostly a copy/pasta from the HtmlSanitizeEx library markdown_html scrubber
4 |
5 | The difference is below under the "additions" comment where we've added some additional customizations
6 |
7 | For more info on customizing scrubbers, see the docs for HtmlSanitizeEx
8 | """
9 |
10 | require HtmlSanitizeEx.Scrubber.Meta
11 | alias HtmlSanitizeEx.Scrubber.Meta
12 |
13 | @valid_schemes ["http", "https", "mailto"]
14 |
15 | # Removes any CDATA tags before the traverser/scrubber runs.
16 | Meta.remove_cdata_sections_before_scrub()
17 |
18 | Meta.strip_comments()
19 |
20 | Meta.allow_tag_with_uri_attributes("a", ["href"], @valid_schemes)
21 | Meta.allow_tag_with_these_attributes("a", ["name", "title"])
22 |
23 | Meta.allow_tag_with_this_attribute_values("a", "target", ["_blank"])
24 |
25 | Meta.allow_tag_with_this_attribute_values("a", "rel", [
26 | "noopener",
27 | "noreferrer"
28 | ])
29 |
30 | Meta.allow_tag_with_these_attributes("b", [])
31 | Meta.allow_tag_with_these_attributes("blockquote", [])
32 | Meta.allow_tag_with_these_attributes("br", [])
33 | Meta.allow_tag_with_these_attributes("code", ["class"])
34 | Meta.allow_tag_with_these_attributes("del", [])
35 | Meta.allow_tag_with_these_attributes("em", [])
36 | Meta.allow_tag_with_these_attributes("h1", [])
37 | Meta.allow_tag_with_these_attributes("h2", [])
38 | Meta.allow_tag_with_these_attributes("h3", [])
39 | Meta.allow_tag_with_these_attributes("h4", [])
40 | Meta.allow_tag_with_these_attributes("h5", [])
41 | Meta.allow_tag_with_these_attributes("h6", [])
42 | Meta.allow_tag_with_these_attributes("hr", [])
43 | Meta.allow_tag_with_these_attributes("i", [])
44 |
45 | Meta.allow_tag_with_uri_attributes("img", ["src"], @valid_schemes)
46 |
47 | Meta.allow_tag_with_these_attributes("img", [
48 | "width",
49 | "height",
50 | "title",
51 | "alt"
52 | ])
53 |
54 | Meta.allow_tag_with_these_attributes("li", [])
55 | Meta.allow_tag_with_these_attributes("ol", [])
56 | Meta.allow_tag_with_these_attributes("p", [])
57 | Meta.allow_tag_with_these_attributes("pre", [])
58 | Meta.allow_tag_with_these_attributes("span", [])
59 | Meta.allow_tag_with_these_attributes("strong", [])
60 | Meta.allow_tag_with_these_attributes("table", [])
61 | Meta.allow_tag_with_these_attributes("tbody", [])
62 | Meta.allow_tag_with_these_attributes("td", [])
63 | Meta.allow_tag_with_these_attributes("th", [])
64 | Meta.allow_tag_with_these_attributes("thead", [])
65 | Meta.allow_tag_with_these_attributes("tr", [])
66 | Meta.allow_tag_with_these_attributes("u", [])
67 | Meta.allow_tag_with_these_attributes("ul", [])
68 |
69 | # Additions
70 | Meta.allow_tag_with_these_attributes("div", [])
71 |
72 | Meta.strip_everything_not_covered()
73 | end
74 |
--------------------------------------------------------------------------------
/lib/tilex/blog/request.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Blog.Request do
2 | use Ecto.Schema
3 |
4 | @type t :: module
5 |
6 | @primary_key false
7 |
8 | schema "requests" do
9 | field(:page, :string)
10 | field(:request_time, :utc_datetime)
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/tilex/cache.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Cache do
2 | def cache(key, value_block) do
3 | :tilex_cache
4 | |> Cachex.get(key)
5 | |> process_cache(key, value_block)
6 | end
7 |
8 | defp process_cache({:ok, nil}, key, value_block) do
9 | value = value_block.()
10 | Cachex.put!(:tilex_cache, key, value)
11 | value
12 | end
13 |
14 | defp process_cache({:ok, value}, _, _), do: value
15 | end
16 |
--------------------------------------------------------------------------------
/lib/tilex/liking.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Liking do
2 | alias Tilex.Notifications
3 | alias Tilex.Blog.Post
4 | alias Tilex.Repo
5 |
6 | def like(slug) do
7 | post = Repo.get_by!(Post, slug: slug)
8 | likes = post.likes + 1
9 | max_likes = Enum.max([likes, post.max_likes])
10 | max_likes_changed = max_likes != post.max_likes
11 | like_changes = %{likes: likes, max_likes: max_likes}
12 | changeset = Post.changeset(post, like_changes)
13 | post = Repo.update!(changeset)
14 |
15 | Notifications.post_liked(post, max_likes_changed)
16 |
17 | likes
18 | end
19 |
20 | def unlike(slug) do
21 | post = Repo.get_by!(Post, slug: slug)
22 | likes = post.likes - 1
23 | like_changes = %{likes: likes}
24 | changeset = Post.changeset(post, like_changes)
25 | Repo.update!(changeset)
26 | likes
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/tilex/mailer.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Mailer do
2 | use Swoosh.Mailer, otp_app: :tilex
3 | end
4 |
--------------------------------------------------------------------------------
/lib/tilex/markdown.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Markdown do
2 | alias Tilex.Cache
3 | alias Tilex.Blog.PostScrubber
4 | alias HtmlSanitizeEx.Scrubber
5 |
6 | @earmark_options %Earmark.Options{
7 | code_class_prefix: "language-",
8 | escape: true,
9 | pure_links: false,
10 | smartypants: false
11 | }
12 |
13 | @content_earmark_options %Earmark.Options{
14 | code_class_prefix: "language-",
15 | escape: false,
16 | pure_links: false,
17 | smartypants: false
18 | }
19 |
20 | def to_html_live(markdown) do
21 | markdown
22 | |> Earmark.as_html!(@earmark_options)
23 | |> sanitize_markdown_html()
24 | |> String.trim()
25 | end
26 |
27 | def to_html(markdown), do: Cache.cache(markdown, fn -> to_html_live(markdown) end)
28 |
29 | def to_content(markdown) do
30 | markdown
31 | |> Earmark.as_html!(@content_earmark_options)
32 | |> sanitize_markdown_html()
33 | |> Floki.parse_fragment()
34 | |> case do
35 | {:ok, fragment} -> fragment |> Floki.text() |> String.trim()
36 | _error -> markdown
37 | end
38 | end
39 |
40 | defp sanitize_markdown_html(html), do: Scrubber.scrub(html, PostScrubber)
41 | end
42 |
--------------------------------------------------------------------------------
/lib/tilex/notifications/notifiers/notifier.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Notifications.Notifier do
2 | @moduledoc """
3 | Notifier implements callbacks for different system
4 | notifications.
5 |
6 | Implement your own notifier using this module and overriding
7 | the appropriate handlers.
8 |
9 | ## Example:
10 | defmodule Tilex.Notifications.Notifiers.Facebook do
11 | use Tilex.Notifications.Notifier
12 |
13 | def handle_post_created(post, developer, channel, url) do
14 | # send notification about created post to facebook
15 | end
16 |
17 | def handle_post_liked(post, developer, url) do
18 | # send notification about liked post to facebook
19 | end
20 | end
21 |
22 | Then add your new notifier to
23 | Tilex.Notifications.NotifiersSupervisor.children/1
24 | """
25 |
26 | alias Tilex.Blog.Post
27 | alias Tilex.Blog.Developer
28 | alias Tilex.Blog.Channel
29 |
30 | @callback handle_post_created(Post.t(), Developer.t(), Channel.t(), url :: String.t()) :: any
31 | @callback handle_post_liked(Post.t(), Developer.t(), url :: String.t()) :: any
32 | @callback handle_page_views_report(String.t()) :: any
33 |
34 | defmacro __using__(_) do
35 | quote location: :keep do
36 | use GenServer
37 | @behaviour Tilex.Notifications.Notifier
38 |
39 | ## Client API
40 |
41 | def start_link(_) do
42 | GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
43 | end
44 |
45 | def post_created(post, developer, channel, url) do
46 | GenServer.cast(__MODULE__, {:post_created, post, developer, channel, url})
47 | end
48 |
49 | def post_liked(post, developer, url) do
50 | GenServer.cast(__MODULE__, {:post_liked, post, developer, url})
51 | end
52 |
53 | def page_views_report(report) do
54 | GenServer.call(__MODULE__, {:page_views_report, report})
55 | end
56 |
57 | ### Server Callbacks
58 |
59 | def init(state) do
60 | {:ok, state}
61 | end
62 |
63 | def handle_cast({:post_created, post, developer, channel, url}, state) do
64 | handle_post_created(post, developer, channel, url)
65 | {:noreply, state}
66 | end
67 |
68 | def handle_cast({:post_liked, post, developer, url}, state) do
69 | handle_post_liked(post, developer, url)
70 | {:noreply, state}
71 | end
72 |
73 | def handle_call({:page_views_report, report}, from, state) do
74 | handle_page_views_report(report)
75 | {:reply, from, state}
76 | end
77 | end
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/lib/tilex/notifications/notifiers/slack.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Notifications.Notifiers.Slack do
2 | use Tilex.Notifications.Notifier
3 |
4 | alias Tilex.Blog.Post
5 |
6 | @emoji ~w(
7 | :tada:
8 | :birthday:
9 | :sparkles:
10 | :boom:
11 | :hearts:
12 | :balloon:
13 | :crown:
14 | :mortar_board:
15 | :trophy:
16 | :100:
17 | :fire:
18 | :zap:
19 | :rocket:
20 | :saxophone:
21 | :mega:
22 | :crystal_ball:
23 | :beers:
24 | :revolving_hearts:
25 | :heart_eyes_cat:
26 | :scream_cat:
27 | )
28 |
29 | def handle_post_created(post, developer, channel, url, http \\ :httpc) do
30 | "#{developer.username} created a new post #{link(url, post.title)} in ##{channel.name}"
31 | |> send_slack_message(http)
32 | end
33 |
34 | def handle_post_liked(%Post{max_likes: max_likes, title: title}, developer, url, http \\ :httpc) do
35 | appropriate_emoji = Enum.at(@emoji, round(max_likes / 10 - 1), ":smile:")
36 |
37 | "#{developer.username}'s post has #{max_likes} likes! #{appropriate_emoji} - #{link(url, title)}"
38 | |> send_slack_message(http)
39 | end
40 |
41 | def handle_page_views_report(report, http \\ :httpc) do
42 | send_slack_message(report, http)
43 | end
44 |
45 | defp send_slack_message(message, http) do
46 | message = String.replace(message, "\"", "'")
47 | endpoint = slack_endpoint() |> String.to_charlist()
48 | request = {endpoint, [], ~c"application/json", "{\"text\": \"#{message}\"}"}
49 | http.request(:post, request, [], [])
50 | end
51 |
52 | defp link(url, text), do: "<#{url}|#{text}>"
53 |
54 | defp slack_endpoint, do: Application.get_env(:tilex, :slack_endpoint)
55 | end
56 |
--------------------------------------------------------------------------------
/lib/tilex/notifications/notifiers/twitter.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Notifications.Notifiers.Twitter do
2 | alias OAuther
3 | alias Tilex.Blog.Developer
4 |
5 | use Tilex.Notifications.Notifier
6 |
7 | @tweets_url "https://api.x.com/2/tweets"
8 |
9 | def handle_post_created(post, developer, channel, url) do
10 | "#{post.title} #{url} via @#{Developer.twitter_handle(developer)} #til ##{channel.twitter_hashtag}"
11 | |> send_tweet
12 | end
13 |
14 | def handle_post_liked(_post, _dev, _url) do
15 | :ok
16 | end
17 |
18 | def handle_page_views_report(_report) do
19 | :ok
20 | end
21 |
22 | def send_tweet(message) do
23 | params = %{
24 | "text" => message
25 | }
26 |
27 | headers =
28 | oauth_headers("post", @tweets_url)
29 |
30 | Req.post!(@tweets_url, headers: headers, json: params)
31 | end
32 |
33 | defp oauth_headers(method, url) do
34 | {auth_header, _params} =
35 | OAuther.sign(method, url, [], oauth_creds())
36 | |> OAuther.header()
37 |
38 | [auth_header]
39 | end
40 |
41 | def oauth_creds do
42 | credentials = Application.get_env(:tilex, __MODULE__)
43 |
44 | OAuther.credentials(credentials ++ [method: :hmac_sha1])
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/lib/tilex/notifications/notifiers_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Notifications.NotifiersSupervisor do
2 | use Supervisor
3 |
4 | def start_link([]) do
5 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__)
6 | end
7 |
8 | def init(_) do
9 | Supervisor.init(children(), strategy: :one_for_one)
10 | end
11 |
12 | def children do
13 | [
14 | slack_notifier(),
15 | twitter_notifier()
16 | ]
17 | end
18 |
19 | defp slack_notifier, do: Application.get_env(:tilex, :slack_notifier)
20 | defp twitter_notifier, do: Application.get_env(:tilex, :twitter_notifier)
21 | end
22 |
--------------------------------------------------------------------------------
/lib/tilex/page_views_report.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.PageViewsReport do
2 | def report(repo \\ Tilex.Repo) do
3 | report_sql = """
4 | select
5 | count(*),
6 | date_trunc('day', request_time at time zone $1),
7 | 'day' as period from requests
8 | where request_time at time zone $1
9 | between date_trunc('week', now() at time zone $1) - '2 weeks'::interval
10 | and date_trunc('week', now() at time zone $1)
11 | group by date_trunc('day', request_time at time zone $1)
12 | union
13 | select
14 | count(*),
15 | date_trunc('week', request_time at time zone $1),
16 | 'week' as period from requests
17 | where request_time at time zone $1
18 | between date_trunc('week', now() at time zone $1) - '2 weeks'::interval
19 | and date_trunc('week', now() at time zone $1)
20 | group by date_trunc('week', request_time at time zone $1)
21 | order by date_trunc desc;
22 | """
23 |
24 | application_timezone =
25 | Application.get_env(:tilex, :date_display_tz, "america/chicago")
26 | |> String.downcase()
27 |
28 | {:ok, result} = repo.query(report_sql, [application_timezone], log: false)
29 |
30 | create_report(result.rows)
31 | end
32 |
33 | defp create_report([]), do: {:norows, ""}
34 |
35 | defp create_report(rows) do
36 | last_week_row =
37 | rows
38 | |> Enum.find(&match?([_, _, "week"], &1))
39 |
40 | best_day_last_week =
41 | rows
42 | |> Stream.filter(&match?([_, _, "day"], &1))
43 | |> Stream.filter(fn [_, d, _] ->
44 | Timex.compare(d, Enum.at(last_week_row, 1)) == 1 or
45 | Timex.compare(d, Enum.at(last_week_row, 1)) == 0
46 | end)
47 | |> Enum.sort_by(fn [c | _] -> c end)
48 | |> Enum.reverse()
49 | |> List.first()
50 |
51 | previous_week_row =
52 | rows
53 | |> Stream.filter(&match?([_, _, "week"], &1))
54 | |> Enum.reverse()
55 | |> List.first()
56 |
57 | best_day_previous_week =
58 | rows
59 | |> Stream.filter(&match?([_, _, "day"], &1))
60 | |> Stream.filter(fn [_, d, _] ->
61 | (Timex.compare(d, Enum.at(previous_week_row, 1)) == 1 or
62 | Timex.compare(d, Enum.at(previous_week_row, 1)) == 0) and
63 | Timex.compare(d, Enum.at(last_week_row, 1)) == -1
64 | end)
65 | |> Enum.sort_by(fn [c | _] -> c end)
66 | |> Enum.reverse()
67 | |> List.first()
68 |
69 | report = """
70 | Best Day Last Week: #{day_output(best_day_last_week)}
71 | Last Week: #{day_output(last_week_row)}
72 | Best Day Week Before: #{day_output(best_day_previous_week)}
73 | Week Before: #{day_output(previous_week_row)}
74 | """
75 |
76 | {:ok, report}
77 | end
78 |
79 | defp day_output([count, date, _period]) do
80 | "#{count |> to_string |> String.pad_leading(10, " ")} #{format(date)}"
81 | end
82 |
83 | defp day_output(nil) do
84 | String.pad_leading("No data available", 10)
85 | end
86 |
87 | defp format(date) do
88 | Timex.format!(date, "%a %m/%d", :strftime)
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/lib/tilex/pageable.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Pageable do
2 | def robust_page(%{"page" => page}) do
3 | case Integer.parse(page) do
4 | {integer, ""} -> integer
5 | _ -> 1
6 | end
7 | end
8 |
9 | def robust_page(_params), do: 1
10 | end
11 |
--------------------------------------------------------------------------------
/lib/tilex/plug/format_injector.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Plug.FormatInjector do
2 | import Phoenix.Controller, only: [put_format: 2]
3 |
4 | def init(default), do: default
5 |
6 | def call(%{params: %{"titled_slug" => slug}} = conn, _) do
7 | ext = Path.extname(slug)
8 | format = String.trim_leading(ext, ".")
9 | format = if format == "", do: "html", else: format
10 |
11 | conn
12 | |> put_format(format)
13 | end
14 |
15 | def call(conn, _), do: conn
16 | end
17 |
--------------------------------------------------------------------------------
/lib/tilex/plug/rate_limiter.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Plug.RateLimiter do
2 | import Plug.Conn
3 | import Phoenix.Controller, only: [text: 2]
4 |
5 | alias Tilex.RateLimiter
6 |
7 | def init(default), do: default
8 |
9 | def call(conn, _default) do
10 | if RateLimiter.check(ip: conn.remote_ip) do
11 | conn
12 | else
13 | conn
14 | |> put_status(:too_many_requests)
15 | |> text("Don't mess with the space cowboys")
16 | |> halt()
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/tilex/plug/request_rejector.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Plug.RequestRejector do
2 | @rejected_paths [
3 | ~r/\.php$/
4 | ]
5 |
6 | def init([]), do: []
7 |
8 | def call(%Plug.Conn{request_path: path} = conn, _default) do
9 | if Enum.any?(@rejected_paths, &match_rejected_path(path, &1)) do
10 | TilexWeb.ErrorView.render_error_page(conn, 404)
11 | else
12 | conn
13 | end
14 | end
15 |
16 | defp match_rejected_path(path, %Regex{} = regex) do
17 | Regex.match?(regex, path)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/tilex/plug/set_canonical_url.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Plug.SetCanonicalUrl do
2 | import Plug.Conn, only: [assign: 3]
3 |
4 | def init([]), do: []
5 |
6 | def call(%Plug.Conn{request_path: path} = conn, _default) do
7 | canonical_url =
8 | :tilex
9 | |> Application.get_env(:canonical_domain)
10 | |> URI.merge(path)
11 | |> URI.to_string()
12 |
13 | assign(conn, :canonical_url, canonical_url)
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/tilex/query_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.QueryHelpers do
2 | defmacro between(field, left_bound, right_bound) do
3 | quote do
4 | fragment(
5 | "? between ? and ?",
6 | unquote(field),
7 | unquote(left_bound),
8 | unquote(right_bound)
9 | )
10 | end
11 | end
12 |
13 | defmacro matches?(field, regex) do
14 | quote do
15 | fragment("? ~ ?", unquote(field), unquote(regex))
16 | end
17 | end
18 |
19 | defmacro greatest(value1, value2) do
20 | quote do
21 | fragment("greatest(?, ?)", unquote(value1), unquote(value2))
22 | end
23 | end
24 |
25 | defmacro hours_since(timestamp) do
26 | quote do
27 | fragment(
28 | "extract(epoch from (current_timestamp - ?)) / 3600",
29 | unquote(timestamp)
30 | )
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/tilex/rate_limiter.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.RateLimiter do
2 | use GenServer
3 |
4 | @table_name :rate_limiter_lookup
5 |
6 | def start_link([]) do
7 | GenServer.start_link(__MODULE__, [], name: __MODULE__)
8 | end
9 |
10 | @spec check(ip: String.t()) :: boolean()
11 | def check(ip: ip) do
12 | request_times = lookup_ip(ip)
13 | current_time = date_time_module().utc_now()
14 | recent_request_times = filter_recent_request_times(current_time, request_times)
15 | current_requests = [current_time | recent_request_times]
16 |
17 | :ets.insert(@table_name, {ip, current_requests})
18 |
19 | length(current_requests) <= limit()
20 | end
21 |
22 | @impl true
23 | def init(_) do
24 | create_table()
25 | schedule_cleanup()
26 | {:ok, %{}}
27 | end
28 |
29 | @impl true
30 | def handle_info(:cleanup, state) do
31 | :ets.delete_all_objects(@table_name)
32 | schedule_cleanup()
33 | {:noreply, state}
34 | end
35 |
36 | defp create_table do
37 | :ets.new(@table_name, [
38 | :set,
39 | :public,
40 | :named_table,
41 | read_concurrency: true,
42 | write_concurrency: true
43 | ])
44 | end
45 |
46 | defp lookup_ip(ip) do
47 | case :ets.lookup(@table_name, ip) do
48 | [{^ip, requests}] -> requests
49 | _ -> []
50 | end
51 | end
52 |
53 | defp filter_recent_request_times(current_time, request_times) do
54 | end_time = Timex.subtract(current_time, time_period_minutes())
55 |
56 | Enum.filter(request_times, fn time ->
57 | Timex.between?(time, end_time, current_time, inclusive: true)
58 | end)
59 | end
60 |
61 | defp schedule_cleanup do
62 | Process.send_after(self(), :cleanup, cleanup_interval())
63 | end
64 |
65 | defp date_time_module(), do: Application.get_env(:tilex, :date_time_module)
66 |
67 | defp limit(), do: Application.get_env(:tilex, :rate_limiter_requests_per_time_period)
68 |
69 | defp cleanup_interval(), do: Application.get_env(:tilex, :rate_limiter_cleanup_interval)
70 |
71 | defp time_period_minutes() do
72 | :tilex
73 | |> Application.get_env(:rate_limiter_time_period_minutes)
74 | |> Timex.Duration.from_minutes()
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/lib/tilex/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo do
2 | use Ecto.Repo,
3 | otp_app: :tilex,
4 | adapter: Ecto.Adapters.Postgres
5 | end
6 |
--------------------------------------------------------------------------------
/lib/tilex/tracking.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Tracking do
2 | alias Tilex.Repo
3 | alias Tilex.Blog.Request
4 | alias TilexWeb.Endpoint
5 |
6 | import Ecto.Query, only: [from: 2, subquery: 1]
7 | import Tilex.QueryHelpers, only: [between: 3, matches?: 2]
8 |
9 | def track(conn) do
10 | spawn(fn ->
11 | if request_tracking() do
12 | with [referer | _] <- Plug.Conn.get_req_header(conn, "referer") do
13 | page = String.replace(referer, Endpoint.url(), "")
14 | Ecto.Adapters.SQL.query!(Repo, "insert into requests (page) values ($1);", [page])
15 | end
16 | end
17 | end)
18 | end
19 |
20 | def most_viewed_posts(start_date, end_date) do
21 | requests =
22 | from(
23 | req in Request,
24 | group_by: req.page,
25 | where: matches?(req.page, "/posts/"),
26 | where: not matches?(req.page, "/posts/.+/edit$"),
27 | where: between(req.request_time, ^start_date, ^end_date),
28 | order_by: [desc: count(req.page)],
29 | select: %{
30 | url: req.page,
31 | view_count: count(req.page),
32 | url_slug: fragment("substring(page from ?)", "/posts/(.*?)-")
33 | }
34 | )
35 |
36 | query =
37 | from(
38 | req in subquery(requests),
39 | join: post in Tilex.Blog.Post,
40 | on: [slug: req.url_slug],
41 | join: channel in Tilex.Blog.Channel,
42 | on: [id: post.channel_id],
43 | order_by: [desc: req.view_count],
44 | limit: 10,
45 | select: %{
46 | title: post.title,
47 | url: req.url,
48 | view_count: req.view_count,
49 | channel_name: channel.name
50 | }
51 | )
52 |
53 | Repo.all(query)
54 | end
55 |
56 | def total_page_views(start_date, end_date) do
57 | query =
58 | from(
59 | req in Request,
60 | where: between(req.request_time, ^start_date, ^end_date),
61 | select: count(req.page)
62 | )
63 |
64 | Repo.one(query)
65 | end
66 |
67 | defp request_tracking(), do: Application.get_env(:tilex, :request_tracking, false)
68 | end
69 |
--------------------------------------------------------------------------------
/lib/tilex_web.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb 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 TilexWeb, :controller
9 | use TilexWeb, :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: TilexWeb
23 |
24 | import Plug.Conn
25 | import TilexWeb.Gettext
26 | alias TilexWeb.Router.Helpers, as: Routes
27 |
28 | alias Tilex.Repo
29 | import Ecto
30 | import Ecto.Query
31 | end
32 | end
33 |
34 | def view do
35 | quote do
36 | use Phoenix.View,
37 | root: "lib/tilex_web/templates",
38 | namespace: TilexWeb
39 |
40 | # Import convenience functions from controllers
41 | import Phoenix.Controller,
42 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
43 |
44 | # Include shared imports and aliases for views
45 | unquote(view_helpers())
46 | end
47 | end
48 |
49 | def live_view do
50 | quote do
51 | use Phoenix.LiveView,
52 | layout: {TilexWeb.LayoutView, "live.html"}
53 |
54 | unquote(view_helpers())
55 | end
56 | end
57 |
58 | def live_component do
59 | quote do
60 | use Phoenix.LiveComponent
61 |
62 | unquote(view_helpers())
63 | end
64 | end
65 |
66 | def component do
67 | quote do
68 | use Phoenix.Component
69 |
70 | unquote(view_helpers())
71 | end
72 | end
73 |
74 | def router do
75 | quote do
76 | use Phoenix.Router
77 |
78 | import Plug.Conn
79 | import Phoenix.Controller
80 | import Phoenix.LiveView.Router
81 | end
82 | end
83 |
84 | def channel do
85 | quote do
86 | use Phoenix.Channel
87 | import TilexWeb.Gettext
88 | end
89 | end
90 |
91 | defp view_helpers do
92 | quote do
93 | # Use all HTML functionality (forms, tags, etc)
94 | use Phoenix.HTML
95 |
96 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
97 | import Phoenix.LiveView.Helpers
98 |
99 | # Import basic rendering functionality (render, render_layout, etc)
100 | import Phoenix.View
101 |
102 | import TilexWeb.ErrorHelpers
103 | import TilexWeb.Gettext
104 | alias TilexWeb.Router.Helpers, as: Routes
105 |
106 | use Appsignal.Phoenix.View
107 |
108 | import TilexWeb.HTMLHelpers
109 | import TilexWeb.Icon
110 | end
111 | end
112 |
113 | @doc """
114 | When used, dispatch to the appropriate controller/view/etc.
115 | """
116 | defmacro __using__(which) when is_atom(which) do
117 | apply(__MODULE__, which, [])
118 | end
119 | end
120 |
--------------------------------------------------------------------------------
/lib/tilex_web/channels/text_converter_channel.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.TextConverterChannel do
2 | use Phoenix.Channel
3 | use Appsignal.Instrumentation.Decorators
4 |
5 | alias Tilex.Markdown
6 |
7 | def join("text_converter", _message, socket) do
8 | {:ok, socket}
9 | end
10 |
11 | @decorate channel_action()
12 | def handle_in("convert", %{"markdown" => markdown}, socket) do
13 | html = Markdown.to_html_live(markdown)
14 |
15 | push(socket, "converted", %{html: html})
16 | {:noreply, socket}
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/tilex_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | channel("post:search", Tilex.Blog.PostChannel)
6 | channel("text_converter", Tilex.TextConverterChannel)
7 |
8 | # Socket params are passed from the client and can
9 | # be used to verify and authenticate a user. After
10 | # verification, you can put default assigns into
11 | # the socket that will be set for all channels, ie
12 | #
13 | # {:ok, assign(socket, :user_id, verified_user_id)}
14 | #
15 | # To deny connection, return `:error`.
16 | #
17 | # See `Phoenix.Token` documentation for examples in
18 | # performing token verification on connect.
19 | def connect(_params, socket) do
20 | {:ok, socket}
21 | end
22 |
23 | # Socket id's are topics that allow you to identify all sockets for a given
24 | # user:
25 | #
26 | # def id(socket), do: "users_socket:#{socket.assigns.user_id}"
27 | #
28 | # Would allow you to broadcast a "disconnect" event and terminate
29 | # all active sockets and channels for a given user:
30 | #
31 | # TilexWeb.Endpoint.broadcast("users_socket:#{user.id}", "disconnect",
32 | # %{})
33 | #
34 | # Returning `nil` makes this socket anonymous.
35 | def id(_socket), do: nil
36 | end
37 |
--------------------------------------------------------------------------------
/lib/tilex_web/controllers/api/developer_post_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.Api.DeveloperPostController do
2 | @moduledoc """
3 | This module implements a public API for querying Tilex data.
4 | """
5 |
6 | use TilexWeb, :controller
7 | alias Tilex.Posts
8 |
9 | @doc """
10 | This function allows external requesters to retrieve a developer's three most
11 | recent posts.
12 | """
13 | def index(conn, params) do
14 | posts = Posts.by_developer(params["username"], limit: 3)
15 |
16 | render(conn, "index.json", posts: posts)
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/tilex_web/controllers/api/post_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.Api.PostController do
2 | @moduledoc "
3 | This module implements a public API for querying the Tilex feed
4 | "
5 |
6 | use TilexWeb, :controller
7 | import Tilex.Pageable
8 | alias Tilex.Posts
9 |
10 | @doc """
11 | This functions allows external requesters to retrieve the feed of til in json format
12 | """
13 | def index(conn, params) do
14 | page = robust_page(params)
15 | posts = Posts.all(page)
16 |
17 | render(conn, "index.json", posts: posts)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/tilex_web/controllers/auth_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.AuthController do
2 | use TilexWeb, :controller
3 | plug(Ueberauth)
4 |
5 | alias Tilex.Blog.Developer
6 | alias Tilex.Repo
7 | alias Tilex.Auth
8 |
9 | def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
10 | case authenticate(auth) do
11 | {:ok, developer} ->
12 | conn = Auth.Guardian.Plug.sign_in(conn, developer)
13 |
14 | conn
15 | |> put_flash(:info, "Signed in with #{developer.email}")
16 | |> redirect(to: Routes.post_path(conn, :index))
17 |
18 | {:error, reason} ->
19 | conn
20 | |> put_flash(:info, reason)
21 | |> redirect(to: Routes.post_path(conn, :index))
22 | end
23 | end
24 |
25 | def index(conn, _params) do
26 | redirect(conn, to: Routes.auth_path(conn, :request, "google"))
27 | end
28 |
29 | def delete(conn, _params) do
30 | conn
31 | |> Auth.Guardian.Plug.sign_out()
32 | |> put_flash(:info, "Signed out")
33 | |> redirect(to: Routes.post_path(conn, :index))
34 | end
35 |
36 | defp authenticate(%{info: %{email: email, name: name}}) when is_binary(name) do
37 | case authorized(email) do
38 | {:ok, email} ->
39 | attrs = %{
40 | email: email,
41 | username: Developer.format_username(name)
42 | }
43 |
44 | Developer.find_or_create(Repo, attrs)
45 |
46 | _ ->
47 | {:error, "#{email} is not a valid email address"}
48 | end
49 | end
50 |
51 | defp authenticate(_), do: {:error, "oauth2 profile is missing a valid name"}
52 |
53 | defp authorized(email) do
54 | cond do
55 | String.match?(email, ~r/@#{hosted_domain()}$/) -> {:ok, email}
56 | email in guest_allowlist() -> {:ok, email}
57 | true -> {:error, email}
58 | end
59 | end
60 |
61 | defp hosted_domain, do: Application.get_env(:tilex, :hosted_domain)
62 |
63 | defp guest_allowlist do
64 | with emails when is_binary(emails) <- Application.get_env(:tilex, :guest_author_allowlist),
65 | allowlist <- String.split(emails, [",", " "], trim: true) do
66 | allowlist
67 | else
68 | _ -> []
69 | end
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/tilex_web/controllers/channel_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.ChannelController do
2 | use TilexWeb, :controller
3 | import Tilex.Pageable
4 | alias Tilex.Posts
5 |
6 | def show(conn, %{"name" => channel_name} = params) do
7 | page = robust_page(params)
8 | {posts, posts_count, channel} = Posts.by_channel(channel_name, page)
9 |
10 | conn =
11 | case page do
12 | 1 -> conn
13 | _ -> assign(conn, :meta_robots, "noindex")
14 | end
15 |
16 | render(
17 | conn,
18 | "show.html",
19 | posts: posts,
20 | posts_count: posts_count,
21 | channel: channel,
22 | page: page,
23 | random: false
24 | )
25 | end
26 |
27 | def random_by_channel(conn, %{"channel" => channel_name}) do
28 | {posts, posts_count, channel} = Posts.random_post_by_channel(channel_name)
29 |
30 | conn
31 | |> assign(:meta_robots, "noindex")
32 | |> render(
33 | "show.html",
34 | posts: posts,
35 | posts_count: posts_count,
36 | channel: channel,
37 | page: 1,
38 | random: true
39 | )
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/tilex_web/controllers/developer_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.DeveloperController do
2 | use TilexWeb, :controller
3 | import Tilex.Pageable
4 | alias Tilex.Blog.Developer
5 | alias Tilex.Posts
6 | alias Tilex.Repo
7 | alias Tilex.Auth
8 |
9 | def show(conn, %{"name" => username} = params) do
10 | page = robust_page(params)
11 | {posts, posts_count, developer} = Posts.by_developer(username, page)
12 |
13 | conn
14 | |> assign(:meta_robots, "noindex")
15 | |> render(
16 | "show.html",
17 | posts: posts,
18 | posts_count: posts_count,
19 | developer: developer,
20 | page: page
21 | )
22 | end
23 |
24 | def edit(conn, _params) do
25 | developer = Auth.Guardian.Plug.current_resource(conn)
26 | changeset = Developer.changeset(developer)
27 |
28 | render(conn, "edit.html", developer: developer, changeset: changeset)
29 | end
30 |
31 | def update(conn, %{"developer" => developer_params}) do
32 | developer = Auth.Guardian.Plug.current_resource(conn)
33 |
34 | changeset = Developer.changeset(developer, developer_params)
35 |
36 | case Repo.update(changeset) do
37 | {:ok, _developer} ->
38 | conn
39 | |> put_flash(:info, "Developer Updated")
40 | |> redirect(to: Routes.post_path(conn, :index))
41 |
42 | {:error, changeset} ->
43 | render(conn, "edit.html", developer: developer, changeset: changeset)
44 | end
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/lib/tilex_web/controllers/feed_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.FeedController do
2 | use TilexWeb, :controller
3 |
4 | def index(conn, _params) do
5 | posts =
6 | Repo.all(
7 | from(
8 | p in Tilex.Blog.Post,
9 | order_by: [desc: p.inserted_at],
10 | preload: [:channel, :developer],
11 | limit: 25
12 | )
13 | )
14 |
15 | conn
16 | |> put_resp_content_type("application/xml")
17 | |> render("index.xml", items: posts)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/tilex_web/controllers/pixel_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.PixelController do
2 | use TilexWeb, :controller
3 |
4 | def index(conn, _) do
5 | Tilex.Tracking.track(conn)
6 |
7 | conn
8 | |> put_resp_header("cache-control", "no-cache, no-store, must-revalidate")
9 | |> put_resp_content_type("application/javascript")
10 | |> send_resp(:ok, "")
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/tilex_web/controllers/sitemap_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.SitemapController do
2 | use TilexWeb, :controller
3 |
4 | def index(conn, _) do
5 | conn
6 | |> assign(:posts, Repo.all(Tilex.Blog.Post))
7 | |> assign(:channels, Repo.all(Tilex.Blog.Channel))
8 | |> put_layout(false)
9 | |> render("sitemap.xml")
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/tilex_web/controllers/stats_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.StatsController do
2 | use TilexWeb, :controller
3 |
4 | alias Tilex.Stats
5 |
6 | plug(
7 | Guardian.Plug.EnsureAuthenticated,
8 | [error_handler: __MODULE__]
9 | when action in ~w(developer)a
10 | )
11 |
12 | @behaviour Guardian.Plug.ErrorHandler
13 |
14 | @impl Guardian.Plug.ErrorHandler
15 | def auth_error(conn, {_failure_type, _reason}, _opts) do
16 | conn
17 | |> put_status(302)
18 | |> put_flash(:info, "Authentication required")
19 | |> redirect(to: Routes.stats_path(conn, :index))
20 | end
21 |
22 | def index(conn, _) do
23 | conn
24 | |> assign(:page_title, "Statistics")
25 | |> assign(:meta_robots, "noindex")
26 | |> render("index.html", Stats.all())
27 | end
28 |
29 | def developer(conn, params) do
30 | conn
31 | |> assign(:page_title, "Admin Statistics")
32 | |> render(
33 | "developer.html",
34 | params
35 | |> Map.get("filter")
36 | |> developer_params()
37 | |> Stats.developer()
38 | )
39 | end
40 |
41 | defp developer_params(params) do
42 | %{
43 | start_date: date_param(params, "start_date", Date.add(Date.utc_today(), -7)),
44 | end_date: date_param(params, "end_date", Date.utc_today())
45 | }
46 | end
47 |
48 | defp date_param(nil, field, default), do: date_param(%{}, field, default)
49 |
50 | defp date_param(params, field, default) do
51 | case Map.get(params, field) do
52 | date when date in [nil, ""] -> default
53 | date -> Timex.parse!(date, "{YYYY}-{M}-{D}")
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/tilex_web/controllers/test/auth_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.Test.AuthController do
2 | use TilexWeb, :controller
3 |
4 | alias Tilex.Blog.Developer
5 | alias Tilex.Repo
6 | alias Tilex.Auth
7 |
8 | def index(conn, params) do
9 | developer = Repo.get_by!(Developer, id: params["id"])
10 | conn = Auth.Guardian.Plug.sign_in(conn, developer)
11 |
12 | redirect(conn, to: Routes.post_path(conn, :index))
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/tilex_web/controllers/web_manifest_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.WebManifestController do
2 | use TilexWeb, :controller
3 |
4 | def index(conn, _) do
5 | conn
6 | |> assign(:organization_name, Application.get_env(:tilex, :organization_name))
7 | |> render("manifest.json")
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/tilex_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :tilex
3 | use Appsignal.Phoenix
4 |
5 | if sandbox = Application.compile_env(:tilex, :sandbox) do
6 | plug(Phoenix.Ecto.SQL.Sandbox, sandbox: sandbox)
7 | end
8 |
9 | socket("/socket", TilexWeb.UserSocket, websocket: true)
10 |
11 | @cors_origin Application.compile_env(:tilex, :cors_origin)
12 |
13 | if @cors_origin do
14 | origin = String.split(@cors_origin, [",", " "], trim: true)
15 | plug(CORSPlug, origin: origin)
16 | end
17 |
18 | # The session will be stored in the cookie and signed,
19 | # this means its contents can be read but not tampered with.
20 | # Set :encryption_salt if you would also like to encrypt it.
21 | @session_options [
22 | store: :cookie,
23 | key: "_tilex_key",
24 | signing_salt: "PSNTTaPr"
25 | ]
26 |
27 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
28 |
29 | # Serve at "/" the static files from "priv/static" directory.
30 | #
31 | # You should set gzip to true if you are running phx.digest
32 | # when deploying your static files in production.
33 | plug(
34 | Plug.Static,
35 | at: "/",
36 | from: :tilex,
37 | gzip: true,
38 | headers: [{"access-control-allow-origin", "*"}],
39 | only: ~w(
40 | assets
41 | apple-touch-icon-120x120.png
42 | apple-touch-icon.png
43 | apple-touch-icon-precomposed.png
44 | css
45 | favicon.ico
46 | favicon.png
47 | fonts
48 | images
49 | js
50 | robots.txt
51 | )
52 | )
53 |
54 | # Code reloading can be explicitly enabled under the
55 | # :code_reloader configuration of your endpoint.
56 | if code_reloading? do
57 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
58 | plug Phoenix.LiveReloader
59 | plug Phoenix.CodeReloader
60 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :tilex
61 | end
62 |
63 | plug Phoenix.LiveDashboard.RequestLogger,
64 | param_key: "request_logger",
65 | cookie_key: "request_logger"
66 |
67 | plug Plug.RequestId
68 | plug Plug.Logger
69 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
70 |
71 | plug(
72 | Plug.Parsers,
73 | parsers: [:urlencoded, :multipart, :json],
74 | pass: ["*/*"],
75 | json_decoder: Phoenix.json_library()
76 | )
77 |
78 | plug Plug.MethodOverride
79 | plug Plug.Head
80 | plug Plug.Session, @session_options
81 | plug TilexWeb.Router
82 | end
83 |
--------------------------------------------------------------------------------
/lib/tilex_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.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 TilexWeb.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :tilex
24 | end
25 |
--------------------------------------------------------------------------------
/lib/tilex_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.stop.duration",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.router_dispatch.stop.duration",
29 | tags: [:route],
30 | unit: {:native, :millisecond}
31 | ),
32 |
33 | # Database Metrics
34 | summary("tilex.repo.query.total_time",
35 | unit: {:native, :millisecond},
36 | description: "The sum of the other measurements"
37 | ),
38 | summary("tilex.repo.query.decode_time",
39 | unit: {:native, :millisecond},
40 | description: "The time spent decoding the data received from the database"
41 | ),
42 | summary("tilex.repo.query.query_time",
43 | unit: {:native, :millisecond},
44 | description: "The time spent executing the query"
45 | ),
46 | summary("tilex.repo.query.queue_time",
47 | unit: {:native, :millisecond},
48 | description: "The time spent waiting for a database connection"
49 | ),
50 | summary("tilex.repo.query.idle_time",
51 | unit: {:native, :millisecond},
52 | description:
53 | "The time the connection spent waiting before being checked out for the query"
54 | ),
55 |
56 | # VM Metrics
57 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
58 | summary("vm.total_run_queue_lengths.total"),
59 | summary("vm.total_run_queue_lengths.cpu"),
60 | summary("vm.total_run_queue_lengths.io")
61 | ]
62 | end
63 |
64 | defp periodic_measurements do
65 | [
66 | # A module, function and arguments to be invoked periodically.
67 | # This function must call :telemetry.execute/3 and a metric must be added above.
68 | # {TilexWeb, :count_users, []}
69 | ]
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/channel/show.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
10 | <%= render TilexWeb.SharedView, "_posts_list.html", conn: @conn, posts: @posts %>
11 |
12 |
13 | <%= render TilexWeb.SharedView, "_pagination.html", conn: @conn, page: @page, posts: @posts %>
14 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/developer/edit.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 | <%= form_for @changeset, Routes.developer_path(@conn, :update), fn f -> %>
7 |
8 |
9 | <%= label f, :email %>
10 | <%= error_tag f, :email %>
11 |
12 |
13 | <%= Guardian.Plug.current_resource(@conn).email %>
14 |
15 |
16 | <%= label f, :twitter_handle %>
17 | <%= error_tag f, :twitter_handle %>
18 |
19 |
20 | <%= text_input f, :twitter_handle, placeholder: "Enter name..." %>
21 |
22 |
23 | <%= label f, :editor %>
24 | <%= error_tag f, :editor %>
25 |
26 |
27 | <%= select f, :editor, ["Text Field", "Vim", "Code Editor"] %>
28 |
29 |
30 | <%= submit "Submit" %>
31 | <%= link("cancel", to: Routes.post_path(@conn, :index)) %>
32 | <% end %>
33 |
34 |
35 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/developer/show.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%= developer_header(@posts_count, @developer) %>
5 | <%= if handle = @developer.twitter_handle do %>
6 |
9 | <% end %>
10 |
11 |
12 | <%= render TilexWeb.SharedView, "_posts_list.html", conn: @conn, posts: @posts %>
13 |
14 |
15 | <%= render TilexWeb.SharedView, "_pagination.html", conn: @conn, page: @page, posts: @posts %>
16 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/error/404.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
…that there is no page at this URL!
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/error/500.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
…that something is broken!
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/feed/index.xml.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Today I Learned
5 | TIL is an open-source project by Hashrocket that exists to catalogue the sharing and accumulation of knowledge as it happens day-to-day.
6 |
7 | <%= Routes.post_url(@conn, :index) %>
8 |
9 | <%= for post <- @items do %>
10 | -
11 |
12 | - #<%= post.channel.name %>]]>
13 |
14 | <%= Routes.post_url(@conn, :show, post) %>
15 |
16 | ]]>
17 |
18 | <%= post.developer.username %>
19 | <%= TilexWeb.SharedView.rss_date(post) %>
20 | <%= Routes.post_url(@conn, :show, post) %>
21 |
22 | <% end %>
23 |
24 |
25 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/layout/app.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= if message = get_flash(@conn, :info) do %>
4 |
<%= message %>
5 | <% end %>
6 |
7 | <%= get_flash(@conn, :error) %>
8 | <%= @inner_content %>
9 |
10 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/layout/live.html.heex:
--------------------------------------------------------------------------------
1 |
2 | <%= live_flash(@flash, :info) %>
5 |
6 | <%= live_flash(@flash, :error) %>
9 |
10 | <%= @inner_content %>
11 |
12 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/layout/site_nav.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
68 |
69 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/post/edit.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
5 | <%= render TilexWeb.PostView, "form.html", channels: @channels, changeset: @changeset, current_user: @current_user, action: Routes.post_path(@conn, :update, @post) %>
6 |
7 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/post/form.html.eex:
--------------------------------------------------------------------------------
1 | <%= form_for @changeset, @action, fn f -> %>
2 |
5 |
6 |
7 | <%= label f, :title %> <%= error_tag f, :title %>
8 |
9 |
10 |
11 | <%= text_input f, :title, placeholder: "Enter title..." %>
12 |
13 |
14 |
15 |
16 | <%= label f, :body %> <%= error_tag f, :body %>
17 |
18 |
19 |
20 | <%= textarea f, :body,
21 | placeholder: "Enter body...",
22 | class: "autosize",
23 | style: "overflow: hidden; word-wrap: break-word; height: 72px;"
24 | %>
25 |
26 |
27 | <%= label f, :channel_id, "Channel" %> <%= error_tag f, :channel_id %>
28 |
29 |
30 | <%= select f, :channel_id, @channels, prompt: "" %>
31 |
32 |
33 | <%= submit "Submit" %>
34 | <%= link("cancel", to: "/") %>
35 | <% end %>
36 |
37 |
38 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | Uploading Image...
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/post/index.html.eex:
--------------------------------------------------------------------------------
1 |
2 | <%= render TilexWeb.SharedView, "_posts_list.html", conn: @conn, posts: @posts %>
3 |
4 |
5 | <%= render TilexWeb.SharedView, "_pagination.html", conn: @conn, page: @page, posts: @posts %>
6 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/post/new.html.eex:
--------------------------------------------------------------------------------
1 |
2 | <%= render TilexWeb.PostView, "form.html", channels: @channels, changeset: @changeset, current_user: @current_user, action: Routes.post_path(@conn, :create) %>
3 |
4 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/post/search_results.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%= TilexWeb.SharedView.pluralize(@posts_count, "post") %>
5 | about
6 | <%= @query %>
7 |
8 |
9 |
10 |
11 | <%= render TilexWeb.PostView, "index.html", conn: @conn, posts: @posts, page: @page %>
12 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/post/show.html.eex:
--------------------------------------------------------------------------------
1 | <%= render TilexWeb.SharedView, "post.html",
2 | conn: @conn,
3 | post: @post,
4 | twitter_shareable: assigns[:twitter_shareable]
5 | %>
6 |
7 |
8 | <%= link(to: Routes.channel_path(@conn, :show, @post.channel.name)) do %>
9 | See More #<%= @post.channel.name %> TILs
10 | <% end %>
11 |
12 |
13 | <%= if TilexWeb.PostView.more_info?(@post.channel) do %>
14 |
15 | <%= render TilexWeb.SharedView, "_#{@post.channel.name}.html" %>
16 |
17 | <% end %>
18 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/post/show.md.eex:
--------------------------------------------------------------------------------
1 | <%= @post.title %>
2 |
3 | <%= @post.body %>
4 |
5 | <%= @post.developer.username %>
6 |
7 | <%= TilexWeb.SharedView.display_date(@post) %>
8 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/shared/_elixir.html.eex:
--------------------------------------------------------------------------------
1 | Looking for help? At Hashrocket , we
2 | 💜 Elixir! From our many Elixir client
3 | projects, to sponsoring the Chicago Elixir
4 | Meetup , to the source code for this application , we are
6 | invested in this community. Contact
7 | us today to talk about your Elixir project.
8 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/shared/_javascript.html.eex:
--------------------------------------------------------------------------------
1 | Looking for help? At Hashrocket , our
2 | JavaScript experts launch scalable, performant apps on the Web, Android and
3 | iOS. Contact us and find out
4 | how we can help you.
5 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/shared/_pagination.html.eex:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/shared/_posts_list.html.eex:
--------------------------------------------------------------------------------
1 | <%= for post <- Enum.take(@posts, Application.get_env(:tilex, :page_size)) do %>
2 | <%= render TilexWeb.SharedView, "post.html", conn: @conn, post: post %>
3 | <% end %>
4 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/shared/_rails.html.eex:
--------------------------------------------------------------------------------
1 | Looking for help?
2 | Hashrocket
3 | has been an industry leader in Ruby on Rails since 2008. Rails is a core skill
4 | for each developer at Hashrocket, and we'd love to take a look at your project.
5 | Contact us
6 | and find out how we can help you.
7 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/shared/_react.html.eex:
--------------------------------------------------------------------------------
1 | Looking for help? At Hashrocket , our
2 | JavaScript experts launch scalable, performant apps on the Web, Android and
3 | iOS. Contact us and find out
4 | how we can help you.
5 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/shared/_ruby.html.eex:
--------------------------------------------------------------------------------
1 | Looking for help? Each developer at Hashrocket
2 | has years of experience working with Ruby applications of all types and sizes.
3 | We're an active presence at Ruby conferences, have written some of the most
4 | popular gems, and have worked on many of the web's Ruby on Rails success
5 | stories. Contact us
6 | today to talk about your Ruby project.
7 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/shared/_sql.html.eex:
--------------------------------------------------------------------------------
1 | Looking for help?
2 | Hashrocket
3 | developers believe that data quality is as important as code quality. We enjoy
4 | all the challenges of relational databases, from finding the fastest index, to
5 | structuring data to fit the needs of an application. We're eager to share our
6 | experiences; check out
7 | PG Casts ,
8 | our series of free PostgreSQL screencasts.
9 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/shared/_vim.html.eex:
--------------------------------------------------------------------------------
1 | Every developer at Hashrocket is a Vim
2 | expert. Check out our development environment, Dotmatrix , and if you are in
4 | Chicago, come to the Vim Chicago
5 | Meetup hosted at our Chicago office.
6 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/shared/post.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | <%= link(@post.title, to: Routes.post_path(@conn, :show, @post)) %>
6 |
7 | <%= raw Tilex.Markdown.to_html(@post.body) %>
8 |
9 | <%= if assigns[:twitter_shareable] && Mix.env != :test do %>
10 |
18 | <% end %>
19 |
20 |
21 |
22 | <%= link(@post.developer.username, to: Routes.developer_path(@conn, :show, @post.developer)) %>
23 |
24 |
25 | <%= link(display_date(@post), to: Routes.post_path(@conn, :show, @post), class: "post__permalink") %>
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | <%= link("##{@post.channel.name}", to: Routes.channel_path(@conn, :show, @post.channel.name), class: "post__tag-link") %>
34 |
35 |
36 | <%= link("permalink", to: Routes.post_path(@conn, :show, @post), class: "post__permalink") %>
37 |
38 |
39 | <%= link("raw", to: "#{Routes.post_path(@conn, :show, @post)}.md", class: "post__raw-link") %>
40 |
41 | <%= if TilexWeb.SharedView.post_creator_or_admin?(@conn, @post) do %>
42 |
43 | <%= link("edit", to: Routes.post_path(@conn, :edit, @post), class: "post__permalink") %>
44 |
45 | <% end %>
46 |
47 | <%= link to: "#", class: "js-like-action post__like-link", id: @post.slug do %>
48 | <%= icon("heart", :small, "likes") %>
49 | <%= @post.likes %>
50 | <% end %>
51 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/lib/tilex_web/templates/sitemap/sitemap.xml.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | https://hashrocket.com/
5 | daily
6 | 1.0
7 |
8 |
9 | <%= for post <- @posts do %>
10 |
11 | <%= Routes.post_url(@conn, :show, post) %>
12 | monthly
13 | 0.8
14 |
15 | <% end %>
16 |
17 | <%= for channel <- @channels do %>
18 |
19 | <%= Routes.channel_url(@conn, :show, channel) %>
20 | daily
21 | 0.4
22 |
23 | <% end %>
24 |
25 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/api/developer_post_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.Api.DeveloperPostView do
2 | use TilexWeb, :view
3 |
4 | def render("index.json", %{posts: posts}) do
5 | %{
6 | data: %{
7 | posts: render_many(posts, TilexWeb.Api.PostView, "post.json")
8 | }
9 | }
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/api/post_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.Api.PostView do
2 | use TilexWeb, :view
3 |
4 | def render("index.json", %{posts: posts}) do
5 | %{
6 | data: %{
7 | posts: render_many(posts, TilexWeb.Api.PostView, "post_with_developer.json")
8 | }
9 | }
10 | end
11 |
12 | def render("post_with_developer.json", %{post: post}) do
13 | %{
14 | slug: post.slug,
15 | title: post.title,
16 | developer_username: post.developer.username,
17 | channel_name: post.channel.name,
18 | inserted_at: post.inserted_at
19 | }
20 | end
21 |
22 | def render("post.json", %{post: post}), do: Map.take(post, [:slug, :title])
23 | end
24 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/channel_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.ChannelView do
2 | use TilexWeb, :view
3 |
4 | alias TilexWeb.SharedView
5 |
6 | def channel_header(posts_count, channel, random) do
7 | " #{if random, do: "Random post", else: SharedView.pluralize(posts_count, "post")} about ##{channel.name}"
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/developer_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.DeveloperView do
2 | use TilexWeb, :view
3 |
4 | alias TilexWeb.SharedView
5 |
6 | def developer_header(posts_count, developer) do
7 | "#{SharedView.pluralize(posts_count, "post")} by #{developer.username}"
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.ErrorHelpers do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | use Phoenix.HTML
7 |
8 | @doc """
9 | Generates tag for inlined form input errors.
10 | """
11 | def error_tag(form, field) do
12 | Enum.map(Keyword.get_values(form.errors, field), fn error ->
13 | content_tag(:span, translate_error(error),
14 | class: "invalid-feedback",
15 | phx_feedback_for: input_name(form, field)
16 | )
17 | end)
18 | end
19 |
20 | @doc """
21 | Translates an error message using gettext.
22 | """
23 | def translate_error({msg, opts}) do
24 | # When using gettext, we typically pass the strings we want
25 | # to translate as a static argument:
26 | #
27 | # # Translate "is invalid" in the "errors" domain
28 | # dgettext("errors", "is invalid")
29 | #
30 | # # Translate the number of files with plural rules
31 | # dngettext("errors", "1 file", "%{count} files", count)
32 | #
33 | # Because the error messages we show in our forms and APIs
34 | # are defined inside Ecto, we need to translate them dynamically.
35 | # This requires us to call the Gettext module passing our gettext
36 | # backend as first argument.
37 | #
38 | # Note we use the "errors" domain, which means translations
39 | # should be written to the errors.po file. The :count option is
40 | # set by Ecto and indicates we should also apply plural rules.
41 | if count = opts[:count] do
42 | Gettext.dngettext(TilexWeb.Gettext, "errors", msg, msg, count, opts)
43 | else
44 | Gettext.dgettext(TilexWeb.Gettext, "errors", msg, opts)
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.ErrorView do
2 | use TilexWeb, :view
3 | import Phoenix.Controller, only: [put_view: 2, put_root_layout: 2]
4 | import Plug.Conn, only: [put_status: 2, halt: 1]
5 |
6 | def render_error_page(conn, status) do
7 | conn
8 | |> put_root_layout({TilexWeb.LayoutView, :error})
9 | |> put_status(status)
10 | |> put_view(__MODULE__)
11 | |> Phoenix.Controller.render("#{status}.html")
12 | |> halt()
13 | end
14 |
15 | # If you want to customize a particular status code
16 | # for a certain format, you may uncomment below.
17 | # def render("500.html", _assigns) do
18 | # "Internal Server Error"
19 | # end
20 |
21 | # By default, Phoenix returns the status message from
22 | # the template name. For example, "404.html" becomes
23 | # "Not Found".
24 | def template_not_found(_template, assigns) do
25 | render("500.html", assigns)
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/feed_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.FeedView do
2 | use TilexWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/html_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.HTMLHelpers do
2 | @moduledoc false
3 |
4 | use Phoenix.HTML
5 |
6 | def svg_tag(path), do: path |> File.read!() |> raw()
7 | end
8 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/icon.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.Icon do
2 | use Phoenix.HTML
3 | import TilexWeb.Router.Helpers, only: [static_path: 2]
4 |
5 | @icons_svg_file "images/icons.svg"
6 | @external_resource "priv/static/#{@icons_svg_file}"
7 | @icons_svg_content File.read!(@external_resource)
8 | @icon_names ~r/id="([\w-]+)"/ |> Regex.scan(@icons_svg_content) |> Enum.map(&List.last/1)
9 | @sizes [:small, :medium, :large]
10 |
11 | def icon(name, size \\ :medium, title \\ nil) when name in @icon_names and size in @sizes do
12 | content_tag(:svg, class: "icon icon-#{name} icon-#{size}", aria_labelledby: "title") do
13 | [
14 | content_tag(:title, title || name, lang: "en"),
15 | content_tag(:use, "", href: icon_path(name))
16 | ]
17 | end
18 | end
19 |
20 | defp icon_path(name), do: static_path(TilexWeb.Endpoint, "/#{@icons_svg_file}") <> "##{name}"
21 | end
22 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/layout_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.LayoutView do
2 | use TilexWeb, :view
3 | import TilexWeb.Endpoint, only: [static_url: 0]
4 | import Phoenix.Component, only: [live_flash: 2]
5 | import TilexWeb.StructuredDataView, only: [to_ld_json: 1, organization_ld: 0]
6 | alias Tilex.Blog.Post
7 |
8 | # Phoenix LiveDashboard is available only in development by default,
9 | # so we instruct Elixir to not warn if the dashboard route is missing.
10 | @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}}
11 |
12 | def current_user(conn) do
13 | Guardian.Plug.current_resource(conn)
14 | end
15 |
16 | def ga_identifier do
17 | Application.get_env(:tilex, :ga_identifier)
18 | end
19 |
20 | def imgur_api_key(conn) do
21 | current_user(conn) && Application.get_env(:tilex, :imgur_client_id)
22 | end
23 |
24 | def editor_preference(conn) do
25 | user = current_user(conn)
26 | user && user.editor
27 | end
28 |
29 | def page_title(%{post: post}), do: post.title
30 | def page_title(%{channel: channel}), do: String.capitalize(channel.name)
31 | def page_title(%{developer: developer}), do: developer.username
32 | def page_title(%{page_title: page_title}), do: page_title
33 | def page_title(%{conn: %{status: 404}}), do: "Not Found"
34 | def page_title(%{conn: %{status: s}}) when is_integer(s) and s > 400, do: "Server Error"
35 | def page_title(_), do: Application.get_env(:tilex, :organization_name)
36 |
37 | @images_folder "priv/static/images"
38 | @external_resource @images_folder
39 |
40 | @twitter_card_files Path.wildcard("#{@images_folder}/*_twitter_card.png")
41 | for file <- @twitter_card_files, do: @external_resource(file)
42 | @twitter_cards Enum.map(@twitter_card_files, &Path.basename/1)
43 |
44 | @default_twitter_card "til_twitter_card.png"
45 | @external_resource @default_twitter_card
46 |
47 | def twitter_image_url(%Post{} = p), do: twitter_image_url("#{channel_name(p)}_twitter_card.png")
48 | def twitter_image_url(card) when card in @twitter_cards, do: static_url() <> "/images/#{card}"
49 | def twitter_image_url(_card), do: static_url() <> "/images/#{@default_twitter_card}"
50 |
51 | defp channel_name(post) do
52 | case Ecto.assoc_loaded?(post.channel) do
53 | true -> String.replace(post.channel.name, "-", "_")
54 | false -> nil
55 | end
56 | end
57 |
58 | def twitter_title(%Post{title: title}), do: "Today I Learned: " <> title
59 | def twitter_title(_post), do: "Today I Learned: a Hashrocket Project"
60 |
61 | def twitter_description(%Post{body: body}) do
62 | body |> String.split("\n") |> hd |> Tilex.Markdown.to_content()
63 | end
64 |
65 | def twitter_description(_post) do
66 | """
67 | TIL is an open-source project by Hashrocket that exists to catalogue the sharing & accumulation of knowledge as it happens day-to-day. Posts have a #{Post.body_max_words()}-word limit, and posting is open to any Rocketeer as well as select friends of the team. We hope you enjoy learning along with us.
68 | """
69 | end
70 |
71 | def request_tracking(), do: Application.get_env(:tilex, :request_tracking)
72 | end
73 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/post_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.PostView do
2 | use TilexWeb, :view
3 |
4 | def more_info?(channel) do
5 | File.exists?("lib/tilex_web/templates/shared/_#{channel.name}.html.eex")
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/shared_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.SharedView do
2 | use TilexWeb, :view
3 |
4 | alias Guardian.Plug
5 | alias Timex.Timezone
6 |
7 | def display_date(post) do
8 | post.inserted_at
9 | |> maybe_convert_to_display_tz()
10 | |> Timex.format!("%B %-e, %Y", :strftime)
11 | end
12 |
13 | def pluralize(1, object), do: "1 #{object}"
14 |
15 | def pluralize(count, object), do: "#{count} #{object}s"
16 |
17 | def rss_date(post) do
18 | Timex.format!(post.inserted_at, "%a, %d %b %Y %H:%M:%S GMT", :strftime)
19 | end
20 |
21 | def pagination_href(conn, page) do
22 | conn.request_path <> "?" <> URI.encode_query(Map.put(conn.params, "page", page))
23 | end
24 |
25 | def post_creator_or_admin?(conn, post) do
26 | Plug.current_resource(conn) &&
27 | (post.developer == Plug.current_resource(conn) || Plug.current_resource(conn).admin)
28 | end
29 |
30 | defp maybe_convert_to_display_tz(timestamp) do
31 | case Application.get_env(:tilex, :date_display_tz) do
32 | setting when setting in [nil, ""] ->
33 | timestamp
34 |
35 | tz ->
36 | Timezone.convert(timestamp, tz)
37 | end
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/sitemap_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.SitemapView do
2 | use TilexWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/stats_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.StatsView do
2 | use TilexWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/structured_data_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.StructuredDataView do
2 | use TilexWeb, :view
3 | alias TilexWeb.Router.Helpers, as: Routes
4 | alias Tilex.Blog.Post
5 |
6 | @organization_ld %{
7 | "@context" => "http://schema.org",
8 | "@type" => "Organization",
9 | "name" => "Hashrocket",
10 | "url" => "https://hashrocket.com",
11 | "image" => "https://hashrocket.com/hashrocket_logo.svg",
12 | "logo" => "https://hashrocket.com/hashrocket_logo.svg",
13 | "telephone" => "1-904-339-7047",
14 | "duns" => "015835393",
15 | "foundingDate" => "2008-01-22",
16 | "founder" => %{
17 | "@type" => "Person",
18 | "url" => "http://marianphelan.com",
19 | "name" => "Marian Phelan",
20 | "jobTitle" => "CEO"
21 | },
22 | "brand" => %{
23 | "@type" => "Brand",
24 | "name" => "Hashrocket",
25 | "url" => "https://hashrocket.com",
26 | "logo" => "https://hashrocket.com/hashrocket_logo.svg"
27 | },
28 | "address" => %{
29 | "@type" => "PostalAddress",
30 | "streetAddress" => "320 1st St. N #714",
31 | "addressLocality" => "Jacksonville Beach",
32 | "addressRegion" => "FL",
33 | "postalCode" => "32250",
34 | "addressCountry" => "US"
35 | },
36 | "sameAs" => [
37 | "https://clutch.co/profile/hashrocket",
38 | "https://coderwall.com/team/hashrocket",
39 | "https://dribbble.com/hashrocket",
40 | "https://www.facebook.com/hashrocket",
41 | "https://github.com/hashrocket",
42 | "https://plus.google.com/+hashrocket",
43 | "https://www.linkedin.com/company/hashrocket",
44 | "https://twitter.com/hashrocket",
45 | "https://www.youtube.com/hashrocket",
46 | "https://vimeo.com/hashrocket"
47 | ]
48 | }
49 |
50 | def to_ld_json(data), do: data |> to_json() |> raw()
51 |
52 | def organization_ld, do: @organization_ld
53 |
54 | def post_ld(conn, %Post{channel: channel, developer: developer} = post) do
55 | author_same_as =
56 | case developer.twitter_handle do
57 | nil -> []
58 | handle -> ["https://twitter.com/#{handle}"]
59 | end
60 |
61 | %{
62 | "@context" => "http://schema.org",
63 | "@type" => "BlogPosting",
64 | "headline" => post.title,
65 | "articleSection" => channel.name,
66 | "mainEntityOfPage" => Routes.post_url(conn, :show, post),
67 | "image" => Routes.static_url(conn, "/images/til-logo-512x512.png"),
68 | "datePublished" => DateTime.to_date(post.inserted_at),
69 | "dateModified" => DateTime.to_date(post.updated_at),
70 | "author" => %{
71 | "@type" => "Person",
72 | "name" => developer.username,
73 | "url" => Routes.developer_url(conn, :show, developer),
74 | "sameAs" => author_same_as
75 | },
76 | "publisher" => @organization_ld
77 | }
78 | end
79 |
80 | defp to_json(data), do: Phoenix.json_library().encode!(data)
81 | end
82 |
--------------------------------------------------------------------------------
/lib/tilex_web/views/web_manifest_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.WebManifestView do
2 | use TilexWeb, :view
3 |
4 | def render("manifest.json", %{conn: conn, organization_name: organization_name}) do
5 | %{
6 | short_name: "TIL - #{organization_name}",
7 | name: "Today I Learned - #{organization_name}",
8 | icons: [
9 | %{
10 | src: "apple-touch-icon.png",
11 | type: "image/png",
12 | sizes: "100x100"
13 | },
14 | %{
15 | src: "apple-touch-icon-120x120.png",
16 | type: "image/png",
17 | sizes: "120x120"
18 | },
19 | %{
20 | src: Routes.static_url(conn, "/images/til-logo-144x144.png"),
21 | type: "image/png",
22 | sizes: "144x144"
23 | },
24 | %{
25 | src: Routes.static_url(conn, "/images/til-logo-512x512.png"),
26 | type: "image/png",
27 | sizes: "512x512"
28 | }
29 | ],
30 | start_url: "/",
31 | background_color: "#d5e9f5",
32 | theme_color: "#d5e9f5",
33 | display: "standalone"
34 | }
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Mixfile do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :tilex,
7 | version: "0.0.1",
8 | elixir: "~> 1.14",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | compilers: Mix.compilers(),
11 | start_permanent: Mix.env() == :prod,
12 | aliases: aliases(),
13 | deps: deps()
14 | ]
15 | end
16 |
17 | # Configuration for the OTP application.
18 | #
19 | # Type `mix help compile.app` for more information.
20 | def application do
21 | [
22 | mod: {Tilex.Application, []},
23 | extra_applications: [:logger, :runtime_tools, :appsignal]
24 | ]
25 | end
26 |
27 | # Specifies which paths to compile per environment.
28 | defp elixirc_paths(:test), do: ["lib", "test/support"]
29 | defp elixirc_paths(_), do: ["lib"]
30 |
31 | # Specifies your project dependencies.
32 | #
33 | # Type `mix help deps` for examples and options.
34 | defp deps do
35 | [
36 | {:appsignal_phoenix, "~> 2.0"},
37 | {:cachex, "~> 3.1"},
38 | {:cors_plug, "~> 3.0"},
39 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false},
40 | {:earmark, "~> 1.4.4"},
41 | {:ecto_sql, "~> 3.6"},
42 | {:esbuild, "~> 0.4", runtime: Mix.env() == :dev},
43 | {:floki, "~>0.34"},
44 | {:gettext, "~> 0.18"},
45 | {:guardian, "~> 2.0"},
46 | {:hackney, "~>1.18.1"},
47 | {:html_sanitize_ex, "~> 1.2"},
48 | {:jason, "~> 1.2"},
49 | {:oauther,
50 | git: "https://github.com/tobstarr/oauther.git", branch: "master", override: true},
51 | {:optimus, "~>0.3.0"},
52 | {:phoenix_ecto, "~> 4.4"},
53 | {:phoenix_html, "~> 3.0"},
54 | {:phoenix_live_dashboard, "~> 0.7"},
55 | {:phoenix_live_reload, "~> 1.2", only: :dev},
56 | {:phoenix_live_view, "~> 0.18"},
57 | {:phoenix, "~> 1.6.14"},
58 | {:plug_cowboy, "~> 2.5"},
59 | {:postgrex, ">= 0.0.0"},
60 | {:swoosh, "~> 1.3"},
61 | {:req, "~> 0.5.6"},
62 | {:telemetry_metrics, "~> 0.6"},
63 | {:telemetry_poller, "~> 1.0"},
64 | {:timex, "~> 3.1"},
65 | {:tzdata, "~> 1.1.0"},
66 | {:ueberauth_google, "~> 0.5"},
67 | {:wallaby, "~>0.30.1", [runtime: false, only: :test]}
68 | ]
69 | end
70 |
71 | # Aliases are shortcuts or tasks specific to the current project.
72 | # For example, to create, migrate and run the seeds file at once:
73 | #
74 | # $ mix ecto.setup
75 | #
76 | # See the documentation for `Mix` for more info on aliases.
77 | defp aliases do
78 | [
79 | setup: ["deps.get", "ecto.setup"],
80 | "ecto.migrate": ["ecto.migrate", "ecto.dump"],
81 | "ecto.rollback": ["ecto.rollback", "ecto.dump"],
82 | "ecto.setup": [
83 | "ecto.create",
84 | "ecto.load --skip-if-loaded",
85 | "ecto.migrate",
86 | "run priv/repo/seeds.exs"
87 | ],
88 | "ecto.reset": ["ecto.drop", "ecto.setup"],
89 | test: ["ecto.create --quiet", "ecto.load --skip-if-loaded", "ecto.migrate --quiet", "test"],
90 | "assets.deploy": ["esbuild default --minify", "phx.digest"]
91 | ]
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/phoenix_static_buildpack.config:
--------------------------------------------------------------------------------
1 | clean_cache=true
2 | node_version=19.0.0
3 |
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | msgid "should be %{count} character(s)"
53 | msgid_plural "should be %{count} character(s)"
54 | msgstr[0] ""
55 | msgstr[1] ""
56 |
57 | ## From Ecto.Changeset.validate_length/3
58 | msgid "should have %{count} item(s)"
59 | msgid_plural "should have %{count} item(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be at least %{count} character(s)"
64 | msgid_plural "should be at least %{count} character(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at most %{count} character(s)"
74 | msgid_plural "should be at most %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should have at most %{count} item(s)"
79 | msgid_plural "should have at most %{count} item(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | ## From Ecto.Changeset.validate_number/3
84 | msgid "must be less than %{number}"
85 | msgstr ""
86 |
87 | msgid "must be greater than %{number}"
88 | msgstr ""
89 |
90 | msgid "must be less than or equal to %{number}"
91 | msgstr ""
92 |
93 | msgid "must be greater than or equal to %{number}"
94 | msgstr ""
95 |
96 | msgid "must be equal to %{number}"
97 | msgstr ""
98 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here has no
9 | ## effect: edit them in PO (`.po`) files instead.
10 | ## From Ecto.Changeset.cast/4
11 | msgid "can't be blank"
12 | msgstr ""
13 |
14 | ## From Ecto.Changeset.unique_constraint/3
15 | msgid "has already been taken"
16 | msgstr ""
17 |
18 | ## From Ecto.Changeset.put_change/3
19 | msgid "is invalid"
20 | msgstr ""
21 |
22 | ## From Ecto.Changeset.validate_acceptance/3
23 | msgid "must be accepted"
24 | msgstr ""
25 |
26 | ## From Ecto.Changeset.validate_format/3
27 | msgid "has invalid format"
28 | msgstr ""
29 |
30 | ## From Ecto.Changeset.validate_subset/3
31 | msgid "has an invalid entry"
32 | msgstr ""
33 |
34 | ## From Ecto.Changeset.validate_exclusion/3
35 | msgid "is reserved"
36 | msgstr ""
37 |
38 | ## From Ecto.Changeset.validate_confirmation/3
39 | msgid "does not match confirmation"
40 | msgstr ""
41 |
42 | ## From Ecto.Changeset.no_assoc_constraint/3
43 | msgid "is still associated with this entry"
44 | msgstr ""
45 |
46 | msgid "are still associated with this entry"
47 | msgstr ""
48 |
49 | ## From Ecto.Changeset.validate_length/3
50 | msgid "should be %{count} character(s)"
51 | msgid_plural "should be %{count} character(s)"
52 | msgstr[0] ""
53 | msgstr[1] ""
54 |
55 | msgid "should have %{count} item(s)"
56 | msgid_plural "should have %{count} item(s)"
57 | msgstr[0] ""
58 | msgstr[1] ""
59 |
60 | msgid "should be at least %{count} character(s)"
61 | msgid_plural "should be at least %{count} character(s)"
62 | msgstr[0] ""
63 | msgstr[1] ""
64 |
65 | msgid "should have at least %{count} item(s)"
66 | msgid_plural "should have at least %{count} item(s)"
67 | msgstr[0] ""
68 | msgstr[1] ""
69 |
70 | msgid "should be at most %{count} character(s)"
71 | msgid_plural "should be at most %{count} character(s)"
72 | msgstr[0] ""
73 | msgstr[1] ""
74 |
75 | msgid "should have at most %{count} item(s)"
76 | msgid_plural "should have at most %{count} item(s)"
77 | msgstr[0] ""
78 | msgstr[1] ""
79 |
80 | ## From Ecto.Changeset.validate_number/3
81 | msgid "must be less than %{number}"
82 | msgstr ""
83 |
84 | msgid "must be greater than %{number}"
85 | msgstr ""
86 |
87 | msgid "must be less than or equal to %{number}"
88 | msgstr ""
89 |
90 | msgid "must be greater than or equal to %{number}"
91 | msgstr ""
92 |
93 | msgid "must be equal to %{number}"
94 | msgstr ""
95 |
--------------------------------------------------------------------------------
/priv/repo/migrations/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto_sql],
3 | inputs: ["*.exs"]
4 | ]
5 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20161118221207_create_post.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.CreatePost do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:posts) do
6 | add :title, :varchar, null: false
7 | add :body, :text, null: false
8 |
9 | add :inserted_at, :timestamptz, null: false, default: fragment("now()")
10 | add :updated_at, :timestamptz, null: false, default: fragment("now()")
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20161228153637_create_channel.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.CreateChannel do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:channels) do
6 | add :name, :string, null: false
7 | add :twitter_hashtag, :string, null: false
8 |
9 | timestamps()
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20161228161102_posts_belong_to_channels.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.PostsBelongToChannels do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:posts) do
6 | add :channel_id, references(:channels, on_delete: :delete_all)
7 | end
8 |
9 | create index(:posts, [:channel_id])
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20161229180247_add_index_to_channels.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddIndexToChannels do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create unique_index(:channels, [:name])
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170106200911_add_slug_to_post.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddSlugToPost do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:posts) do
6 | add :slug, :string, null: false
7 | end
8 |
9 | create unique_index(:posts, [:slug])
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170120203217_add_likes_to_post.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddLikesToPost do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:posts) do
6 | add :likes, :integer, default: 1, null: false
7 | end
8 |
9 | create constraint(:posts, "likes_must_be_greater_than_zero", check: "likes > 0")
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170317204314_add_max_likes_to_posts.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddMaxLikesToPosts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:posts) do
6 | add :max_likes, :integer, default: 1, null: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170317205716_add_max_likes_constraints_to_posts.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddMaxLikesConstraintsToPosts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create constraint(:posts, "max_likes_must_be_greater_than_zero", check: "max_likes > 0")
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170317210210_add_published_at_to_posts.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddPublishedAtToPosts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:posts) do
6 | add :published_at, :timestamptz, null: false, default: fragment("now()")
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170317210552_add_tweeted_to_posts.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddTweetedToPosts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:posts) do
6 | add :tweeted, :boolean, null: false, default: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170317210749_channel_id_not_null_on_posts.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.ChannelIdNotNullOnPosts do
2 | use Ecto.Migration
3 |
4 | def up do
5 | alter table(:posts) do
6 | modify :channel_id, :integer, null: false
7 | end
8 | end
9 |
10 | def down do
11 | alter table(:posts) do
12 | modify :channel_id, :integer, null: true
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170317214042_create_developers_table.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.CreateDevelopersTable do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:developers) do
6 | add :email, :varchar, null: false
7 | add :username, :varchar, null: false
8 | add :google_id, :varchar, null: false
9 |
10 | timestamps()
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170319161606_add_developer_id_to_posts.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddDeveloperIdToPosts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:posts) do
6 | add :developer_id, references(:developers, on_delete: :delete_all)
7 | end
8 |
9 | create index(:posts, [:developer_id])
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170518173833_add_tweeted_at_to_posts.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddTweetedAtToPosts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:posts) do
6 | remove :tweeted
7 | add :tweeted_at, :timestamptz
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170518183105_add_twitter_handle_to_developers.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddTwitterHandleToDevelopers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:developers) do
6 | add :twitter_handle, :string, default: "hashrocket"
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170601120632_add_admin_to_developers.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddAdminToDevelopers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:developers) do
6 | add :admin, :boolean, default: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170602200233_add_editor_to_developers.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddEditorToDevelopers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:developers) do
6 | add :editor, :string, default: "Text Field"
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170726192554_remove_default_from_twitter_handle_column.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.RemoveDefaultFromTwitterHandleColumn do
2 | use Ecto.Migration
3 |
4 | def up do
5 | execute """
6 | alter table developers alter twitter_handle drop default;
7 | """
8 | end
9 |
10 | def down do
11 | execute """
12 | alter table developers alter twitter_handle set default 'hashrocket';
13 | """
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170823194255_update_text_editor_on_developers.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.UpdateTextEditorOnDevelopers do
2 | use Ecto.Migration
3 |
4 | def up do
5 | execute """
6 | update developers set editor = 'Vim' where editor = 'Ace (w/ Vim)';
7 | """
8 |
9 | execute """
10 | update developers set editor = 'Code Editor' where editor = 'Ace';
11 | """
12 | end
13 |
14 | def down do
15 | execute """
16 | update developers set editor = 'Ace (w/ Vim)' where editor = 'Vim';
17 | """
18 |
19 | execute """
20 | update developers set editor = 'Ace' where editor = 'Code Editor';
21 | """
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20171208181757_remove_google_id.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.RemoveGoogleId do
2 | use Ecto.Migration
3 |
4 | def up do
5 | execute """
6 | alter table developers drop column google_id;
7 | """
8 | end
9 |
10 | def down do
11 | execute """
12 | alter table developers add column google_id varchar;
13 | """
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20180908171609_create_tracking.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.CreateTracking do
2 | use Ecto.Migration
3 |
4 | def up do
5 | execute """
6 | create table requests (page text not null, request_time timestamp with time zone default now());
7 | """
8 | end
9 |
10 | def down do
11 | execute """
12 | drop table requests;
13 | """
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20190827182708_add_index_to_requests.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddIndexToRequests do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create(index(:requests, :page))
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200518184142_add_index_to_request_time_on_requests.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddIndexToRequestTimeOnRequests do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create(index(:requests, :request_time))
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20220425135720_add_request_time_in_chicago_tz_index_to_requests.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddRequestTimeInChicagoTzIndexToRequests do
2 | use Ecto.Migration
3 |
4 | @disable_ddl_transaction true
5 | @disable_migration_lock true
6 |
7 | def up do
8 | execute """
9 | create index concurrently index_requests_on_request_time_in_chicago on requests (timezone('america/chicago', request_time))
10 | """
11 | end
12 |
13 | def down do
14 | execute """
15 | drop index concurrently index_requests_on_request_time_in_chicago
16 | """
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20220429184256_add_request_time_in_application_timezone_index_to_requests.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Repo.Migrations.AddRequestTimeInApplicationTimezoneIndexToRequests do
2 | use Ecto.Migration
3 |
4 | @disable_ddl_transaction true
5 | @disable_migration_lock true
6 |
7 | def up do
8 | application_timezone =
9 | Application.get_env(:tilex, :date_display_tz, "america/chicago")
10 | |> String.downcase()
11 |
12 | execute """
13 | drop index concurrently if exists index_requests_on_request_time_in_chicago
14 | """
15 |
16 | execute """
17 | create index concurrently if not exists index_requests_on_request_time_in_app_tz on requests (timezone('#{application_timezone}', request_time))
18 | """
19 | end
20 |
21 | def down do
22 | execute """
23 | drop index concurrently if exists index_requests_on_request_time_in_app_tz
24 | """
25 |
26 | execute """
27 | create index concurrently if not exists index_requests_on_request_time_in_chicago on requests (timezone('america/chicago', request_time))
28 | """
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | # Script for populating the database. You can run it as:
2 | #
3 | # mix run priv/repo/seeds.exs
4 | #
5 | # Inside the script, you can read and write to any of your
6 | # repositories directly:
7 | #
8 | # Tilex.Repo.insert!(%Tilex.SomeSchema{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
13 | alias Tilex.Blog.Channel
14 | alias Tilex.Blog.Developer
15 | alias Tilex.Blog.Post
16 | alias Tilex.Repo
17 |
18 | Repo.delete_all(Post)
19 | Repo.delete_all(Channel)
20 | Repo.delete_all(Developer)
21 |
22 | phoenix_channel = Repo.insert!(%Channel{name: "phoenix", twitter_hashtag: "phoenix"})
23 | elixir_channel = Repo.insert!(%Channel{name: "elixir", twitter_hashtag: "myelixirstatus"})
24 | erlang_channel = Repo.insert!(%Channel{name: "erlang", twitter_hashtag: "erlang"})
25 |
26 | developer =
27 | Repo.insert!(%Developer{email: "developer@hashrocket.com", username: "rickyrocketeer"})
28 |
29 | 1..100
30 | |> Enum.each(fn _i ->
31 | Repo.insert!(%Post{
32 | title: "Observing Change",
33 | body: "A Gold Master Test in Practice",
34 | channel: phoenix_channel,
35 | developer: developer,
36 | slug: Post.generate_slug()
37 | })
38 |
39 | Repo.insert!(%Post{
40 | title: "Controlling Your Test Environment",
41 | body: "Slow browser integration tests are a hard problem",
42 | channel: elixir_channel,
43 | developer: developer,
44 | slug: Post.generate_slug()
45 | })
46 |
47 | Repo.insert!(%Post{
48 | title: "Testing Elixir",
49 | body: "A Rubyist's Journey",
50 | channel: erlang_channel,
51 | developer: developer,
52 | slug: Post.generate_slug()
53 | })
54 | end)
55 |
--------------------------------------------------------------------------------
/priv/static/apple-touch-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/apple-touch-icon-120x120.png
--------------------------------------------------------------------------------
/priv/static/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/apple-touch-icon-precomposed.png
--------------------------------------------------------------------------------
/priv/static/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/apple-touch-icon.png
--------------------------------------------------------------------------------
/priv/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/favicon.ico
--------------------------------------------------------------------------------
/priv/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/favicon.png
--------------------------------------------------------------------------------
/priv/static/fonts/Lora-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/fonts/Lora-Bold.woff2
--------------------------------------------------------------------------------
/priv/static/fonts/Lora-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/fonts/Lora-Regular.woff2
--------------------------------------------------------------------------------
/priv/static/images/command_line_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/command_line_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/elixir_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/elixir_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/elm_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/elm_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/git_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/git_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/html_css_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/html_css_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/javascript_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/javascript_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/python_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/python_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/rails_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/rails_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/react_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/react_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/reasonml_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/reasonml_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/ruby_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/ruby_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/sql_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/sql_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/til-logo-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/til-logo-144x144.png
--------------------------------------------------------------------------------
/priv/static/images/til-logo-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/til-logo-512x512.png
--------------------------------------------------------------------------------
/priv/static/images/til_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/til_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/vim_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/vim_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/images/workflow_twitter_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hashrocket/tilex/196a2d9a699374542863f018cd8ed6ffbda60bc3/priv/static/images/workflow_twitter_card.png
--------------------------------------------------------------------------------
/priv/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 |
3 | sitemap: https://til.hashrocket.com/sitemap.xml
4 |
5 | User-agent: *
6 | Disallow: /*?*page=*
7 |
8 | User-agent: *
9 | Disallow: /*?*q=*
10 |
11 | User-agent: *
12 | Disallow: /statistics*
13 |
14 | User-agent: *
15 | Disallow: /authors/*
16 |
17 | User-agent: *
18 | Disallow: /random*
19 |
20 | User-agent: *
21 | Disallow: /*/random*
22 |
23 | User-agent: *
24 | Disallow: /*.md*
25 |
--------------------------------------------------------------------------------
/test/controllers/api/developer_post_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Api.DeveloperPostControllerTest do
2 | use TilexWeb.ConnCase, async: true
3 |
4 | alias Tilex.Factory
5 |
6 | test "returns a developer's most recent posts", %{conn: conn} do
7 | jackdonaughy = Factory.insert!(:developer, username: "jackdonaughy")
8 |
9 | jack_post_titles = [
10 | "30 Rock Is Awesome",
11 | "I'm a Big Meanie",
12 | "Get It Together, Lemon"
13 | ]
14 |
15 | jack_post_titles
16 | |> Enum.with_index()
17 | |> Enum.map(fn {title, index} ->
18 | Factory.insert!(:post,
19 | title: title,
20 | developer: jackdonaughy,
21 | inserted_at: Timex.to_datetime({{2018, 1, 1}, {1, 1, index}}, "Etc/UTC")
22 | )
23 | end)
24 |
25 | lizlemon = Factory.insert!(:developer, username: "lizlemon")
26 | Factory.insert!(:post, title: "Eye-rolling Is My Life", developer: lizlemon)
27 |
28 | conn = get(conn, Routes.developer_post_path(conn, :index, username: "jackdonaughy"))
29 |
30 | json_response(conn, 200)["data"]["posts"]
31 | |> Enum.reverse()
32 | |> Enum.map(fn post -> post["title"] end)
33 | |> (fn posts ->
34 | assert posts == jack_post_titles
35 | end).()
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/test/controllers/api/post_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Api.PostControllerTest do
2 | use TilexWeb.ConnCase, async: true
3 |
4 | alias Tilex.Factory
5 |
6 | test "returns the entries", %{conn: conn} do
7 | lizlemon = Factory.insert!(:developer, username: "lizlemon")
8 |
9 | Factory.insert!(:post,
10 | title: "Eye-rolling Is My Life",
11 | developer: lizlemon,
12 | inserted_at: Timex.to_datetime({{2018, 1, 1}, {1, 1, 0}}, "Etc/UTC")
13 | )
14 |
15 | jackdonaughy = Factory.insert!(:developer, username: "jackdonaughy")
16 |
17 | jack_post_titles = [
18 | "30 Rock Is Awesome",
19 | "I'm a Big Meanie",
20 | "Get It Together, Lemon"
21 | ]
22 |
23 | jack_post_titles
24 | |> Enum.with_index()
25 | |> Enum.map(fn {title, index} ->
26 | Factory.insert!(:post,
27 | title: title,
28 | developer: jackdonaughy,
29 | inserted_at: Timex.to_datetime({{2018, 1, 1}, {1, 2, index}}, "Etc/UTC")
30 | )
31 | end)
32 |
33 | conn = get(conn, Routes.api_post_path(conn, :index))
34 |
35 | expected_post_titles = ["Eye-rolling Is My Life"] ++ jack_post_titles
36 |
37 | json_response(conn, 200)["data"]["posts"]
38 | |> Enum.reverse()
39 | |> Enum.map(fn post -> post["title"] end)
40 | |> (fn posts ->
41 | assert length(posts) === 4
42 | assert posts == expected_post_titles
43 | end).()
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/test/controllers/auth_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.AuthControllerTest do
2 | use TilexWeb.ConnCase, async: true
3 |
4 | alias Tilex.Factory
5 |
6 | test "GET /auth/google/callback with hashrocket email", %{conn: conn} do
7 | ueberauth_auth =
8 | ueberauth_struct("developer@hashrocket.com", "Ricky Rocketeer", "186823978541230597895")
9 |
10 | conn = assign(conn, :ueberauth_auth, ueberauth_auth)
11 |
12 | conn = get(conn, Routes.auth_path(conn, :callback, "google"))
13 |
14 | assert redirected_to(conn) == "/"
15 | assert get_flash(conn, :info) == "Signed in with developer@hashrocket.com"
16 |
17 | new_developer = Tilex.Repo.get_by!(Tilex.Blog.Developer, email: "developer@hashrocket.com")
18 | assert new_developer.email == "developer@hashrocket.com"
19 | assert new_developer.username == "rickyrocketeer"
20 | end
21 |
22 | test "GET /auth/google/callback with existing hashrocket email", %{conn: conn} do
23 | Factory.insert!(
24 | :developer,
25 | email: "rebecca@hashrocket.com",
26 | name: "Rebecca Rocketeer"
27 | )
28 |
29 | existing_developer = Tilex.Repo.get_by!(Tilex.Blog.Developer, email: "rebecca@hashrocket.com")
30 | assert existing_developer.email == "rebecca@hashrocket.com"
31 |
32 | ueberauth_auth =
33 | ueberauth_struct("rebecca@hashrocket.com", "Rebecca Rocketeer", "126456978541230597123")
34 |
35 | conn = assign(conn, :ueberauth_auth, ueberauth_auth)
36 |
37 | conn = get(conn, Routes.auth_path(conn, :callback, "google"))
38 |
39 | assert redirected_to(conn) == "/"
40 | assert get_flash(conn, :info) == "Signed in with rebecca@hashrocket.com"
41 | end
42 |
43 | test "GET /auth/google/callback with other email domain", %{conn: conn} do
44 | ueberauth_auth =
45 | ueberauth_struct("developer@gmail.com", "Rando Programmer", "186823978541230597895")
46 |
47 | conn = assign(conn, :ueberauth_auth, ueberauth_auth)
48 |
49 | conn = get(conn, Routes.auth_path(conn, :callback, "google"))
50 |
51 | assert redirected_to(conn) == "/"
52 | assert get_flash(conn, :info) == "developer@gmail.com is not a valid email address"
53 | end
54 |
55 | test "GET /auth/google/callback with nameless profile", %{conn: conn} do
56 | ueberauth_auth = ueberauth_struct("developer@gmail.com", nil, "186823978541230597895")
57 |
58 | conn = assign(conn, :ueberauth_auth, ueberauth_auth)
59 |
60 | conn = get(conn, Routes.auth_path(conn, :callback, "google"))
61 |
62 | assert redirected_to(conn) == "/"
63 | assert get_flash(conn, :info) == "oauth2 profile is missing a valid name"
64 | end
65 |
66 | test "GET /auth/google/callback with allowlisted email", %{conn: conn} do
67 | Application.put_env(:tilex, :guest_author_allowlist, "david@byrne.com, bell@thecat.com")
68 |
69 | ueberauth_auth =
70 | ueberauth_struct("bell@thecat.com", "Archibald Douglas", "186823978541230597895")
71 |
72 | conn = assign(conn, :ueberauth_auth, ueberauth_auth)
73 |
74 | conn = get(conn, Routes.auth_path(conn, :callback, "google"))
75 |
76 | assert redirected_to(conn) == "/"
77 | assert get_flash(conn, :info) == "Signed in with bell@thecat.com"
78 | end
79 |
80 | defp ueberauth_struct(email, name, uid) do
81 | %Ueberauth.Auth{
82 | info: %Ueberauth.Auth.Info{
83 | email: email,
84 | name: name
85 | },
86 | uid: uid
87 | }
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/test/controllers/stats_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.StatsControllerTest do
2 | use TilexWeb.ConnCase, async: true
3 |
4 | test "developer/2 redirects to stats index when unauthenticated", %{conn: conn} do
5 | response = get(conn, Routes.stats_path(conn, :developer))
6 | assert html_response(response, 302)
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/test/controllers/web_manifest_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.WebManifestControllerTest do
2 | use TilexWeb.ConnCase, async: true
3 |
4 | test "index returns web manifest json", %{conn: conn} do
5 | organization_name = Application.get_env(:tilex, :organization_name)
6 | conn = get(conn, Routes.web_manifest_path(conn, :index))
7 | assert json_response(conn, 200)["short_name"] == "TIL - #{organization_name}"
8 | assert json_response(conn, 200)["name"] == "Today I Learned - #{organization_name}"
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/test/features/admin_edits_post_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AdminEditsPostTest do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | alias Tilex.Integration.Pages.PostForm
5 | alias Tilex.Integration.Pages.PostShowPage
6 |
7 | test "fills out form and updates post from post show", %{session: session} do
8 | Factory.insert!(:channel, name: "phoenix")
9 | developer = Factory.insert!(:developer)
10 | admin = Factory.insert!(:developer, %{admin: true})
11 |
12 | post =
13 | Factory.insert!(
14 | :post,
15 | title: "Awesome Post!",
16 | developer: developer,
17 | body: "This is how to be *awesome*!"
18 | )
19 |
20 | session
21 | |> sign_in(admin)
22 | |> PostForm.navigate(post)
23 | |> PostForm.ensure_page_loaded()
24 | |> PostForm.expect_title_preview("Awesome Post!")
25 | |> PostForm.fill_in_title("Even Awesomer Post!")
26 | |> PostForm.click_submit()
27 |
28 | session
29 | |> PostShowPage.ensure_page_loaded("Even Awesomer Post!")
30 | end
31 |
32 | test "enters a title that is too long", %{session: session} do
33 | Factory.insert!(:channel, name: "phoenix")
34 | developer = Factory.insert!(:developer)
35 |
36 | post =
37 | Factory.insert!(
38 | :post,
39 | title: "Limited characters",
40 | developer: developer,
41 | body: "Bugs revealed"
42 | )
43 |
44 | session
45 | |> sign_in(developer)
46 | |> PostForm.navigate(post)
47 | |> PostForm.ensure_page_loaded()
48 | |> PostForm.fill_in_title(String.duplicate("I can codez ", 10))
49 | |> PostForm.click_submit()
50 |
51 | session
52 | |> PostForm.expect_form_has_error("Title should be at most 50 character(s)")
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/test/features/developer_edits_post_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DeveloperEditsPostTest do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | alias Tilex.Integration.Pages.PostForm
5 | alias Tilex.Integration.Pages.PostShowPage
6 |
7 | test "fills out form and updates post from post show", %{session: session} do
8 | Factory.insert!(:channel, name: "phoenix")
9 | developer = Factory.insert!(:developer)
10 |
11 | post =
12 | Factory.insert!(
13 | :post,
14 | title: "Awesome Post!",
15 | developer: developer,
16 | body: "This is how to be *awesome*!"
17 | )
18 |
19 | session
20 | |> sign_in(developer)
21 | |> PostShowPage.navigate(post)
22 | |> PostShowPage.click_edit()
23 |
24 | session
25 | |> PostForm.ensure_page_loaded()
26 | |> PostForm.expect_preview_content("em", "awesome")
27 | |> PostForm.expect_word_count(6)
28 | |> PostForm.expect_words_left("194 words available")
29 | |> PostForm.expect_title_characters_left("37 characters available")
30 | |> PostForm.expect_title_preview("Awesome Post!")
31 | |> PostForm.fill_in_title("Even Awesomer Post!")
32 | |> PostForm.fill_in_body("This is how to be super awesome!")
33 | |> PostForm.select_channel("phoenix")
34 | |> PostForm.click_submit()
35 |
36 | session
37 | |> PostShowPage.ensure_page_loaded("Even Awesomer Post!")
38 | |> PostShowPage.ensure_info_flash("Post Updated")
39 | |> PostShowPage.expect_post_attributes(%{
40 | title: "Even Awesomer Post!",
41 | body: "This is how to be super awesome!",
42 | channel: "#phoenix",
43 | likes_count: 1
44 | })
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/test/features/developer_edits_profile_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DeveloperEditsProfileTest do
2 | use Tilex.IntegrationCase, async: false
3 |
4 | test "fills out form and updates post from post show", %{session: session} do
5 | developer = Factory.insert!(:developer, email: "fine@sixdollareggs.com")
6 |
7 | sign_in(session, developer)
8 |
9 | click(session, Query.link("Profile"))
10 |
11 | h1_heading = Element.text(find(session, Query.css("#profile_edit header h1")))
12 | profile_form = Element.text(find(session, Query.css("#profile_edit form")))
13 |
14 | assert h1_heading == "My Profile"
15 | assert profile_form =~ "fine@sixdollareggs.com"
16 |
17 | session
18 | |> fill_in(Query.text_field("Twitter handle"), with: "mcnormalmode")
19 | |> (fn session ->
20 | find(session, Query.select("Editor"), fn element ->
21 | click(element, Query.option("Vim"))
22 | end)
23 |
24 | session
25 | end).()
26 | |> click(Query.button("Submit"))
27 |
28 | element_text = fn session, selector ->
29 | Element.text(find(session, Query.css(selector)))
30 | end
31 |
32 | info_flash = element_text.(session, ".alert-info")
33 |
34 | assert info_flash == "Developer Updated"
35 |
36 | developer =
37 | Tilex.Blog.Developer
38 | |> Tilex.Repo.all()
39 | |> Enum.reverse()
40 | |> hd
41 |
42 | assert developer.twitter_handle == "mcnormalmode"
43 | assert developer.editor == "Vim"
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/test/features/developer_sees_navigation_bar_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DeveloperSeesNavigationBarTest do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | alias Tilex.Integration.Pages.Navigation
5 |
6 | describe "when developer is not authenticated" do
7 | test "there is no link on admin navbar", %{session: session} do
8 | link_texts =
9 | session
10 | |> visit("/")
11 | |> get_texts(Navigation.item_query())
12 |
13 | assert link_texts == []
14 | end
15 | end
16 |
17 | describe "when developer is authenticated" do
18 | setup [:authenticated_developer]
19 |
20 | test "there are links on admin navbar", %{session: session} do
21 | link_texts =
22 | session
23 | |> visit("/")
24 | |> get_texts(Navigation.item_query())
25 |
26 | assert link_texts == ["Rock Teer", "Sign Out", "Create Post", "Profile"]
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/test/features/developer_signs_out_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Features.DeveloperSignsOutTest do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | test "signs out and sees a flash message", %{:session => session} do
5 | developer = Factory.insert!(:developer)
6 |
7 | session
8 | |> sign_in(developer)
9 | |> click(Query.link("Sign Out"))
10 | |> Browser.find(Query.css(".alert-info", text: "Signed out"))
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/features/developer_views_stats_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Features.DeveloperViewsStatsTest do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | def format_stats_date(%Date{year: year, day: day, month: month}) do
5 | day_s = String.pad_leading(Integer.to_string(day), 2, "0")
6 | month_s = String.pad_leading(Integer.to_string(month), 2, "0")
7 |
8 | "#{month_s}-#{day_s}-#{year}"
9 | end
10 |
11 | test "sees total number of posts by channel", %{session: session} do
12 | developer = Factory.insert!(:developer)
13 | phoenix_channel = Factory.insert!(:channel, name: "phoenix")
14 | other_channel = Factory.insert!(:channel, name: "other")
15 |
16 | Factory.insert!(
17 | :post,
18 | title: "functional programming rocks",
19 | channel: phoenix_channel
20 | )
21 |
22 | Enum.each(["i'm fine", "all these people out here", "what?"], fn title ->
23 | Factory.insert!(:post, title: title, channel: other_channel)
24 | end)
25 |
26 | Enum.each(["i'm not fine", "where are all the people", "okay."], fn title ->
27 | Factory.insert!(
28 | :post,
29 | inserted_at:
30 | DateTime.utc_now()
31 | |> DateTime.add(-:timer.hours(48), :millisecond)
32 | |> DateTime.truncate(:second),
33 | title: title,
34 | channel: other_channel
35 | )
36 | end)
37 |
38 | session
39 | |> sign_in(developer)
40 | |> visit("/developer/statistics")
41 |
42 | assert(
43 | session
44 | |> find(Query.css(".stats_column header", text: "7 posts in 2 channels"))
45 | )
46 |
47 | channels = find(session, Query.css(".stats_column ul#channels"))
48 | [other_channel, phoenix_channel] = all(channels, Query.css("li"))
49 |
50 | assert text_without_newlines(other_channel) =~ "#other 6 posts"
51 | assert text_without_newlines(phoenix_channel) =~ "#phoenix 1 post"
52 |
53 | session
54 | |> fill_in(Query.css("#start-date"),
55 | with: Date.utc_today() |> Date.add(-7) |> format_stats_date()
56 | )
57 | |> fill_in(Query.css("#end-date"),
58 | with: Date.utc_today() |> Date.add(-1) |> format_stats_date()
59 | )
60 | |> click(Query.css("#filter-submit"))
61 |
62 | assert(
63 | session
64 | |> find(Query.css(".stats_column header", text: "3 posts in 1 channel"))
65 | )
66 |
67 | channels = find(session, Query.css(".stats_column ul#channels"))
68 | [other_channel] = all(channels, Query.css("li"))
69 |
70 | assert text_without_newlines(other_channel) =~ "#other 3 posts"
71 | end
72 |
73 | test "does not see sees til activity chart", %{session: session} do
74 | developer = Factory.insert!(:developer)
75 |
76 | session
77 | |> sign_in(developer)
78 | |> visit("/developer/statistics")
79 |
80 | refute_has(session, Query.css("ul#activity"))
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/test/features/robot_sees_sitemap.exs:
--------------------------------------------------------------------------------
1 | defmodule RobotSeesSitemap do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | test "And sees posts", %{session: session} do
5 | post = Factory.insert!(:post, title: "Klaus and Greta")
6 |
7 | visit(session, "/sitemap.xml")
8 |
9 | assert find(session, Query.css("loc", text: Tilex.Blog.Post.slugified_title(post.title)))
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/test/features/visitor_searches_posts_test.exs:
--------------------------------------------------------------------------------
1 | defmodule VisiorSearchesPosts do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | def fill_in_search(session, query) do
5 | session
6 | |> visit("/")
7 | |> find(Query.css(".site_nav__search .site_nav__link"))
8 | |> Element.click()
9 |
10 | session
11 | |> fill_in(Query.text_field("q"), with: query)
12 | |> click(Query.button("Search"))
13 | end
14 |
15 | test "with no found posts", %{session: session} do
16 | Factory.insert!(:post, title: "elixir is awesome")
17 | fill_in_search(session, "ruby on rails")
18 | search_result_header = get_text(session, "#search")
19 |
20 | assert search_result_header == "0 posts about ruby on rails"
21 | end
22 |
23 | test "with 2 found posts", %{session: session} do
24 | ["Elixir Rules", "Because JavaScript", "Hashrocket Rules"]
25 | |> Enum.each(&Factory.insert!(:post, title: &1))
26 |
27 | fill_in_search(session, "rules")
28 |
29 | search_result_header = get_text(session, "#search")
30 | body = get_text(session, "body")
31 |
32 | assert search_result_header == "2 posts about rules"
33 | assert find(session, Query.css("article.post", count: 2))
34 | assert body =~ ~r/Elixir Rules/
35 | assert body =~ ~r/Hashrocket Rules/
36 | refute body =~ ~r/Because JavaScript/
37 | end
38 |
39 | test "with paginated query results", %{session: session} do
40 | max_posts_on_page = Application.get_env(:tilex, :page_size)
41 |
42 | 1..(max_posts_on_page * 2)
43 | |> Enum.map(&"Random Elixir Post #{&1}")
44 | |> Enum.each(&Factory.insert!(:post, title: &1))
45 |
46 | Factory.insert!(:post, title: "No Match")
47 |
48 | fill_in_search(session, "Elixir")
49 |
50 | first_page_first_post = get_first_post_on_page_title(session)
51 | search_result_header = get_text(session, "#search")
52 |
53 | assert search_result_header == "10 posts about Elixir"
54 | assert find(session, Query.css("article.post", count: max_posts_on_page))
55 |
56 | visit(session, "/?_utf8=✓&page=2&q=Elixir")
57 |
58 | second_page_first_post = get_first_post_on_page_title(session)
59 | search_result_header = get_text(session, "#search")
60 |
61 | assert search_result_header == "10 posts about Elixir"
62 | refute first_page_first_post == second_page_first_post
63 | assert find(session, Query.css("article.post", count: max_posts_on_page))
64 | end
65 |
66 | defp get_first_post_on_page_title(session) do
67 | get_text(session, "#home > article:first-child h1 a")
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/test/features/visitor_views_channel_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Features.VisitorViewsChannelTest do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | test "sees associated posts", %{session: session} do
5 | target_channel = Factory.insert!(:channel, name: "phoenix")
6 | other_channel = Factory.insert!(:channel, name: "other")
7 |
8 | Enum.each(["i'm fine", "all these people out here", "what?"], fn title ->
9 | Factory.insert!(:post, title: title, channel: other_channel)
10 | end)
11 |
12 | Factory.insert!(:post, title: "functional programming rocks", channel: target_channel)
13 |
14 | visit(session, "/")
15 | assert find(session, Query.css("article.post", count: 4))
16 | click(session, Query.link("#phoenix"))
17 |
18 | page_header = Element.text(find(session, Query.css(".page_head")))
19 |
20 | assert page_header =~ ~r/1 post about #phoenix/
21 | assert find(session, Query.css("article.post"))
22 | assert page_title(session) == "Phoenix - Today I Learned"
23 | end
24 |
25 | test "the page has a list of paginated posts", %{session: session} do
26 | channel = Factory.insert!(:channel, name: "smalltalk")
27 |
28 | {:ok, inserted_at} = DateTime.from_naive(~N[2019-06-28 16:05:47], "Etc/UTC")
29 |
30 | Enum.each(1..6, fn x ->
31 | Factory.insert!(
32 | :post,
33 | title: "Title#{x}",
34 | body: "It starts with Rails and ends with Elixir",
35 | channel: channel,
36 | inserted_at: DateTime.add(inserted_at, x)
37 | )
38 | end)
39 |
40 | session
41 | |> visit("/smalltalk")
42 | |> assert_has(Query.css("article.post", count: 5))
43 | |> assert_has(Query.css("nav.pagination", visible: true))
44 | |> visit("/smalltalk?page=2")
45 | |> assert_has(Query.css("h1", text: "Title1", visible: true))
46 | |> assert_has(Query.css("article.post", count: 1))
47 | |> visit("/smalltalk")
48 | |> assert_has(Query.css("h1", text: "Title5", visible: true))
49 | |> assert_has(Query.css("article.post", count: 5))
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/test/features/visitor_views_developer_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Features.VisitorViewsDeveloper do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | alias TilexWeb.Endpoint
5 |
6 | test "and sees the developer's posts", %{session: session} do
7 | developer = Factory.insert!(:developer, username: "makinpancakes")
8 | post = Factory.insert!(:post, developer: developer)
9 |
10 | session
11 | |> visit(post_path(Endpoint, :show, post))
12 | |> find(Query.css("body"))
13 |
14 | visit(session, developer_path(Endpoint, :show, developer))
15 | page_header = Element.text(find(session, Query.css(".page_head")))
16 |
17 | assert page_header =~ ~r/1 post by makinpancakes/
18 | assert find(session, Query.css("article.post"))
19 | assert page_title(session) == "makinpancakes - Today I Learned"
20 | end
21 |
22 | test "and sees a prolific developer's posts", %{session: session} do
23 | developer = Factory.insert!(:developer, username: "banjocardhush")
24 |
25 | Factory.insert_list!(:post, 7, developer: developer)
26 |
27 | visit(session, developer_path(Endpoint, :show, developer))
28 |
29 | page_header = Element.text(find(session, Query.css(".page_head")))
30 | assert page_header =~ ~r/7 posts by banjocardhush/
31 | end
32 |
33 | test "and sees the developer's twitter when set", %{session: session} do
34 | developer = Factory.insert!(:developer, twitter_handle: "makinbaconpancakes")
35 |
36 | visit(session, developer_path(Endpoint, :show, developer))
37 |
38 | assert has?(session, Query.link("@makinbaconpancakes"))
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/test/features/visitor_views_error_page_test.exs:
--------------------------------------------------------------------------------
1 | defmodule VisitorViewsErrorPageTest do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | describe "pages early rejected by url rule" do
5 | test "shows not found error page", %{session: session} do
6 | session = visit(session, "/some-page.php")
7 | assert page_title(session) == "Not Found - Today I Learned"
8 |
9 | assert session
10 | |> find(Query.css("#home h1"))
11 | |> Element.text() == "…that there is no page at this URL!"
12 | end
13 | end
14 |
15 | describe "channel not found" do
16 | test "shows not found error page", %{session: session} do
17 | session = visit(session, "/missing-channel")
18 | assert page_title(session) == "Not Found - Today I Learned"
19 |
20 | assert session
21 | |> find(Query.css("#home h1"))
22 | |> Element.text() == "…that there is no page at this URL!"
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/test/features/visitor_views_rss_feed_test.exs:
--------------------------------------------------------------------------------
1 | defmodule VisitorViewsRSSFeed do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | test "via the legacy atom query parameter", %{session: session} do
5 | visit(session, "/?format=atom")
6 | assert current_path(session) == "/rss"
7 | end
8 |
9 | test "via an alternate RSS query parameter", %{session: session} do
10 | visit(session, "/?format=rss")
11 | assert current_path(session) == "/rss"
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/test/features/visitor_visits_homepage_test.exs:
--------------------------------------------------------------------------------
1 | defmodule VisitorVisitsHomepageTest do
2 | use Tilex.IntegrationCase, async: true
3 |
4 | test "the page does not have a Create Post link", %{session: session} do
5 | visit(session, "/")
6 |
7 | refute has?(session, Query.link("Create Post"))
8 | end
9 |
10 | test "the page has the appropriate branding", %{session: session} do
11 | header_text =
12 | session
13 | |> visit("/")
14 | |> find(Query.css("h1 > a"))
15 | |> Element.text()
16 |
17 | assert header_text =~ ~r/Today I Learned/i
18 |
19 | assert page_title(session) == "Hashrocket - Today I Learned"
20 | end
21 |
22 | test "the page has a list of posts", %{session: session} do
23 | channel = Factory.insert!(:channel, name: "smalltalk")
24 |
25 | Factory.insert!(
26 | :post,
27 | title: "A post about porting Rails applications to Phoenix",
28 | body: "It starts with Rails and ends with Elixir",
29 | channel: channel
30 | )
31 |
32 | visit(session, "/")
33 |
34 | element_text = fn session, selector ->
35 | Element.text(find(session, Query.css(selector)))
36 | end
37 |
38 | post_header = element_text.(session, "article h1")
39 | post_body = element_text.(session, "article")
40 | post_footer = element_text.(session, ".post aside")
41 |
42 | assert post_header == "A post about porting Rails applications to Phoenix"
43 | assert post_body =~ ~r/It starts with Rails and ends with Elixir/
44 | assert post_footer =~ ~r/#smalltalk/i
45 | end
46 |
47 | test "the page has a list of paginated posts", %{session: session} do
48 | Factory.insert_list!(:post, 5 + 1)
49 |
50 | visit(session, "/")
51 |
52 | assert find(session, Query.css("article.post", count: 5))
53 | assert find(session, Query.css("nav.pagination", visible: true))
54 |
55 | visit(session, "/?page=2")
56 |
57 | assert find(session, Query.css("article.post", count: 1))
58 |
59 | visit(session, "/")
60 |
61 | assert find(session, Query.css("article.post", count: 5))
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/test/lib/tilex/auth/error_handler_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Auth.ErrorHandlerTest do
2 | use TilexWeb.ConnCase, async: true
3 |
4 | alias Tilex.Auth.ErrorHandler
5 |
6 | setup %{conn: conn} do
7 | new_conn =
8 | conn
9 | |> Plug.Test.init_test_session(%{})
10 | |> Phoenix.ConnTest.fetch_flash()
11 |
12 | [conn: new_conn]
13 | end
14 |
15 | describe "auth_error/3" do
16 | test "redirects to root with 302", %{conn: conn} do
17 | assert conn
18 | |> ErrorHandler.auth_error({:oauth_failure, :token_expired}, [])
19 | |> redirected_to(302) == "/"
20 | end
21 |
22 | test "adds a flash message with error", %{conn: conn} do
23 | assert conn
24 | |> ErrorHandler.auth_error({:oauth_failure, :token_expired}, [])
25 | |> Phoenix.ConnTest.get_flash() == %{"info" => "Authentication required"}
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/test/lib/tilex/notifications/notifications_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.NotificationsTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Tilex.Notifications
5 |
6 | @data [
7 | %{
8 | time_zone: "America/Chicago",
9 | input: ~N[2021-09-20 08:59:59.999],
10 | expected: ~N[2021-09-20 09:00:00.000000]
11 | },
12 | %{
13 | time_zone: "America/Chicago",
14 | input: ~N[2021-09-20 09:00:00.000],
15 | expected: ~N[2021-09-27 09:00:00.000000]
16 | },
17 | %{
18 | time_zone: "Europe/Paris",
19 | input: ~N[2021-09-20 08:59:59.999],
20 | expected: ~N[2021-09-20 09:00:00.000000]
21 | },
22 | %{
23 | time_zone: "Europe/Paris",
24 | input: ~N[2021-09-20 09:00:00.000],
25 | expected: ~N[2021-09-27 09:00:00.000000]
26 | }
27 | ]
28 |
29 | describe "next_report_time/1" do
30 | for %{input: input, time_zone: time_zone, expected: expected} <- @data do
31 | @input input
32 | @time_zone time_zone
33 | @expected expected
34 |
35 | test "gets the next Monday at 9 am when '#{inspect(@input)}' for #{@time_zone}" do
36 | now = DateTime.from_naive!(@input, @time_zone)
37 | expected = DateTime.from_naive!(@expected, @time_zone)
38 | assert Notifications.next_report_time(now) == expected
39 | end
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/test/lib/tilex/plug/request_rejector_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Plug.RequestRejectorTest do
2 | use TilexWeb.ConnCase, async: true
3 |
4 | alias Tilex.Plug.RequestRejector
5 |
6 | @rejected_requests ~w[
7 | /xmlrpc.php
8 | /wp-login.php
9 | ]
10 |
11 | @successful_paths ~w[
12 | /posts/something
13 | ]
14 |
15 | describe "call/2" do
16 | for path <- @rejected_requests do
17 | @path path
18 |
19 | test "halts conn on bad request: '#{inspect(@path)}'" do
20 | conn =
21 | build_conn()
22 | |> Map.put(:request_path, @path)
23 | |> RequestRejector.call([])
24 |
25 | assert %Plug.Conn{halted: true, status: 404} = conn
26 | end
27 | end
28 |
29 | for path <- @successful_paths do
30 | @path path
31 |
32 | test "allows conn on ok request: '#{inspect(@path)}'" do
33 | conn =
34 | build_conn()
35 | |> Map.put(:request_path, @path)
36 | |> RequestRejector.call([])
37 |
38 | assert %Plug.Conn{halted: false, status: nil} = conn
39 | end
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/test/lib/tilex/plug/set_canonical_url_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Plug.SetCanonicalUrlTest do
2 | use TilexWeb.ConnCase, async: true
3 |
4 | alias Tilex.Plug.SetCanonicalUrl
5 |
6 | @base Application.compile_env(:tilex, :canonical_domain)
7 |
8 | @urls [
9 | {"/", %{}, "#{@base}/"},
10 | {"/", %{"foo" => "bar"}, "#{@base}/"},
11 | {"/123-some-post", %{}, "#{@base}/123-some-post"},
12 | {"/123-some-post", %{"foo" => "bar"}, "#{@base}/123-some-post"}
13 | ]
14 |
15 | describe "call/2" do
16 | for {path, query, canonical} <- @urls do
17 | @path path
18 | @query query
19 | @canonical canonical
20 |
21 | test "sets canonical url for: '#{@path}' with '#{inspect(@query)}'" do
22 | conn =
23 | :get
24 | |> build_conn(@path, @query)
25 | |> SetCanonicalUrl.call([])
26 |
27 | assert conn.assigns.canonical_url == @canonical
28 | end
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/test/lib/tilex/rate_limiter_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.RateLimiterTest do
2 | use ExUnit.Case, async: false
3 |
4 | alias Tilex.RateLimiter
5 | alias Tilex.DateTimeMock
6 |
7 | setup do
8 | RateLimiter.start_link([])
9 | :ok
10 | end
11 |
12 | test "it responds to valid calls with true" do
13 | DateTimeMock.start_link([])
14 | assert RateLimiter.check(ip: "abc.def.ghi.123")
15 | end
16 |
17 | test "it responds to invalid calls with false" do
18 | ip = "1.1.1.1"
19 | now = DateTime.utc_now()
20 |
21 | times =
22 | [25, 20, 15, 10, 5, 1]
23 | |> Enum.map(fn seconds -> Timex.subtract(now, Timex.Duration.from_seconds(seconds)) end)
24 |
25 | DateTimeMock.start_link(times)
26 |
27 | 1..5
28 | |> Enum.each(fn _ ->
29 | assert RateLimiter.check(ip: ip)
30 | end)
31 |
32 | assert RateLimiter.check(ip: ip) == false
33 | end
34 |
35 | test "it responds with true if requests are spread over more than 1 minute" do
36 | ip = "1.1.1.1"
37 | now = DateTime.utc_now()
38 |
39 | times =
40 | [62, 20, 15, 10, 5, 1]
41 | |> Enum.map(fn seconds -> Timex.subtract(now, Timex.Duration.from_seconds(seconds)) end)
42 |
43 | DateTimeMock.start_link(times)
44 |
45 | 1..5
46 | |> Enum.each(fn _ ->
47 | assert RateLimiter.check(ip: ip)
48 | end)
49 |
50 | assert RateLimiter.check(ip: ip) == true
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule TilexWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use TilexWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # Import conveniences for testing with connections
23 | import Plug.Conn
24 | import Phoenix.ConnTest
25 | import TilexWeb.ConnCase
26 |
27 | alias TilexWeb.Router.Helpers, as: Routes
28 |
29 | # The default endpoint for testing
30 | @endpoint TilexWeb.Endpoint
31 | end
32 | end
33 |
34 | setup tags do
35 | Tilex.DataCase.setup_sandbox(tags)
36 | {:ok, conn: Phoenix.ConnTest.build_conn()}
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | we enable the SQL sandbox, so changes done to the database
11 | are reverted at the end of every test. If you are using
12 | PostgreSQL, you can even run database tests asynchronously
13 | by setting `use Tilex.DataCase, async: true`, although
14 | this option is not recommended for other databases.
15 | """
16 |
17 | use ExUnit.CaseTemplate
18 |
19 | using do
20 | quote do
21 | alias Tilex.Repo
22 |
23 | import Ecto
24 | import Ecto.Changeset
25 | import Ecto.Query
26 | import Tilex.DataCase
27 | end
28 | end
29 |
30 | setup tags do
31 | Tilex.DataCase.setup_sandbox(tags)
32 | :ok
33 | end
34 |
35 | @doc """
36 | Sets up the sandbox based on the test tags.
37 | """
38 | def setup_sandbox(tags) do
39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Tilex.Repo, shared: not tags[:async])
40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
41 | end
42 |
43 | @doc """
44 | A helper that transforms changeset errors into a map of messages.
45 |
46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
47 | assert "password is too short" in errors_on(changeset).password
48 | assert %{password: ["password is too short"]} = errors_on(changeset)
49 |
50 | """
51 | def errors_on(changeset) do
52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
53 | Regex.replace(~r"%{(\w+)}", message, fn _, key ->
54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
55 | end)
56 | end)
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/test/support/date_time_mock.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.DateTimeMock do
2 | use Agent
3 |
4 | def start_link(initial_value) do
5 | Agent.start_link(fn -> initial_value end, name: __MODULE__)
6 | end
7 |
8 | def utc_now do
9 | Agent.get_and_update(__MODULE__, fn
10 | [] ->
11 | {Timex.now(), []}
12 |
13 | [current | rest] ->
14 | {current, rest}
15 | end)
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/support/factory.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Factory do
2 | alias Tilex.Blog.Channel
3 | alias Tilex.Blog.Developer
4 | alias Tilex.Blog.Post
5 | alias Tilex.Repo
6 | alias Tilex.Blog.Request
7 |
8 | import Ecto.Query
9 |
10 | def build(:channel) do
11 | %Channel{
12 | name: "phoenix",
13 | twitter_hashtag: "phoenix"
14 | }
15 | end
16 |
17 | def build(:post) do
18 | %Post{
19 | title: "A post",
20 | body: "A body",
21 | channel: find_first_or_build(:channel),
22 | developer: find_first_or_build(:developer),
23 | slug: Post.generate_slug()
24 | }
25 | end
26 |
27 | def build(:developer) do
28 | %Developer{
29 | email: "developer@hashrocket.com",
30 | username: "Ricky Rocketeer"
31 | }
32 | end
33 |
34 | def build(:request) do
35 | %Request{
36 | page: "/posts/159-default",
37 | request_time: Timex.now() |> DateTime.truncate(:second)
38 | }
39 | end
40 |
41 | def build(factory_name, attributes) do
42 | factory_name |> build() |> struct(attributes)
43 | end
44 |
45 | def insert!(factory_name, attributes \\ []) do
46 | Repo.insert!(build(factory_name, attributes))
47 | end
48 |
49 | def insert_list!(factory_name, count, attributes \\ []) do
50 | 1..count
51 | |> Enum.each(fn _i ->
52 | Repo.insert!(build(factory_name, attributes))
53 | end)
54 | end
55 |
56 | defp find_first_or_build(:channel) do
57 | Repo.one(from(Channel, limit: 1)) || build(:channel)
58 | end
59 |
60 | defp find_first_or_build(:developer) do
61 | Repo.one(from(Developer, limit: 1)) || build(:developer)
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/test/support/integration_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.IntegrationCase do
2 | use ExUnit.CaseTemplate
3 |
4 | using do
5 | quote do
6 | use Wallaby.DSL
7 | alias Wallaby.Query
8 | alias Wallaby.Element
9 |
10 | alias Tilex.Blog.Channel
11 | alias Tilex.Factory
12 | alias Tilex.Blog.Post
13 | alias Tilex.Repo
14 | alias TilexWeb.Endpoint
15 | import Ecto
16 | import Ecto.Changeset
17 | import Ecto.Query
18 |
19 | import TilexWeb.Router.Helpers
20 | import Tilex.WallabyTestHelpers
21 |
22 | def sign_in(session, developer) do
23 | visit(session, "/admin?id=#{developer.id}")
24 | end
25 |
26 | def authenticated_developer(%{session: session}) do
27 | developer = Factory.insert!(:developer, admin: true, username: "Rock Teer")
28 | [session: sign_in(session, developer), developer: developer]
29 | end
30 | end
31 | end
32 |
33 | setup tags do
34 | Tilex.DataCase.setup_sandbox(tags)
35 | metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(Tilex.Repo, self())
36 | {:ok, session} = Wallaby.start_session(metadata: metadata)
37 | {:ok, session: session}
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/test/support/pages/create_post_page.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Integration.Pages.CreatePostPage do
2 | use Wallaby.DSL
3 |
4 | def navigate(session) do
5 | visit(session, "/posts/new")
6 | end
7 |
8 | def ensure_page_loaded(session) do
9 | session
10 | |> assert_has(Query.css("main header h1", text: "Create Post"))
11 |
12 | session
13 | end
14 |
15 | def fill_in_form(session, fields \\ %{}) do
16 | session
17 | |> fill_in(Query.text_field("Title"), with: Map.get(fields, :title))
18 | |> fill_in(Query.text_field("Body"), with: Map.get(fields, :body))
19 | |> (fn session ->
20 | find(session, Query.select("Channel"), fn element ->
21 | click(element, Query.option(Map.get(fields, :channel)))
22 | end)
23 |
24 | session
25 | end).()
26 | end
27 |
28 | def submit_form(session) do
29 | session
30 | |> click(Query.button("Submit"))
31 | end
32 |
33 | def click_cancel(session) do
34 | session
35 | |> click(Query.link("cancel"))
36 | end
37 |
38 | def expect_form_has_error(session, error_text) do
39 | session
40 | |> assert_has(Query.css("form", text: error_text))
41 |
42 | session
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/test/support/pages/index_page.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Integration.Pages.IndexPage do
2 | use Wallaby.DSL
3 |
4 | def navigate(session) do
5 | visit(session, "/")
6 | end
7 |
8 | def ensure_page_loaded(session) do
9 | session
10 | |> Browser.find(Query.css("#home"))
11 |
12 | session
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/support/pages/navigation.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Integration.Pages.Navigation do
2 | use Wallaby.DSL
3 |
4 | def ensure_heading(session, text) do
5 | heading =
6 | session
7 | |> Browser.find(Query.css("header.site_head div h1"))
8 | |> Element.text()
9 |
10 | ExUnit.Assertions.assert(heading =~ ~r/#{text}/i, "Expected: #{text}, Found: #{heading}")
11 |
12 | session
13 | end
14 |
15 | def click_create_post(session) do
16 | click(session, Query.link("Create Post"))
17 | end
18 |
19 | @spec item_query() :: Query.t()
20 | def item_query(), do: Query.css("nav.admin_panel a")
21 | end
22 |
--------------------------------------------------------------------------------
/test/support/pages/post_form.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Integration.Pages.PostForm do
2 | use Wallaby.DSL
3 | import TilexWeb.Router.Helpers
4 | alias TilexWeb.Endpoint
5 |
6 | def navigate(session, post) do
7 | session
8 | |> visit(post_path(Endpoint, :edit, post))
9 | end
10 |
11 | def ensure_page_loaded(session) do
12 | session
13 | |> element_with_text?("main header h1", "Edit Post")
14 | end
15 |
16 | def expect_preview_content(session, tag, text) do
17 | session
18 | |> element_with_text?(".content_preview #{tag}", text)
19 | end
20 |
21 | def expect_word_count(session, word_count) do
22 | session
23 | |> element_with_text?(".word_count", word_count |> to_string)
24 | end
25 |
26 | def expect_words_left(session, text) do
27 | session
28 | |> element_with_text?(".word_limit", text)
29 | end
30 |
31 | def expect_title_characters_left(session, text) do
32 | session
33 | |> element_with_text?(".character_limit", text)
34 | end
35 |
36 | def expect_title_preview(session, title) do
37 | session
38 | |> element_with_text?(".title_preview", title)
39 | end
40 |
41 | defp element_with_text?(session, selector, text) do
42 | session
43 | |> assert_has(Query.css(selector, text: text))
44 |
45 | session
46 | end
47 |
48 | def fill_in_title(session, title) do
49 | session
50 | |> fill_in(Query.text_field("Title"), with: title)
51 | end
52 |
53 | def fill_in_body(session, body) do
54 | session
55 | |> fill_in(Query.text_field("Body"), with: body)
56 | end
57 |
58 | def select_channel(session, name) do
59 | session
60 | |> (fn session ->
61 | find(session, Query.select("Channel"), fn element ->
62 | click(element, Query.option(name))
63 | end)
64 |
65 | session
66 | end).()
67 | end
68 |
69 | def click_submit(session) do
70 | session
71 | |> click(Query.button("Submit"))
72 | end
73 |
74 | def expect_form_has_error(session, error_text) do
75 | session
76 | |> assert_has(Query.css("form", text: error_text))
77 |
78 | session
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/test/support/pages/post_show_page.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Integration.Pages.PostShowPage do
2 | use Wallaby.DSL
3 |
4 | def navigate(session, post) do
5 | visit(session, "/posts/#{post.slug}")
6 | end
7 |
8 | def ensure_page_loaded(session, title) do
9 | session
10 | |> Browser.find(Query.css("article.post"))
11 |
12 | session
13 | |> Browser.find(Query.css("h1", text: title))
14 |
15 | session
16 | end
17 |
18 | def ensure_info_flash(session, message) do
19 | session
20 | |> Browser.find(Query.css(".alert-info", text: message))
21 |
22 | session
23 | end
24 |
25 | def expect_post_attributes(session, attrs \\ %{}) do
26 | expected_title = Map.fetch!(attrs, :title)
27 | expected_body = Map.fetch!(attrs, :body)
28 | expected_channel = Map.fetch!(attrs, :channel)
29 | expected_likes_count = attrs |> Map.fetch!(:likes_count) |> to_string()
30 |
31 | session
32 | |> Browser.find(Query.css(".post h1", text: expected_title))
33 |
34 | session
35 | |> Browser.find(Query.css(".post .copy", text: expected_body))
36 |
37 | channel_name =
38 | session
39 | |> Browser.find(Query.css(".post aside .post__tag-link"))
40 | |> Element.text()
41 |
42 | ExUnit.Assertions.assert(
43 | channel_name =~ ~r/#{expected_channel}/i,
44 | "Unable to find text channel #{expected_channel}, instead found #{channel_name}"
45 | )
46 |
47 | session
48 | |> Browser.find(Query.css(".js-like-action", text: expected_likes_count))
49 |
50 | session
51 | end
52 |
53 | def click_edit(session) do
54 | click(session, Query.link("edit"))
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/test/support/wallaby_test_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Tilex.WallabyTestHelpers do
2 | use Wallaby.DSL
3 |
4 | alias Wallaby.Query
5 | alias Wallaby.Element
6 | alias Wallaby.Session
7 |
8 | def get_text(session, selector) do
9 | session |> find(Query.css(selector)) |> Element.text()
10 | end
11 |
12 | @spec get_texts(Session.t(), Query.t()) :: [String.t()]
13 | def get_texts(session, query) do
14 | session
15 | |> all(query)
16 | |> Enum.map(&Element.text/1)
17 | end
18 |
19 | def text_without_newlines(element) do
20 | String.replace(Element.text(element), "\n", " ")
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
3 | Ecto.Adapters.SQL.Sandbox.mode(Tilex.Repo, :manual)
4 |
5 | {:ok, _} = Application.ensure_all_started(:wallaby)
6 | Application.put_env(:wallaby, :base_url, TilexWeb.Endpoint.url())
7 |
--------------------------------------------------------------------------------
/test/tilex/blog/channel_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Blog.ChannelTest do
2 | use Tilex.DataCase, async: false
3 |
4 | alias Tilex.Blog.Channel
5 | alias Tilex.Factory
6 |
7 | @valid_attrs %{name: "phoenix", twitter_hashtag: "phoenix"}
8 | @invalid_attrs %{}
9 |
10 | test "changeset with valid attributes" do
11 | changeset = Channel.changeset(%Channel{}, @valid_attrs)
12 | assert changeset.valid?
13 | end
14 |
15 | test "changeset with invalid attributes" do
16 | changeset = Channel.changeset(%Channel{}, @invalid_attrs)
17 | refute changeset.valid?
18 | end
19 |
20 | test "it can return an alphabetized list of all records" do
21 | Enum.each(["zsh", "jekyll", "ada"], fn name ->
22 | Factory.insert!(:channel, name: name)
23 | end)
24 |
25 | query =
26 | Channel
27 | |> Channel.alphabetized()
28 |
29 | channels = Repo.all(query)
30 |
31 | names =
32 | Enum.map(channels, fn channel ->
33 | channel.name
34 | end)
35 |
36 | assert names == ["ada", "jekyll", "zsh"]
37 | end
38 |
39 | test "changeset with non-unique channel name" do
40 | Tilex.Repo.insert(Channel.changeset(%Channel{}, @valid_attrs))
41 | {:error, changeset} = Tilex.Repo.insert(Channel.changeset(%Channel{}, @valid_attrs))
42 |
43 | refute changeset.valid?
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/test/tilex/blog/developer_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Blog.DeveloperTest do
2 | use Tilex.DataCase, async: true
3 |
4 | alias Tilex.Blog.Developer
5 |
6 | test "can format its username" do
7 | username = "Johnny Appleseed"
8 | result = "johnnyappleseed"
9 | assert Developer.format_username(username) == result
10 | end
11 |
12 | test "changeset strips leading @ symbol from twitter handle" do
13 | changeset = Developer.changeset(%Developer{}, %{twitter_handle: "@tilex"})
14 | assert Ecto.Changeset.get_field(changeset, :twitter_handle) == "tilex"
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/tilex/blog/post_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Blog.PostTest do
2 | use Tilex.DataCase, async: true
3 |
4 | alias Tilex.Blog.Post
5 |
6 | @valid_attrs %{
7 | body: "some content",
8 | title: "some content",
9 | channel_id: 1,
10 | developer_id: 1
11 | }
12 |
13 | @invalid_attrs %{}
14 |
15 | @invalid_attrs_title %{
16 | body: "some content",
17 | title: "You'll never believe what a long, verbose title this is.",
18 | channel_id: 1
19 | }
20 |
21 | @invalid_attrs_body %{
22 | body: String.duplicate("wordy ", 205),
23 | title: "some content",
24 | channel_id: 1
25 | }
26 |
27 | test "changeset with valid attributes" do
28 | changeset = Post.changeset(%Post{}, @valid_attrs)
29 | assert changeset.valid?
30 | end
31 |
32 | test "changeset with invalid attributes" do
33 | changeset = Post.changeset(%Post{}, @invalid_attrs)
34 | refute changeset.valid?
35 | end
36 |
37 | test "changeset with title longer than 50 characters" do
38 | changeset = Post.changeset(%Post{}, @invalid_attrs_title)
39 | refute changeset.valid?
40 | end
41 |
42 | test "changeset with body longer than 200 words" do
43 | changeset = Post.changeset(%Post{}, @invalid_attrs_body)
44 | refute changeset.valid?
45 | end
46 |
47 | test "changeset generates slug" do
48 | changeset = Post.changeset(%Post{}, @valid_attrs)
49 | assert String.length(Ecto.Changeset.get_change(changeset, :slug)) == 10
50 | end
51 |
52 | test "changeset does not replace slug" do
53 | slug = "my_existing_slug"
54 | changeset = Post.changeset(%Post{slug: slug}, @valid_attrs)
55 | assert Ecto.Changeset.get_field(changeset, :slug) == slug
56 | end
57 |
58 | test "can slugify its own title" do
59 | title = "Hacking Your Shower!!!"
60 | result = "hacking-your-shower"
61 | assert Post.slugified_title(title) == result
62 | end
63 |
64 | test "generate_slug/0" do
65 | slug_regex = ~r/[a-z0-9]/
66 | slug = Post.generate_slug()
67 |
68 | assert String.length(slug) == 10
69 | assert String.match?(slug, slug_regex)
70 |
71 | another_slug = Post.generate_slug()
72 |
73 | assert String.length(another_slug) == 10
74 | assert String.match?(another_slug, slug_regex)
75 | refute slug == another_slug
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/test/tilex/notifications/notifiers/slack_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.Notifications.Notifiers.SlackTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Tilex.Notifications.Notifiers.Slack
5 | alias Tilex.Blog.Channel
6 | alias Tilex.Blog.Developer
7 | alias Tilex.Blog.Post
8 |
9 | defmodule HTTPMock do
10 | def request(
11 | :post,
12 | {~c"https://slack.test.com/abc/123", [], ~c"application/json", payload},
13 | [],
14 | []
15 | ) do
16 | {:ok, payload}
17 | end
18 | end
19 |
20 | describe "handle_post_created/5" do
21 | test "notifies post creation for simple post" do
22 | post = %Post{title: "simple post"}
23 | developer = %Developer{username: "vnegrisolo"}
24 | channel = %Channel{name: "ruby"}
25 | url = "http://til.test.com/abc123"
26 | message = "vnegrisolo created a new post in #ruby"
27 |
28 | assert Slack.handle_post_created(post, developer, channel, url, HTTPMock) ==
29 | {:ok, "{\"text\": \"#{message}\"}"}
30 | end
31 |
32 | test "notifies post creation for post with double quotes" do
33 | post = %Post{title: "post with \"double\" quote"}
34 | developer = %Developer{username: "vnegrisolo"}
35 | channel = %Channel{name: "ruby"}
36 | url = "http://til.test.com/abc123"
37 |
38 | message =
39 | "vnegrisolo created a new post in #ruby"
40 |
41 | assert Slack.handle_post_created(post, developer, channel, url, HTTPMock) ==
42 | {:ok, "{\"text\": \"#{message}\"}"}
43 | end
44 | end
45 |
46 | describe "handle_post_liked/4" do
47 | test "notifies post liked for simple post" do
48 | post = %Post{title: "simple post", max_likes: 20}
49 | developer = %Developer{username: "vnegrisolo"}
50 | url = "http://til.test.com/abc123"
51 |
52 | message =
53 | "vnegrisolo's post has 20 likes! :birthday: - "
54 |
55 | assert Slack.handle_post_liked(post, developer, url, HTTPMock) ==
56 | {:ok, "{\"text\": \"#{message}\"}"}
57 | end
58 |
59 | test "notifies post liked for post with double quotes" do
60 | post = %Post{title: "post with \"double\" quote", max_likes: 20}
61 | developer = %Developer{username: "vnegrisolo"}
62 | url = "http://til.test.com/abc123"
63 |
64 | message =
65 | "vnegrisolo's post has 20 likes! :birthday: - "
66 |
67 | assert Slack.handle_post_liked(post, developer, url, HTTPMock) ==
68 | {:ok, "{\"text\": \"#{message}\"}"}
69 | end
70 | end
71 |
72 | describe "handle_page_views_report/2" do
73 | test "notifies page views report" do
74 | report = """
75 | Best Day Last Week: 10
76 | Last Week: 20
77 | Best Day Week Before: 30
78 | Week Before: 40
79 | """
80 |
81 | assert Slack.handle_page_views_report(report, HTTPMock) ==
82 | {:ok, "{\"text\": \"#{report}\"}"}
83 | end
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/test/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.ErrorViewTest do
2 | use TilexWeb.ConnCase, async: true
3 |
4 | import Phoenix.View
5 |
6 | test "renders 404.html" do
7 | assert render_to_string(TilexWeb.ErrorView, "404.html", []) =~ "no page at this URL"
8 | end
9 |
10 | test "render 500.html" do
11 | assert render_to_string(TilexWeb.ErrorView, "500.html", []) =~ "something is broken"
12 | end
13 |
14 | test "render any other" do
15 | assert render_to_string(TilexWeb.ErrorView, "505.html", []) =~ "something is broken"
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/views/shared_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.SharedViewTest do
2 | use TilexWeb.ConnCase, async: true
3 |
4 | import TilexWeb.SharedView
5 |
6 | @insert_time %{inserted_at: ~U[2016-11-18 03:34:08.142287Z]}
7 |
8 | describe "display_date/1" do
9 | test "it produces a human-readable date in server's timezone" do
10 | assert display_date(@insert_time) == "November 17, 2016"
11 | end
12 | end
13 |
14 | describe "pluralize/2" do
15 | test "counts 1" do
16 | assert pluralize(1, "hippo") == "1 hippo"
17 | end
18 |
19 | test "counts 2" do
20 | assert pluralize(2, "hippo") == "2 hippos"
21 | end
22 | end
23 |
24 | describe "rss_date/1" do
25 | test "renders rfc 822 dates" do
26 | assert rss_date(@insert_time) == "Fri, 18 Nov 2016 03:34:08 GMT"
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/test/views/structured_data_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tilex.StructuredDataViewTest do
2 | use ExUnit.Case, async: true
3 | alias TilexWeb.StructuredDataView
4 |
5 | @ld_json_data [
6 | {%{}, "{}"},
7 | {%{foo: :bar}, "{\"foo\":\"bar\"}"},
8 | {%{foo: nil}, "{\"foo\":null}"},
9 | {%{foo: []}, "{\"foo\":[]}"},
10 | {%{foo: [1, "a"]}, "{\"foo\":[1,\"a\"]}"}
11 | ]
12 |
13 | describe "to_ld_json" do
14 | for {input, output} <- @ld_json_data do
15 | @input input
16 | @output output
17 |
18 | test "renders ld_json from data: '#{inspect(@input)}'" do
19 | assert StructuredDataView.to_ld_json(@input) == {:safe, @output}
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------