├── .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 |
3 |

4 | <%= channel_header(@posts_count, @channel, @random) %> 5 | <%= link to: Routes.channel_path(@conn, :random_by_channel, @channel.name) do %> 6 | <%= icon("surprise") %> 7 | <% end %> 8 |

9 |
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 |
4 |

My Profile

5 |
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 | 7 | <%= icon("twitter", :medium, "twitter @#{handle}") %> 8 | 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 | <![CDATA[<%= post.title %> - #<%= 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 | 5 | <% end %> 6 |
7 | 8 | <%= @inner_content %> 9 |
10 | -------------------------------------------------------------------------------- /lib/tilex_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 9 | 10 | <%= @inner_content %> 11 |
12 | -------------------------------------------------------------------------------- /lib/tilex_web/templates/layout/site_nav.html.eex: -------------------------------------------------------------------------------- 1 | 69 | -------------------------------------------------------------------------------- /lib/tilex_web/templates/post/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Edit Post

4 |
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 |
3 |

Create Post

4 |
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 |
39 |
40 |

41 |
42 |
43 | 46 |
47 |
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 | 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 | 28 |
29 |
30 | 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 | --------------------------------------------------------------------------------