├── .circleci └── config.yml ├── .credo.exs ├── .dockerignore ├── .env.dev ├── .env.prod ├── .formatter.exs ├── .gitignore ├── .sobelow-conf ├── .tool-versions ├── Dockerfile ├── Dockerfile-dev ├── LICENSE ├── README.md ├── assets ├── .babelrc ├── css │ ├── app.css │ └── attachment-list.css ├── js │ ├── app.js │ ├── currency-conversion.js │ ├── hooks │ │ ├── currency-mask.js │ │ ├── date-picker.js │ │ ├── focus-wrap.js │ │ ├── index.js │ │ ├── podcast-notifier.js │ │ ├── podcast-player.js │ │ └── restore-body-scroll.js │ └── leaflet │ │ ├── leaflet-icon.js │ │ ├── leaflet-map.js │ │ └── leaflet-marker.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── tailwind.config.js ├── vendor │ └── topbar.js └── webpack.config.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── docker-compose.prod.yml ├── docker-compose.yml ├── elixir_buildpack.config ├── entrypoint.sh ├── lib ├── guilda.ex ├── guilda │ ├── accounts.ex │ ├── accounts │ │ ├── events.ex │ │ ├── user.ex │ │ ├── user_notifier.ex │ │ ├── user_token.ex │ │ └── user_totp.ex │ ├── application.ex │ ├── audit_log.ex │ ├── bot.ex │ ├── extensions │ │ └── ecto │ │ │ └── ip_address.ex │ ├── finances.ex │ ├── finances │ │ ├── policy.ex │ │ └── transaction.ex │ ├── geo.ex │ ├── mailer.ex │ ├── podcasts.ex │ ├── podcasts │ │ ├── episode.ex │ │ └── policy.ex │ ├── postgres_types.ex │ ├── release.ex │ └── repo.ex ├── guilda_web.ex ├── guilda_web │ ├── channels │ │ └── presence.ex │ ├── components │ │ ├── badge.ex │ │ ├── button.ex │ │ ├── components.ex │ │ ├── dialog.ex │ │ ├── helpers.ex │ │ ├── icons.ex │ │ └── layout_components.ex │ ├── controllers │ │ ├── auth_controller.ex │ │ ├── feed_controller.ex │ │ ├── user_auth.ex │ │ ├── user_confirmation_controller.ex │ │ ├── user_registration_controller.ex │ │ ├── user_reset_password_controller.ex │ │ ├── user_session_controller.ex │ │ ├── user_settings_controller.ex │ │ └── user_totp_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── helpers.ex │ ├── live │ │ ├── episode_live │ │ │ ├── episode_component.ex │ │ │ ├── form_component.ex │ │ │ ├── form_component.html.heex │ │ │ ├── index.ex │ │ │ └── index.html.heex │ │ ├── finance_live │ │ │ ├── form_component.ex │ │ │ ├── form_component.html.heex │ │ │ ├── index.ex │ │ │ └── index.html.heex │ │ ├── home_live.ex │ │ ├── home_live.html.heex │ │ ├── members_live.ex │ │ ├── members_live.html.heex │ │ ├── menu_live.ex │ │ ├── menu_live.html.heex │ │ ├── online_members_live.ex │ │ ├── user_setting_live.ex │ │ ├── user_setting_live.html.heex │ │ └── user_settings_live │ │ │ ├── add_email_password_component.ex │ │ │ ├── change_email_component.ex │ │ │ └── totp_component.ex │ ├── mjml.ex │ ├── mount_hooks │ │ ├── init_assigns.ex │ │ ├── require_user.ex │ │ └── track_presence.ex │ ├── request_context.ex │ ├── router.ex │ ├── simple_s3_upload.ex │ ├── telemetry.ex │ ├── templates │ │ ├── feed │ │ │ └── index.xml.eex │ │ ├── layout │ │ │ ├── app.html.heex │ │ │ ├── email.html.heex │ │ │ ├── email.text.eex │ │ │ ├── live.html.heex │ │ │ ├── navbar.html.heex │ │ │ └── root.html.heex │ │ ├── user_confirmation │ │ │ ├── edit.html.heex │ │ │ └── new.html.heex │ │ ├── user_notifier │ │ │ ├── confirmation_instructions.html.heex │ │ │ ├── confirmation_instructions.text.eex │ │ │ ├── reset_password_instructions.html.heex │ │ │ ├── reset_password_instructions.text.eex │ │ │ ├── update_email_instructions.html.heex │ │ │ └── update_email_instructions.text.eex │ │ ├── user_registration │ │ │ └── new.html.heex │ │ ├── user_reset_password │ │ │ ├── edit.html.heex │ │ │ └── new.html.heex │ │ ├── user_session │ │ │ └── new.html.heex │ │ └── user_totp │ │ │ └── new.html.heex │ └── views │ │ ├── email_view.ex │ │ ├── error_view.ex │ │ ├── feed_view.ex │ │ ├── layout_view.ex │ │ ├── user_confirmation_view.ex │ │ ├── user_notifier_view.ex │ │ ├── user_registration_view.ex │ │ ├── user_reset_password_view.ex │ │ ├── user_session_view.ex │ │ ├── user_settings_view.ex │ │ ├── user_totp_view.ex │ │ └── view_helpers.ex └── sizeable.ex ├── mix.exs ├── mix.lock ├── phoenix_static_buildpack.config ├── priv ├── gettext │ ├── default.pot │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── default.po │ │ │ └── errors.po │ └── errors.pot ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20200905004133_enable_extensions.exs │ │ ├── 20200905004134_create_users_auth_tables.exs │ │ ├── 20200913191141_create_transactions.exs │ │ ├── 20210118225544_create_podcast_episodes.exs │ │ ├── 20210126233220_add_admin_flag_to_users.exs │ │ ├── 20220201042033_enable_postgis.exs │ │ ├── 20220201042045_add_geom_to_users.exs │ │ ├── 20220326135109_add_hashed_password_to_users.exs │ │ ├── 20220328022131_create_audit_logs.exs │ │ └── 20220328204819_create_users_totps.exs │ └── seeds.exs └── static │ ├── favicon.ico │ ├── images │ ├── guilda-logo.png │ └── guildacast.png │ └── robots.txt ├── rel └── overlays │ └── bin │ ├── migrate │ ├── migrate.bat │ ├── server │ └── server.bat └── test ├── guilda ├── accounts_test.exs ├── finances_test.exs ├── podcasts_test.exs ├── presence │ └── presence_client_test.exs └── sizeable_test.exs ├── guilda_web ├── controllers │ ├── auth_controller_test.exs │ ├── user_auth_test.exs │ ├── user_confirmation_controller_test.exs │ ├── user_registration_controller_test.exs │ ├── user_reset_password_controller_test.exs │ ├── user_session_controller_test.exs │ ├── user_settings_controller_test.exs │ └── user_totp_controller_test.exs ├── live │ ├── finance │ │ ├── finance_live_as_admin_test.exs │ │ ├── finance_live_as_guest_test.exs │ │ └── finance_live_as_user_test.exs │ ├── home_live_test.exs │ ├── podcast │ │ ├── podcast_episode_live_as_admin_test.exs │ │ ├── podcast_episode_live_as_guest_test.exs │ │ └── podcast_episode_live_as_user_test.exs │ └── user_settings_live_test.exs └── views │ ├── error_view_test.exs │ └── layout_view_test.exs ├── support ├── channel_case.ex ├── conn_case.ex ├── data_case.ex ├── fixtures │ ├── accounts_fixtures.ex │ ├── finances_fixtures.ex │ └── podcasts_fixtures.ex ├── presence │ ├── client_mock.ex │ └── presence_mock.ex └── swoosh_helpers.ex └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Elixir CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-elixir/ for more details 4 | version: 2 5 | 6 | defaults: &defaults 7 | working_directory: ~/guildatech 8 | docker: 9 | - image: cimg/elixir:1.11.3 10 | environment: 11 | MIX_ENV: test 12 | 13 | jobs: 14 | build: 15 | <<: *defaults 16 | steps: 17 | - checkout 18 | - restore_cache: 19 | keys: 20 | - v1-build-cache-{{ checksum "mix.lock" }} 21 | - v1-build-cache-{{ .Branch }} 22 | - v1-build-cache 23 | - run: mix local.hex --force 24 | - run: mix local.rebar --force 25 | - run: mix deps.get 26 | - run: mix compile --all-warnings --warnings-as-errors 27 | - save_cache: 28 | key: v1-build-cache-{{ checksum "mix.lock" }} 29 | paths: 30 | - _build 31 | - deps 32 | - ~/.mix 33 | - save_cache: 34 | key: v1-build-cache-{{ .Branch }} 35 | paths: 36 | - _build 37 | - deps 38 | - ~/.mix 39 | - persist_to_workspace: 40 | root: ~/ 41 | paths: 42 | - guildatech 43 | - .mix 44 | 45 | test: 46 | working_directory: ~/guildatech 47 | environment: 48 | MIX_ENV: test 49 | docker: 50 | - image: cimg/elixir:1.13.1 51 | - image: cimg/postgres:14.1-postgis 52 | parallelism: 1 53 | steps: 54 | - attach_workspace: 55 | at: ~/ 56 | - run: 57 | name: Wait for Postgres to start 58 | command: dockerize -wait tcp://localhost:5432 -timeout 1m 59 | - run: mix citest 60 | 61 | credo: 62 | <<: *defaults 63 | steps: 64 | - attach_workspace: 65 | at: ~/ 66 | - run: 67 | name: credo 68 | command: mix credo --strict --ignore Credo.Check.Readability.MaxLineLength, Credo.Check.Consistency.SpaceAroundOperators 69 | 70 | format: 71 | <<: *defaults 72 | steps: 73 | - attach_workspace: 74 | at: ~/ 75 | - run: 76 | name: format 77 | command: mix format --check-formatted 78 | 79 | sobelow: 80 | <<: *defaults 81 | steps: 82 | - attach_workspace: 83 | at: ~/ 84 | - run: 85 | name: sobelow 86 | command: mix sobelow --config 87 | 88 | dialyzer: 89 | <<: *defaults 90 | steps: 91 | - attach_workspace: 92 | at: ~/ 93 | - restore_cache: 94 | keys: 95 | - v1-plt-cache-{{ checksum "mix.lock" }} 96 | - v1-plt-cache-{{ .Branch }} 97 | - v1-plt-cache 98 | - run: mix dialyzer --format short 99 | - save_cache: 100 | key: v1-plt-cache-{{ checksum "mix.lock" }} 101 | paths: 102 | - "dialyzer.plt" 103 | - "dialyzer.plt.hash" 104 | - save_cache: 105 | key: v1-plt-cache-{{ .Branch }} 106 | paths: 107 | - "dialyzer.plt" 108 | - "dialyzer.plt.hash" 109 | 110 | workflows: 111 | version: 2 112 | ci: 113 | jobs: 114 | - build 115 | - test: 116 | requires: 117 | - build 118 | - credo: 119 | requires: 120 | - build 121 | - format: 122 | requires: 123 | - build 124 | - sobelow: 125 | requires: 126 | - build 127 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | # there are valid reasons to keep the .git, namely so that you can get the 3 | # current commit hash 4 | #.git 5 | .log 6 | tmp 7 | 8 | # Mix artifacts 9 | _build 10 | deps 11 | *.ez 12 | releases 13 | 14 | # Generate on crash by the VM 15 | erl_crash.dump 16 | 17 | # Static artifacts 18 | node_modules 19 | 20 | docker-compose.prod.yml 21 | docker-compose.sh 22 | docker-compose.yml 23 | Dockerfile 24 | Dockerfile-dev -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | TELEGRAM_BOT_USERNAME= 2 | TELEGRAM_BOT_TOKEN= 3 | AWS_BUCKET= 4 | AWS_REGION= 5 | AWS_ACCESS_KEY_ID= 6 | AWS_SECRET_ACCESS_KEY= 7 | MAPBOX_ACCESS_TOKEN= 8 | MAILGUN_API_KEY= 9 | MAILGUN_DOMAIN= -------------------------------------------------------------------------------- /.env.prod: -------------------------------------------------------------------------------- 1 | SECRET_KEY_BASE= 2 | DATABASE_URL= 3 | PHX_HOST= 4 | PHX_SERVER=true 5 | RELEASE_NAME=guilda-prod 6 | 7 | TELEGRAM_BOT_USERNAME= 8 | TELEGRAM_BOT_TOKEN= 9 | AWS_BUCKET= 10 | AWS_REGION= 11 | AWS_ACCESS_KEY_ID= 12 | AWS_SECRET_ACCESS_KEY= 13 | MAPBOX_ACCESS_TOKEN= 14 | MAILGUN_API_KEY= 15 | MAILGUN_DOMAIN= -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | plugins: [Phoenix.LiveView.HTMLFormatter], 3 | import_deps: [:ecto, :phoenix, :phoenix_live_view], 4 | inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"], 5 | subdirectories: ["priv/*/migrations"], 6 | line_length: 120 7 | ] 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | guilda-*.tar 24 | 25 | # If NPM crashes, it generates a log, let's ignore it too. 26 | npm-debug.log 27 | 28 | # The directory NPM downloads your dependencies sources to. 29 | /assets/node_modules/ 30 | 31 | # The directory used by ExUnit :tmp_dir 32 | /tmp/ 33 | 34 | # Ignore files generated by build tools 35 | /priv/static/js/ 36 | /priv/static/css/ 37 | 38 | # Jetbrains settings 39 | /.idea 40 | 41 | # Docker resources 42 | /.npm/ 43 | /.config/ 44 | 45 | # Ignore dialyzer 46 | /priv/plts/ 47 | 48 | # Ignore macOS trash 49 | .DS_Store 50 | 51 | # Ignore direnv file 52 | .envrc 53 | 54 | # Ignore VSCode workspace settings 55 | .vscode 56 | 57 | # Docker image 58 | .cache -------------------------------------------------------------------------------- /.sobelow-conf: -------------------------------------------------------------------------------- 1 | [ 2 | verbose: true, 3 | private: false, 4 | skip: true, 5 | router: "", 6 | exit: "low", 7 | format: "txt", 8 | out: "", 9 | threshold: "low", 10 | ignore: ["Config.HTTPS", "Config.CSWH"], 11 | ignore_files: [ 12 | "config/prod.secret.exs" 13 | ] 14 | ] 15 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.14.0-otp-25 2 | erlang 25.0.4 3 | nodejs 16.17.0 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of 2 | # Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # 8 | # This file is based on these images: 9 | # 10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image 12 | # - https://pkgs.org/ - resource for finding needed packages 13 | # - Ex: hexpm/elixir:1.13.0-erlang-24.1.7-debian-bullseye-20210902-slim 14 | # 15 | ARG BUILDER_IMAGE="hexpm/1.14.0-erlang-25.1-debian-bullseye-20220801-slim" 16 | ARG RUNNER_IMAGE="debian:bullseye-20220801-slim" 17 | 18 | FROM ${BUILDER_IMAGE} as builder 19 | 20 | # install build dependencies 21 | RUN apt-get update -y && apt-get install -y build-essential git nodejs npm \ 22 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 23 | 24 | # prepare build dir 25 | WORKDIR /app 26 | 27 | # install hex + rebar 28 | RUN mix local.hex --force && \ 29 | mix local.rebar --force 30 | 31 | COPY assets/package.json assets/package-lock.json ./assets/ 32 | RUN npm install --prefix assets 33 | 34 | # set build ENV 35 | ENV MIX_ENV="prod" 36 | 37 | # install mix dependencies 38 | COPY mix.exs mix.lock ./ 39 | RUN mix deps.get --only $MIX_ENV 40 | RUN mkdir config 41 | 42 | # copy compile-time config files before we compile dependencies 43 | # to ensure any relevant config change will trigger the dependencies 44 | # to be re-compiled. 45 | COPY config/config.exs config/${MIX_ENV}.exs config/ 46 | RUN mix deps.compile 47 | 48 | COPY priv priv 49 | 50 | # note: if your project uses a tool like https://purgecss.com/, 51 | # which customizes asset compilation based on what it finds in 52 | # your Elixir templates, you will need to move the asset compilation 53 | # step down so that `lib` is available. 54 | COPY assets assets 55 | 56 | # Compile the release 57 | COPY lib lib 58 | 59 | RUN mix compile && \ 60 | npm run deploy --prefix assets && \ 61 | mix phx.digest 62 | 63 | # Changes to config/runtime.exs don't require recompiling the code 64 | COPY config/runtime.exs config/ 65 | 66 | COPY rel rel 67 | RUN mix release 68 | 69 | # start a new build stage so that the final image will only contain 70 | # the compiled release and other runtime necessities 71 | FROM ${RUNNER_IMAGE} 72 | 73 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \ 74 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 75 | 76 | # Set the locale 77 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 78 | 79 | ENV LANG en_US.UTF-8 80 | ENV LANGUAGE en_US:en 81 | ENV LC_ALL en_US.UTF-8 82 | 83 | WORKDIR "/app" 84 | RUN chown nobody /app 85 | 86 | # Only copy the final release from the build stage 87 | COPY --from=builder --chown=nobody:root /app/_build/prod/rel/guilda ./ 88 | 89 | USER nobody 90 | 91 | CMD ["/app/bin/server"] 92 | -------------------------------------------------------------------------------- /Dockerfile-dev: -------------------------------------------------------------------------------- 1 | FROM bitwalker/alpine-elixir-phoenix:latest 2 | 3 | RUN \ 4 | apk update && \ 5 | apk --no-cache --update add \ 6 | postgresql-client \ 7 | rm -rf /var/cache/apk/* 8 | 9 | WORKDIR /opt/app 10 | 11 | ENTRYPOINT ["bash","entrypoint.sh"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 #GuildaTech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Guilda 2 | 3 | To start your Phoenix server: 4 | 5 | * Install dependencies with `mix deps.get` 6 | * Create and migrate your database with `mix ecto.setup` 7 | * Install Node.js dependencies with `npm install` inside the `assets` directory 8 | * Start Phoenix endpoint with `mix phx.server` 9 | 10 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 11 | 12 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 13 | 14 | ## Learn more 15 | 16 | * Official website: https://www.phoenixframework.org/ 17 | * Guides: https://hexdocs.pm/phoenix/overview.html 18 | * Docs: https://hexdocs.pm/phoenix 19 | * Forum: https://elixirforum.com/c/phoenix-forum 20 | * Source: https://github.com/phoenixframework/phoenix 21 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | @import "../node_modules/flatpickr/dist/flatpickr.min.css"; 3 | 4 | @import "tailwindcss/base"; 5 | @import "tailwindcss/components"; 6 | 7 | @import "attachment-list.css"; 8 | 9 | @import "tailwindcss/utilities"; 10 | 11 | html { 12 | line-height: 1.4; 13 | -webkit-text-size-adjust: 100%; 14 | overflow-x: hidden; 15 | @apply bg-gray-100; 16 | } 17 | 18 | html, 19 | body { 20 | position: absolute; 21 | width: 100%; 22 | min-height: 100%; 23 | } 24 | 25 | body { 26 | display: flex; 27 | flex-direction: column; 28 | font-size: 1rem; 29 | line-height: 1.25; 30 | } 31 | 32 | /* LiveView specific classes for your customizations */ 33 | .phx-no-feedback.invalid-feedback, 34 | .phx-no-feedback .invalid-feedback { 35 | display: none; 36 | } 37 | 38 | .phx-click-loading { 39 | opacity: 0.5; 40 | transition: opacity 1s ease-out; 41 | } 42 | 43 | .phx-disconnected { 44 | cursor: wait; 45 | } 46 | 47 | .phx-disconnected * { 48 | pointer-events: none; 49 | } 50 | 51 | .phx-modal { 52 | opacity: 1 important; 53 | position: fixed; 54 | z-index: 1; 55 | left: 0; 56 | top: 0; 57 | width: 100%; 58 | height: 100%; 59 | overflow: auto; 60 | background-color: rgb(0, 0, 0); 61 | background-color: rgba(0, 0, 0, 0.4); 62 | } 63 | 64 | .phx-modal-content { 65 | background-color: #fefefe; 66 | margin: 15% auto; 67 | padding: 20px; 68 | border: 1px solid #888; 69 | width: 80%; 70 | } 71 | 72 | .phx-modal-close { 73 | color: #aaa; 74 | float: right; 75 | font-size: 28px; 76 | font-weight: bold; 77 | } 78 | 79 | .phx-modal-close:hover, 80 | .phx-modal-close:focus { 81 | color: black; 82 | text-decoration: none; 83 | cursor: pointer; 84 | } 85 | 86 | /* Alerts and form errors */ 87 | .alert { 88 | padding: 15px; 89 | border: 1px solid transparent; 90 | border-radius: 4px; 91 | } 92 | 93 | .alert-info { 94 | color: #31708f; 95 | background-color: #d9edf7; 96 | border-color: #bce8f1; 97 | } 98 | 99 | .alert-warning { 100 | color: #8a6d3b; 101 | background-color: #fcf8e3; 102 | border-color: #faebcc; 103 | } 104 | 105 | .alert-danger { 106 | color: #a94442; 107 | background-color: #f2dede; 108 | border-color: #ebccd1; 109 | } 110 | 111 | .alert p { 112 | margin-bottom: 0; 113 | } 114 | 115 | .alert:empty { 116 | display: none; 117 | } 118 | 119 | *[hidden] { 120 | display: none; 121 | } 122 | 123 | .flatpickr-current-month .numInputWrapper { 124 | width: 6ch; 125 | } 126 | 127 | .select-wrapper select { 128 | @apply text-sm border-gray-300 rounded-md shadow-sm disabled:bg-gray-100 disabled:cursor-not-allowed focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:focus:border-primary-500 dark:bg-gray-800 dark:text-gray-300 focus:outline-none; 129 | } -------------------------------------------------------------------------------- /assets/css/attachment-list.css: -------------------------------------------------------------------------------- 1 | .AttachmentList { 2 | @apply mb-3 border border-gray-200 divide-y divide-gray-200 rounded-md; 3 | } 4 | 5 | .AttachmentList:empty { 6 | display: none; 7 | } 8 | 9 | .AttachmentList__item { 10 | @apply flex items-center justify-between py-3 pl-3 pr-4 text-sm; 11 | } 12 | 13 | .AttachmentList__wrapper { 14 | @apply flex items-center flex-1 w-0; 15 | } 16 | 17 | .AttachmentList__file-name { 18 | @apply flex-1 w-0 ml-2 truncate; 19 | } 20 | 21 | .AttachmentList__file-action-wrapper { 22 | @apply flex-shrink-0 ml-4; 23 | } 24 | 25 | .AttachmentList__file-action { 26 | @apply font-medium text-blue-600 hover:text-blue-500; 27 | } -------------------------------------------------------------------------------- /assets/js/currency-conversion.js: -------------------------------------------------------------------------------- 1 | const getDigitsFromValue = (value = '') => value.replace(/(-(?!\d))|[^0-9|-]/g, '') || '' 2 | 3 | const padDigits = digits => { 4 | const desiredLength = 3 5 | const actualLength = digits.length 6 | 7 | if (actualLength >= desiredLength) { 8 | return digits 9 | } 10 | 11 | const amountToAdd = desiredLength - actualLength 12 | const padding = '0'.repeat(amountToAdd) 13 | 14 | return padding + digits 15 | } 16 | 17 | const removeLeadingZeros = number => number.replace(/^0+([0-9]+)/, '$1') 18 | 19 | const addDecimalToNumber = (number, separator) => { 20 | const centsStartingPosition = number.length - 2 21 | const dollars = removeLeadingZeros( 22 | number.substring(0, centsStartingPosition) 23 | ) 24 | const cents = number.substring(centsStartingPosition) 25 | return dollars + separator + cents 26 | } 27 | 28 | export const toCurrency = (value, separator = '.') => { 29 | const digits = getDigitsFromValue(value) 30 | const digitsWithPadding = padDigits(digits) 31 | return addDecimalToNumber(digitsWithPadding, separator) 32 | } -------------------------------------------------------------------------------- /assets/js/hooks/currency-mask.js: -------------------------------------------------------------------------------- 1 | import { toCurrency } from "../currency-conversion"; 2 | 3 | export default { 4 | beforeUpdate() { 5 | this.el.value = toCurrency(this.el.value); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /assets/js/hooks/date-picker.js: -------------------------------------------------------------------------------- 1 | import flatpickr from "flatpickr"; 2 | 3 | export default { 4 | mounted() { 5 | this.setupDatePicker(this.el); 6 | }, 7 | 8 | updated() { 9 | this.setupDatePicker(this.el); 10 | }, 11 | 12 | setupDatePicker(el) { 13 | let opts = { 14 | altInput: true, 15 | altFormat: "d/m/Y", 16 | dateFormat: "Y-m-d", 17 | defaultDate: el.getAttribute("value"), 18 | enableTime: false, 19 | }; 20 | let removeMinDate = el.dataset.removeMinDate; 21 | 22 | if (removeMinDate === undefined) { 23 | opts.minDate = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000 * 365); 24 | } 25 | 26 | flatpickr(el, opts); 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /assets/js/hooks/focus-wrap.js: -------------------------------------------------------------------------------- 1 | // Accessible focus handling 2 | let Focus = { 3 | focusMain() { 4 | let target = document.querySelector("main h1") || document.querySelector("main"); 5 | if (target) { 6 | let origTabIndex = target.tabIndex; 7 | target.tabIndex = -1; 8 | target.focus(); 9 | target.tabIndex = origTabIndex; 10 | } 11 | }, 12 | // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document 13 | isFocusable(el) { 14 | if (el.tabIndex > 0 || (el.tabIndex === 0 && el.getAttribute("tabIndex") !== null)) { 15 | return true; 16 | } 17 | if (el.disabled) { 18 | return false; 19 | } 20 | 21 | switch (el.nodeName) { 22 | case "A": 23 | return !!el.href && el.rel !== "ignore"; 24 | case "INPUT": 25 | return el.type != "hidden" && el.type !== "file"; 26 | case "BUTTON": 27 | case "SELECT": 28 | case "TEXTAREA": 29 | return true; 30 | default: 31 | return false; 32 | } 33 | }, 34 | // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document 35 | attemptFocus(el) { 36 | if (!el) { 37 | return; 38 | } 39 | if (!this.isFocusable(el)) { 40 | return false; 41 | } 42 | try { 43 | el.focus(); 44 | } catch (e) {} 45 | 46 | return document.activeElement === el; 47 | }, 48 | // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document 49 | focusFirstDescendant(el) { 50 | for (let i = 0; i < el.childNodes.length; i++) { 51 | let child = el.childNodes[i]; 52 | if (this.attemptFocus(child) || this.focusFirstDescendant(child)) { 53 | return true; 54 | } 55 | } 56 | return false; 57 | }, 58 | // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document 59 | focusLastDescendant(element) { 60 | for (let i = element.childNodes.length - 1; i >= 0; i--) { 61 | let child = element.childNodes[i]; 62 | if (this.attemptFocus(child) || this.focusLastDescendant(child)) { 63 | return true; 64 | } 65 | } 66 | return false; 67 | }, 68 | }; 69 | 70 | // Accessible focus wrapping 71 | export default { 72 | mounted() { 73 | this.content = document.querySelector(this.el.getAttribute("data-content")); 74 | this.focusStart = this.el.querySelector(`#${this.el.id}-start`); 75 | this.focusEnd = this.el.querySelector(`#${this.el.id}-end`); 76 | this.focusStart.addEventListener("focus", () => Focus.focusLastDescendant(this.content)); 77 | this.focusEnd.addEventListener("focus", () => Focus.focusFirstDescendant(this.content)); 78 | this.content.addEventListener("phx:show-end", () => this.content.focus()); 79 | if (window.getComputedStyle(this.content).display !== "none") { 80 | Focus.focusFirstDescendant(this.content); 81 | } 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /assets/js/hooks/index.js: -------------------------------------------------------------------------------- 1 | import CurrencyMaskHook from "./currency-mask"; 2 | import DatePickerHook from "./date-picker"; 3 | import FocusWrapHook from "./focus-wrap"; 4 | import PodcastNotifierHook from "./podcast-notifier"; 5 | import PodcastPlayerHook from "./podcast-player"; 6 | import RestoreBodyScrollHook from "./restore-body-scroll"; 7 | 8 | export default { 9 | CurrencyMask: CurrencyMaskHook, 10 | DatePicker: DatePickerHook, 11 | FocusWrap: FocusWrapHook, 12 | PodcastNotifier: PodcastNotifierHook, 13 | PodcastPlayer: PodcastPlayerHook, 14 | RestoreBodyScroll: RestoreBodyScrollHook, 15 | }; 16 | -------------------------------------------------------------------------------- /assets/js/hooks/podcast-notifier.js: -------------------------------------------------------------------------------- 1 | export default { 2 | mounted() { 3 | this.handleEvent("episode-viewed", (episode) => { 4 | plausible("ListenedToEpisode", { props: { id: episode.id, slug: episode.slug } }); 5 | }); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /assets/js/hooks/podcast-player.js: -------------------------------------------------------------------------------- 1 | export default { 2 | mounted() { 3 | this._lastSecond = 0; 4 | 5 | this.el.addEventListener("timeupdate", (event) => { 6 | var currentTime = Math.round(event.target.currentTime); 7 | if (currentTime !== this._lastSecond && !event.target.paused) { 8 | this._lastSecond = currentTime; 9 | this.pushEventTo(`#${this.el.dataset.target}`, "play-second-elapsed", { 10 | time: currentTime, 11 | }); 12 | } 13 | }); 14 | 15 | this.el.addEventListener("play", (event) => { 16 | plausible("StartedListeningToEpisode", { 17 | props: { id: event.target.dataset.episodeId, slug: event.target.dataset.episodeSlug }, 18 | }); 19 | }); 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /assets/js/hooks/restore-body-scroll.js: -------------------------------------------------------------------------------- 1 | export default { 2 | destroyed() { 3 | document.body.classList.remove("overflow-hidden"); 4 | } 5 | }; -------------------------------------------------------------------------------- /assets/js/leaflet/leaflet-icon.js: -------------------------------------------------------------------------------- 1 | class LeafletIcon extends HTMLElement { 2 | constructor() { 3 | super(); 4 | 5 | this.attachShadow({ mode: "open" }); 6 | } 7 | 8 | static get observedAttributes() { 9 | return ["icon-url"]; 10 | } 11 | 12 | attributeChangedCallback(_name, _oldValue, newValue) { 13 | const event = new CustomEvent("url-updated", { detail: newValue }); 14 | this.dispatchEvent(event); 15 | } 16 | } 17 | 18 | window.customElements.define("leaflet-icon", LeafletIcon); 19 | -------------------------------------------------------------------------------- /assets/js/leaflet/leaflet-map.js: -------------------------------------------------------------------------------- 1 | import L from "leaflet"; 2 | 3 | const template = document.createElement("template"); 4 | template.innerHTML = ` 5 | 8 |
9 | 10 |
11 | `; 12 | 13 | class LeafletMap extends HTMLElement { 14 | constructor() { 15 | super(); 16 | 17 | this.attachShadow({ mode: "open" }); 18 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 19 | this.mapElement = this.shadowRoot.querySelector("div"); 20 | 21 | const accessToken = window.mapAccessToken; 22 | 23 | this.map = L.map(this.mapElement, { maxZoom: 15 }); 24 | this.markersLayer = L.featureGroup().addTo(this.map); 25 | 26 | L.tileLayer("https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}", { 27 | attribution: 28 | 'Map data © OpenStreetMap contributors, CC-BY-SA, Imagery © Mapbox', 29 | maxZoom: 18, 30 | id: "mapbox/streets-v11", 31 | tileSize: 512, 32 | zoomOffset: -1, 33 | accessToken: accessToken, 34 | }).addTo(this.map); 35 | 36 | this.defaultIcon = L.icon({ 37 | iconUrl: "/images/guilda-logo.png", 38 | iconSize: [64, 64], 39 | }); 40 | 41 | this.addEventListener("marker-added", (e) => { 42 | L.marker([e.detail.lat, e.detail.lng], { icon: this.defaultIcon }).addTo(this.markersLayer); 43 | const bounds = this.markersLayer.getBounds().pad(0.1); 44 | this.map.fitBounds(bounds); 45 | }); 46 | } 47 | } 48 | 49 | window.customElements.define("leaflet-map", LeafletMap); 50 | -------------------------------------------------------------------------------- /assets/js/leaflet/leaflet-marker.js: -------------------------------------------------------------------------------- 1 | class LeafletMarker extends HTMLElement { 2 | constructor() { 3 | super(); 4 | 5 | this.attachShadow({ mode: "open" }); 6 | } 7 | 8 | connectedCallback() { 9 | this.dispatchEvent( 10 | new CustomEvent("marker-added", { 11 | bubbles: true, 12 | detail: { lat: this.getAttribute("lat"), lng: this.getAttribute("lng") }, 13 | }) 14 | ); 15 | } 16 | } 17 | 18 | window.customElements.define("leaflet-marker", LeafletMarker); 19 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "description": " ", 4 | "license": "MIT", 5 | "scripts": { 6 | "deploy": "NODE_ENV=production webpack --mode production", 7 | "watch": "webpack --mode development --watch", 8 | "format": "prettier --trailing-comma es5 --write {js,test,css}/**/*.{js,json,css,scss,md} --no-error-on-unmatched-pattern", 9 | "format-check": "prettier --trailing-comma es5 --check {js,test,css}/**/*.{js,json,css,scss,md} --no-error-on-unmatched-pattern" 10 | }, 11 | "dependencies": { 12 | "alpinejs": "^2.8.0", 13 | "flatpickr": "^4.6.9", 14 | "leaflet": "^1.7.1", 15 | "phoenix": "file:../deps/phoenix", 16 | "phoenix_html": "file:../deps/phoenix_html", 17 | "phoenix_live_view": "file:../deps/phoenix_live_view", 18 | "remixicon": "^2.5.0" 19 | }, 20 | "devDependencies": { 21 | "@babel/core": "^7.14.0", 22 | "@babel/preset-env": "^7.14.1", 23 | "@tailwindcss/aspect-ratio": "^0.4.0", 24 | "@tailwindcss/forms": "^0.5.0", 25 | "@tailwindcss/typography": "^0.5.0", 26 | "autoprefixer": "^10.4.0", 27 | "babel-loader": "^8.2.2", 28 | "css-loader": "^6.4.0", 29 | "css-minimizer-webpack-plugin": "^3.0.2", 30 | "glob": "^8.0.3", 31 | "mini-css-extract-plugin": "^2.1.0", 32 | "postcss": "^8.2.3", 33 | "postcss-import": "^14.0.0", 34 | "postcss-loader": "^6.1.1", 35 | "tailwindcss": "^3.0.1", 36 | "webpack": "^5.37.0", 37 | "webpack-cli": "^4.7.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require("tailwindcss/colors"); 2 | const defaultTheme = require("tailwindcss/defaultTheme"); 3 | const plugin = require("tailwindcss/plugin"); 4 | 5 | module.exports = { 6 | content: [ 7 | "./js/**/*.js", 8 | "../lib/*_web.ex", 9 | "../lib/*_web/**/*.*ex" 10 | ], 11 | plugins: [ 12 | require("@tailwindcss/forms"), 13 | require("@tailwindcss/typography"), 14 | require("@tailwindcss/aspect-ratio"), 15 | plugin(({ addVariant }) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 16 | plugin(({ addVariant }) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 17 | plugin(({ addVariant }) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 18 | plugin(({ addVariant }) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])) 19 | ], 20 | darkMode: "class", 21 | theme: { 22 | extend: { 23 | fontFamily: { 24 | sans: ["Inter var", ...defaultTheme.fontFamily.sans], 25 | }, 26 | colors: { 27 | primary: colors.amber, 28 | brand: colors.amber, 29 | }, 30 | }, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const glob = require("glob"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); 5 | 6 | module.exports = (env, options) => { 7 | const devMode = options.mode !== "production"; 8 | 9 | return { 10 | mode: options.mode || "production", 11 | entry: { 12 | app: glob.sync("./vendor/**/*.js").concat(["./js/app.js"]), 13 | }, 14 | output: { 15 | filename: "[name].js", 16 | path: path.resolve(__dirname, "../priv/static/js"), 17 | publicPath: "/js/", 18 | }, 19 | devtool: devMode ? "eval-cheap-module-source-map" : undefined, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.js$/, 24 | exclude: /node_modules/, 25 | use: { 26 | loader: "babel-loader", 27 | }, 28 | }, 29 | { 30 | test: /\.[s]?css$/, 31 | use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"], 32 | }, 33 | { 34 | test: /\.(ttf|woff|woff2|eot|svg)$/, 35 | type: "asset/resource", 36 | }, 37 | ], 38 | }, 39 | plugins: [new MiniCssExtractPlugin({ filename: "../css/app.css" })], 40 | optimization: { 41 | minimizer: ["...", new CssMinimizerPlugin()], 42 | }, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :guilda, 11 | ecto_repos: [Guilda.Repo], 12 | generators: [binary_id: true] 13 | 14 | # Configures the endpoint 15 | config :guilda, GuildaWeb.Endpoint, 16 | url: [host: "localhost"], 17 | secret_key_base: "5wynN+FiYcqfUIm9PO+qzWCJOsOXTirWFMBh9qZ0rJ7n4C30Mw+hg5nH1wGhg057", 18 | render_errors: [view: GuildaWeb.ErrorView, accepts: ~w(html json), layout: false], 19 | pubsub_server: Guilda.PubSub, 20 | live_view: [signing_salt: "E3IrZAj7"] 21 | 22 | config :guilda, Guilda.Repo, 23 | types: Guilda.PostgresTypes, 24 | migration_timestamps: [type: :utc_datetime_usec], 25 | migration_primary_key: [ 26 | name: :id, 27 | type: :binary_id, 28 | autogenerate: false, 29 | read_after_writes: true, 30 | default: {:fragment, "gen_random_uuid()"} 31 | ], 32 | migration_foreign_key: [type: :binary_id] 33 | 34 | config :guilda, :auth, 35 | telegram_bot_username: System.get_env("TELEGRAM_BOT_USERNAME") || "the_bot_name", 36 | telegram_bot_token: System.get_env("TELEGRAM_BOT_TOKEN") 37 | 38 | # Configures Elixir's Logger 39 | config :logger, :console, 40 | format: "$time $metadata[$level] $message\n", 41 | metadata: [:request_id] 42 | 43 | # Use Jason for JSON parsing in Phoenix 44 | config :phoenix, :json_library, Jason 45 | 46 | config :ex_aws, 47 | access_key_id: [System.get_env("AWS_ACCESS_KEY_ID"), :instance_role], 48 | secret_access_key: [System.get_env("AWS_SECRET_ACCESS_KEY"), :instance_role], 49 | region: System.get_env("AWS_REGION"), 50 | json_codec: Jason 51 | 52 | config :guilda, :aws, 53 | bucket: System.get_env("S3_BUCKET"), 54 | region: System.get_env("AWS_REGION"), 55 | access_key_id: System.get_env("AWS_ACCESS_KEY_ID"), 56 | secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY") 57 | 58 | config :guilda, :maps, access_token: System.get_env("MAPBOX_ACCESS_TOKEN") 59 | 60 | config :ex_gram, adapter: ExGram.Adapter.Tesla 61 | 62 | config :gettext, :default_locale, "pt_BR" 63 | 64 | config :guilda, GuildaWeb.Gettext, 65 | split_module_by: [:locale], 66 | locales: ~w(pt_BR en) 67 | 68 | config :guilda, Guilda.Mailer, adapter: Swoosh.Adapters.Local 69 | 70 | # Import environment specific config. This must remain at the bottom 71 | # of this file so it overrides the configuration defined above. 72 | import_config "#{Mix.env()}.exs" 73 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :guilda, Guilda.Repo, 5 | types: Guilda.PostgresTypes, 6 | username: System.get_env("DB_USER", "postgres"), 7 | password: System.get_env("DB_PASS", "postgres"), 8 | database: System.get_env("DB_NAME", "guilda_dev"), 9 | hostname: System.get_env("DB_HOST", "guilda-database"), 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 webpack to recompile .js and .css sources. 19 | config :guilda, GuildaWeb.Endpoint, 20 | http: [port: 4001], 21 | debug_errors: true, 22 | code_reloader: true, 23 | check_origin: false, 24 | watchers: [ 25 | node: [ 26 | "node_modules/webpack/bin/webpack.js", 27 | "--mode", 28 | "development", 29 | "--watch", 30 | "--watch-options-stdin", 31 | cd: Path.expand("../assets", __DIR__) 32 | ] 33 | ] 34 | 35 | # ## SSL Support 36 | # 37 | # In order to use HTTPS in development, a self-signed 38 | # certificate can be generated by running the following 39 | # Mix task: 40 | # 41 | # mix phx.gen.cert 42 | # 43 | # Note that this task requires Erlang/OTP 20 or later. 44 | # Run `mix help phx.gen.cert` for more information. 45 | # 46 | # The `http:` config above can be replaced with: 47 | # 48 | # https: [ 49 | # port: 4001, 50 | # cipher_suite: :strong, 51 | # keyfile: "priv/cert/selfsigned_key.pem", 52 | # certfile: "priv/cert/selfsigned.pem" 53 | # ], 54 | # 55 | # If desired, both `http:` and `https:` keys can be 56 | # configured to run both http and https servers on 57 | # different ports. 58 | 59 | # Watch static and templates for browser reloading. 60 | config :guilda, GuildaWeb.Endpoint, 61 | live_reload: [ 62 | patterns: [ 63 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 64 | ~r"priv/gettext/.*(po)$", 65 | ~r"lib/guilda_web/(live|views)/.*(ex)$", 66 | ~r"lib/guilda_web/templates/.*(eex)$" 67 | ] 68 | ] 69 | 70 | config :guilda, :environment, :dev 71 | 72 | # Do not include metadata nor timestamps in development logs 73 | config :logger, :console, format: "[$level] $message\n" 74 | 75 | # Set a higher stacktrace during development. Avoid configuring such 76 | # in production as building large stacktraces may be expensive. 77 | config :phoenix, :stacktrace_depth, 20 78 | 79 | # Initialize plugs at runtime for faster development compilation 80 | config :phoenix, :plug_init_mode, :runtime 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 | config :guilda, GuildaWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 13 | 14 | # Do not print debug messages in production 15 | config :logger, level: :info 16 | 17 | config :swoosh, :api_client, Swoosh.ApiClient.Finch 18 | 19 | # ## SSL Support 20 | # 21 | # To get SSL working, you will need to add the `https` key 22 | # to the previous section and set your `:url` port to 443: 23 | # 24 | # config :guilda, GuildaWeb.Endpoint, 25 | # ..., 26 | # url: [host: "example.com", port: 443], 27 | # https: [ 28 | # ..., 29 | # port: 443, 30 | # cipher_suite: :strong, 31 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 32 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 33 | # ] 34 | # 35 | # The `cipher_suite` is set to `:strong` to support only the 36 | # latest and more secure SSL ciphers. This means old browsers 37 | # and clients may not be supported. You can set it to 38 | # `:compatible` for wider support. 39 | # 40 | # `:keyfile` and `:certfile` expect an absolute path to the key 41 | # and cert in disk or a relative path inside priv, for example 42 | # "priv/ssl/server.key". For all supported SSL configuration 43 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 44 | # 45 | # We also recommend setting `force_ssl` in your endpoint, ensuring 46 | # no data is ever sent via http, always redirecting to https: 47 | # 48 | # config :guilda, GuildaWeb.Endpoint, 49 | # force_ssl: [hsts: true] 50 | # 51 | # Check `Plug.SSL` for all available options in `force_ssl`. 52 | 53 | config :guilda, :environment, :prod 54 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Start the phoenix server if environment is set and running in a release 4 | if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do 5 | config :guilda, GuildaWeb.Endpoint, server: true 6 | end 7 | 8 | if config_env() == :prod do 9 | database_url = 10 | System.get_env("DATABASE_URL") || 11 | raise """ 12 | environment variable DATABASE_URL is missing. 13 | For example: ecto://USER:PASS@HOST/DATABASE 14 | """ 15 | 16 | maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] 17 | 18 | config :guilda, Guilda.Repo, 19 | # ssl: true, 20 | url: database_url, 21 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 22 | socket_options: maybe_ipv6 23 | 24 | # The secret key base is used to sign/encrypt cookies and other secrets. 25 | # A default value is used in config/dev.exs and config/test.exs but you 26 | # want to use a different value for prod and you most likely don't want 27 | # to check this value into version control, so we use an environment 28 | # variable instead. 29 | secret_key_base = 30 | System.get_env("SECRET_KEY_BASE") || 31 | raise """ 32 | environment variable SECRET_KEY_BASE is missing. 33 | You can generate one by calling: mix phx.gen.secret 34 | """ 35 | 36 | host = System.get_env("PHX_HOST") || "guildatech.com" 37 | port = String.to_integer(System.get_env("PORT") || "4000") 38 | 39 | config :guilda, GuildaWeb.Endpoint, 40 | url: [scheme: "https", host: host, port: 443], 41 | http: [ 42 | # Enable IPv6 and bind on all interfaces. 43 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 44 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 45 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 46 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 47 | port: port 48 | ], 49 | secret_key_base: secret_key_base 50 | 51 | telegram_bot_username = 52 | System.get_env("TELEGRAM_BOT_USERNAME") || 53 | raise """ 54 | environment variable TELEGRAM_BOT_USERNAME is missing. 55 | """ 56 | 57 | telegram_bot_token = 58 | System.get_env("TELEGRAM_BOT_TOKEN") || 59 | raise """ 60 | environment variable TELEGRAM_BOT_TOKEN is missing. 61 | """ 62 | 63 | aws_bucket = 64 | System.get_env("AWS_BUCKET") || 65 | raise """ 66 | environment variable AWS_BUCKET is missing. 67 | """ 68 | 69 | aws_region = 70 | System.get_env("AWS_REGION") || 71 | raise """ 72 | environment variable AWS_REGION is missing. 73 | """ 74 | 75 | aws_key_id = 76 | System.get_env("AWS_ACCESS_KEY_ID") || 77 | raise """ 78 | environment variable AWS_ACCESS_KEY_ID is missing. 79 | """ 80 | 81 | aws_secret = 82 | System.get_env("AWS_SECRET_ACCESS_KEY") || 83 | raise """ 84 | environment variable AWS_SECRET_ACCESS_KEY is missing. 85 | """ 86 | 87 | mapbox_access_token = 88 | System.get_env("MAPBOX_ACCESS_TOKEN") || 89 | raise """ 90 | environment variable MAPBOX_ACCESS_TOKEN is missing. 91 | """ 92 | 93 | mailgun_api_key = 94 | System.get_env("MAILGUN_API_KEY") || 95 | raise """ 96 | environment variable MAILGUN_API_KEY is missing. 97 | """ 98 | 99 | mailgun_domain = 100 | System.get_env("MAILGUN_DOMAIN") || 101 | raise """ 102 | environment variable MAILGUN_DOMAIN is missing. 103 | """ 104 | 105 | config :guilda, :auth, 106 | telegram_bot_username: telegram_bot_username, 107 | telegram_bot_token: telegram_bot_token 108 | 109 | config :guilda, :maps, access_token: mapbox_access_token 110 | 111 | config :guilda, :aws, 112 | bucket: aws_bucket, 113 | region: aws_region, 114 | access_key_id: aws_key_id, 115 | secret_access_key: aws_secret 116 | 117 | config :guilda, Guilda.Mailer, 118 | adapter: Swoosh.Adapters.Mailgun, 119 | api_key: mailgun_api_key, 120 | domain: mailgun_domain 121 | end 122 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Only in tests, remove the complexity from the password hashing algorithm 4 | config :bcrypt_elixir, :log_rounds, 1 5 | 6 | # Configure your database 7 | # 8 | # The MIX_TEST_PARTITION environment variable can be used 9 | # to provide built-in test partitioning in CI environment. 10 | # Run `mix help test` for more information. 11 | config :guilda, Guilda.Repo, 12 | types: Guilda.PostgresTypes, 13 | username: "postgres", 14 | password: "postgres", 15 | database: "guilda_test#{System.get_env("MIX_TEST_PARTITION")}", 16 | hostname: "localhost", 17 | pool: Ecto.Adapters.SQL.Sandbox 18 | 19 | # We don't run a server during test. If one is required, 20 | # you can enable the server option below. 21 | config :guilda, GuildaWeb.Endpoint, 22 | http: [port: 4002], 23 | server: false 24 | 25 | # Print only warnings and errors during test 26 | config :logger, level: :warn 27 | 28 | config :guilda, :environment, :test 29 | 30 | config :guilda, :maps, access_token: "not a token" 31 | 32 | config :gettext, :default_locale, "en" 33 | 34 | config :guilda, GuildaWeb.Gettext, locales: ~w(en) 35 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | volumes: 2 | guilda-postgres-data: 3 | external: true 4 | 5 | services: 6 | guilda-elixir: 7 | image: registry.loworbitlabs.com/guilda-elixir:latest 8 | restart: always 9 | container_name: guilda-elixir 10 | ports: 11 | - 4001:4000 12 | depends_on: 13 | - "guilda-database" 14 | env_file: 15 | - .env.prod 16 | # Database 17 | guilda-database: 18 | restart: always 19 | image: postgis/postgis:14-3.2 20 | container_name: guilda-database 21 | volumes: 22 | - guilda-postgres-data:/var/lib/postgresql/data 23 | environment: 24 | - POSTGRES_USER=postgres 25 | - POSTGRES_PASSWORD=postgres 26 | - POSTGRES_DB=guilda_prod 27 | ports: 28 | - 5434:5432 29 | networks: 30 | default: 31 | name: firefly-network 32 | external: true -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | networks: 2 | internal: 3 | driver: bridge 4 | 5 | volumes: 6 | guilda-postgres-data: 7 | driver: local 8 | 9 | services: 10 | guilda-elixir: 11 | build: 12 | context: . 13 | dockerfile: Dockerfile-dev 14 | container_name: guilda-elixir 15 | networks: 16 | - internal 17 | volumes: 18 | - .:/opt/app 19 | working_dir: /opt/app 20 | ports: 21 | - 4001:4001 22 | env_file: 23 | - .env.dev 24 | guilda-database: 25 | image: postgis/postgis:14-3.2 26 | container_name: guilda-database 27 | networks: 28 | - internal 29 | volumes: 30 | - guilda-postgres-data:/var/lib/postgresql/data 31 | environment: 32 | - POSTGRES_USER=postgres 33 | - POSTGRES_PASSWORD=postgres 34 | - POSTGRES_DB=guilda_dev 35 | ports: 36 | - 5433:5432 37 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | elixir_version=1.10.3 2 | erlang_version=22.3 3 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Docker entrypoint script. 3 | 4 | # Wait until Postgres is ready 5 | echo "Testing if Postgres is accepting connections. {$DB_HOST} {5432} ${DB_USER}" 6 | while ! pg_isready -q -h $DB_HOST -p 5432 -U $DB_USER 7 | do 8 | echo "$(date) - waiting for database to start" 9 | sleep 2 10 | done 11 | 12 | # Create, migrate, and seed database if it doesn't exist. 13 | if [[ -z `psql -Atqc "\\list $DB_NAME"` ]]; then 14 | echo "Database $DB_NAME does not exist. Creating..." 15 | mix ecto.create 16 | mix ecto.migrate 17 | mix run priv/repo/seeds.exs 18 | echo "Database $DB_NAME created." 19 | fi 20 | 21 | npm install --prefix assets && mix phx.server -------------------------------------------------------------------------------- /lib/guilda.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda do 2 | @moduledoc """ 3 | Guilda keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/guilda/accounts/events.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Accounts.Events do 2 | @moduledoc false 3 | 4 | defmodule LocationChanged do 5 | @moduledoc false 6 | defstruct user: nil 7 | end 8 | 9 | defmodule LocationAdded do 10 | @moduledoc false 11 | defstruct lat: nil, lng: nil 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/guilda/accounts/user_notifier.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Accounts.UserNotifier do 2 | @moduledoc """ 3 | This module contains all functions related to user notifications. 4 | """ 5 | use Phoenix.Swoosh, 6 | view: GuildaWeb.UserNotifierView, 7 | layout: {GuildaWeb.LayoutView, :email} 8 | 9 | alias Guilda.Mailer 10 | import GuildaWeb.Gettext 11 | 12 | # Delivers the email using the application mailer. 13 | defp deliver(recipient, subject, template, assigns) do 14 | email = 15 | new() 16 | |> to(recipient) 17 | |> from({"GuildaTech", "guildatech@loworbitlabs.com"}) 18 | |> subject(subject) 19 | |> render_body(template, assigns) 20 | |> GuildaWeb.Mjml.compile!() 21 | 22 | with {:ok, _metadata} <- Mailer.deliver(email) do 23 | {:ok, email} 24 | end 25 | end 26 | 27 | @doc """ 28 | Deliver instructions to confirm account. 29 | """ 30 | def deliver_confirmation_instructions(user, url) do 31 | deliver(user.email, gettext("Confirmation instructions"), :confirmation_instructions, %{user: user, url: url}) 32 | end 33 | 34 | @doc """ 35 | Deliver instructions to reset a user password. 36 | """ 37 | def deliver_reset_password_instructions(user, url) do 38 | deliver(user.email, gettext("Reset password instructions"), :reset_password_instructions, %{user: user, url: url}) 39 | end 40 | 41 | @doc """ 42 | Deliver instructions to update a user email. 43 | """ 44 | def deliver_update_email_instructions(user, url) do 45 | deliver(user.email, gettext("Update email instructions"), :update_email_instructions, %{user: user, url: url}) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/guilda/accounts/user_totp.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Accounts.UserTOTP do 2 | @moduledoc """ 3 | User TOTP schema and API functions. 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | @foreign_key_type :binary_id 10 | schema "users_totps" do 11 | field :secret, :binary 12 | field :code, :string, virtual: true 13 | belongs_to :user, Guilda.Accounts.User 14 | 15 | embeds_many :backup_codes, BackupCode, on_replace: :delete do 16 | field :code, :string 17 | field :used_at, :utc_datetime_usec 18 | end 19 | 20 | timestamps() 21 | end 22 | 23 | def changeset(totp, attrs) do 24 | changeset = 25 | totp 26 | |> cast(attrs, [:code]) 27 | |> validate_required([:code]) 28 | |> validate_format(:code, ~r/^\d{6}$/, message: "should be a 6 digit number") 29 | 30 | code = Ecto.Changeset.get_field(changeset, :code) 31 | 32 | if changeset.valid? and not valid_totp?(totp, code) do 33 | Ecto.Changeset.add_error(changeset, :code, "invalid code") 34 | else 35 | changeset 36 | end 37 | end 38 | 39 | def valid_totp?(totp, code) do 40 | is_binary(code) and byte_size(code) == 6 and NimbleTOTP.valid?(totp.secret, code) 41 | end 42 | 43 | def validate_backup_code(totp, code) when is_binary(code) do 44 | totp.backup_codes 45 | |> Enum.map_reduce(false, fn backup, valid? -> 46 | if Plug.Crypto.secure_compare(backup.code, code) and is_nil(backup.used_at) do 47 | {Ecto.Changeset.change(backup, %{used_at: DateTime.utc_now()}), true} 48 | else 49 | {backup, valid?} 50 | end 51 | end) 52 | |> case do 53 | {backup_codes, true} -> 54 | totp 55 | |> Ecto.Changeset.change() 56 | |> Ecto.Changeset.put_embed(:backup_codes, backup_codes) 57 | 58 | {_, false} -> 59 | nil 60 | end 61 | end 62 | 63 | def validate_backup_code(_totp, _code), do: nil 64 | 65 | def regenerate_backup_codes(changeset) do 66 | Ecto.Changeset.put_embed(changeset, :backup_codes, generate_backup_codes()) 67 | end 68 | 69 | def ensure_backup_codes(changeset) do 70 | case Ecto.Changeset.get_field(changeset, :backup_codes) do 71 | [] -> regenerate_backup_codes(changeset) 72 | _ -> changeset 73 | end 74 | end 75 | 76 | defp generate_backup_codes do 77 | for letter <- Enum.take_random(?A..?Z, 10) do 78 | suffix = 79 | :crypto.strong_rand_bytes(5) 80 | |> Base.encode32() 81 | |> binary_part(0, 7) 82 | 83 | # The first digit is always a letter so we can distinguish 84 | # in the UI between 6 digit TOTP codes and backup ones. 85 | # We also replace the letter O by X to avoid confusion with zero. 86 | code = String.replace(<>, "O", "X") 87 | %Guilda.Accounts.UserTOTP.BackupCode{code: code} 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/guilda/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | children = [ 10 | # Start the Ecto repository 11 | Guilda.Repo, 12 | # Start the Telemetry supervisor 13 | GuildaWeb.Telemetry, 14 | # Start the PubSub system 15 | {Phoenix.PubSub, name: Guilda.PubSub}, 16 | # Start presence 17 | GuildaWeb.Presence, 18 | # Start the Endpoint (http/https) 19 | GuildaWeb.Endpoint, 20 | # Start a worker by calling: Guilda.Worker.start_link(arg) 21 | # {Guilda.Worker, arg} 22 | # {Guilda.Bot, guilda_bot_config()} 23 | {Finch, name: Swoosh.Finch}, 24 | ExGram, 25 | {Guilda.Bot, guilda_bot_config()} 26 | ] 27 | 28 | # See https://hexdocs.pm/elixir/Supervisor.html 29 | # for other strategies and supported options 30 | opts = [strategy: :one_for_one, name: Guilda.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 | def config_change(changed, _new, removed) do 37 | GuildaWeb.Endpoint.config_change(changed, removed) 38 | :ok 39 | end 40 | 41 | if Application.compile_env(:guilda, :environment) == :test do 42 | def guilda_bot_config do 43 | [method: :noup, token: "token"] 44 | end 45 | else 46 | def guilda_bot_config do 47 | case Application.fetch_env!(:guilda, :auth)[:telegram_bot_token] do 48 | nil -> [method: :noup, token: "token"] 49 | token -> [method: :polling, token: token] 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/guilda/bot.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Bot do 2 | @moduledoc """ 3 | Our bot used to store the user's location. 4 | """ 5 | @bot :guilda 6 | use ExGram.Bot, 7 | name: @bot, 8 | setup_commands: true 9 | 10 | import GuildaWeb.Gettext 11 | alias Guilda.Accounts 12 | alias Guilda.Accounts.User 13 | alias Guilda.AuditLog 14 | require Logger 15 | 16 | command("start") 17 | command("help", description: "Mostra os comandos do bot.") 18 | 19 | middleware(ExGram.Middleware.IgnoreUsername) 20 | 21 | def bot, do: @bot 22 | 23 | def handle({:command, :start, _msg}, context) do 24 | answer(context, "Olá!") 25 | end 26 | 27 | def handle({:command, :help, _msg}, context) do 28 | answer( 29 | context, 30 | """ 31 | Eu sou o bot da @guildatech! 32 | 33 | Por enquanto eu não respondo a nenhum comando, mas você pode me enviar sua localização para aparecer no nosso mapa de participantes! 34 | 35 | Para isso, basta apenas me enviar sua localização usando o seu celular. 36 | 37 | Para ver quem já compartilhou a localização acesse https://guildatech.com/members. 38 | """ 39 | ) 40 | end 41 | 42 | def handle({:location, %{latitude: lat, longitude: lng}}, context) do 43 | from = context.update.message.from 44 | 45 | with {:user, %User{} = user} <- {:user, Accounts.get_user_by_telegram_id(Kernel.to_string(from.id))}, 46 | {:location, {:ok, _user}} <- {:location, Accounts.set_lng_lat(AuditLog.system(), user, lng, lat)} do 47 | answer( 48 | context, 49 | gettext("Your location has been saved successfully! See the map at https://guildatech.com/members.") 50 | ) 51 | else 52 | {:user, nil} -> 53 | answer( 54 | context, 55 | gettext( 56 | "You don't have an account yet. Please register at https://guildatech.com/users/register, confirm your email address and link your Telegram account." 57 | ) 58 | ) 59 | 60 | {:location, {:error, _changeset}} -> 61 | answer(context, gettext("Unable to save your location.")) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/guilda/extensions/ecto/ip_address.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Extensions.Ecto.IPAddress do 2 | @moduledoc false 3 | use Ecto.Type 4 | 5 | @impl true 6 | def type, do: :inet 7 | 8 | @impl true 9 | def cast(string) when is_binary(string) do 10 | parts = String.split(string, ".") 11 | 12 | case Enum.map(parts, &Integer.parse/1) do 13 | [{a, ""}, {b, ""}, {c, ""}, {d, ""}] 14 | when a in 0..255 and b in 0..255 and c in 0..255 and d in 0..255 -> 15 | {:ok, {a, b, c, d}} 16 | 17 | _ -> 18 | :error 19 | end 20 | end 21 | 22 | def cast(_), do: :error 23 | 24 | @impl true 25 | def dump({_, _, _, _} = address), do: {:ok, %Postgrex.INET{address: address}} 26 | def dump(_), do: :error 27 | 28 | @impl true 29 | def load(%Postgrex.INET{} = struct), do: {:ok, struct.address} 30 | def load(_), do: :error 31 | end 32 | -------------------------------------------------------------------------------- /lib/guilda/finances/policy.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Finances.Policy do 2 | @moduledoc """ 3 | Authorization policy for the Finances context. 4 | """ 5 | @behaviour Bodyguard.Policy 6 | 7 | alias Guilda.Accounts.User 8 | 9 | def authorize(:create_transaction, %User{is_admin: true}, _params), do: true 10 | 11 | def authorize(:update_transaction, %User{is_admin: true}, _params), do: true 12 | 13 | def authorize(:delete_transaction, %User{is_admin: true}, _params), do: true 14 | 15 | def authorize(:manage_transaction, %User{is_admin: true}, _params), do: true 16 | 17 | def authorize(_action, _user, _params), 18 | do: {:error, Err.wrap(mod: GuildaWeb.UserAuth, reason: :unauthorized)} 19 | end 20 | -------------------------------------------------------------------------------- /lib/guilda/finances/transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Finances.Transaction do 2 | @moduledoc """ 3 | Transaction schema. 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | @foreign_key_type :binary_id 10 | schema "transactions" do 11 | field :amount, :decimal, default: Decimal.new(0) 12 | field :date, :date 13 | field :note, :string 14 | field :payee, :string 15 | field :transaction_type, Ecto.Enum, values: ~w(inflow outflow)a, default: :inflow, virtual: true 16 | 17 | field :toggle, :boolean, default: false, virtual: true 18 | 19 | timestamps() 20 | end 21 | 22 | @doc false 23 | def changeset(transaction, attrs) do 24 | transaction 25 | |> cast(attrs, [:date, :toggle, :amount, :payee, :note]) 26 | |> validate_required([:date, :amount, :payee]) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/guilda/geo.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Geo do 2 | @moduledoc """ 3 | A module used to add an error margin to coordinates. 4 | """ 5 | 6 | @doc """ 7 | Returns a random lat/lng coordinate within the given radius 8 | of a location. 9 | 10 | Adapted from https://gis.stackexchange.com/questions/25877/generating-random-locations-nearby 11 | 12 | ## Example 13 | 14 | iex> random_nearby_lng_lat(39.74, -104.99, 3) 15 | {39.7494, -105.0014} 16 | """ 17 | def random_nearby_lng_lat(lng, lat, radiusKm, precision \\ 3) do 18 | radius_rad = radiusKm / 111.3 19 | 20 | u = :rand.uniform() 21 | v = :rand.uniform() 22 | 23 | w = radius_rad * :math.sqrt(u) 24 | t = 2 * :math.pi() * v 25 | 26 | x = w * :math.cos(t) 27 | y1 = w * :math.sin(t) 28 | 29 | # Adjust the x-coordinate for the shrinking 30 | # of the east-west distances 31 | lat_rads = lat / (180 / :math.pi()) 32 | x1 = x / :math.cos(lat_rads) 33 | 34 | new_lng = Float.round(lng + x1, precision) 35 | new_lat = Float.round(lat + y1, precision) 36 | 37 | {new_lng, new_lat} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/guilda/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Mailer do 2 | @moduledoc false 3 | use Swoosh.Mailer, otp_app: :guilda 4 | end 5 | -------------------------------------------------------------------------------- /lib/guilda/podcasts/episode.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Podcasts.Episode do 2 | @moduledoc """ 3 | Podcasts Episode schema. 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | @foreign_key_type :binary_id 10 | schema "podcast_episodes" do 11 | field :title, :string 12 | field :slug, :string 13 | field :description, :string 14 | field :hosts, :string 15 | field :aired_date, :date 16 | field :cover_url, :string 17 | field :cover_name, :string 18 | field :cover_type, :string 19 | field :cover_size, :integer 20 | field :file_url, :string 21 | field :file_name, :string 22 | field :file_type, :string 23 | field :file_size, :integer 24 | field :length, :integer, default: 0 25 | field :play_count, :integer, default: 0 26 | 27 | timestamps() 28 | end 29 | 30 | @changeset_attrs ~w(aired_date title description hosts slug cover_url cover_name cover_type cover_size file_url 31 | file_name file_type file_size length)a 32 | 33 | def changeset(episode, attrs) do 34 | episode 35 | |> cast(attrs, @changeset_attrs) 36 | |> validate_required(@changeset_attrs) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/guilda/podcasts/policy.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Podcasts.Policy do 2 | @moduledoc """ 3 | Authorization policy for the Podcasts context. 4 | """ 5 | @behaviour Bodyguard.Policy 6 | 7 | alias Guilda.Accounts.User 8 | 9 | def authorize(:create_episode, %User{is_admin: true}, _params), do: true 10 | 11 | def authorize(:update_episode, %User{is_admin: true}, _params), do: true 12 | 13 | def authorize(:delete_episode, %User{is_admin: true}, _params), do: true 14 | 15 | def authorize(_action, _user, _params), 16 | do: {:error, Err.wrap(mod: GuildaWeb.UserAuth, reason: :unauthorized)} 17 | end 18 | -------------------------------------------------------------------------------- /lib/guilda/postgres_types.ex: -------------------------------------------------------------------------------- 1 | Postgrex.Types.define( 2 | Guilda.PostgresTypes, 3 | [Geo.PostGIS.Extension] ++ Ecto.Adapters.Postgres.extensions() 4 | ) 5 | -------------------------------------------------------------------------------- /lib/guilda/release.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | @app :guilda 7 | 8 | def migrate do 9 | load_app() 10 | 11 | for repo <- repos() do 12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 13 | end 14 | end 15 | 16 | def rollback(repo, version) do 17 | load_app() 18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/guilda/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Repo do 2 | use Ecto.Repo, 3 | otp_app: :guilda, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /lib/guilda_web.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb 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 GuildaWeb, :controller 9 | use GuildaWeb, :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: GuildaWeb 23 | 24 | import Plug.Conn 25 | import GuildaWeb.Gettext 26 | alias GuildaWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/guilda_web/templates", 34 | namespace: GuildaWeb 35 | 36 | use Phoenix.Component 37 | 38 | # Import convenience functions from controllers 39 | import Phoenix.Controller, 40 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 41 | 42 | # Include shared imports and aliases for views 43 | unquote(view_helpers()) 44 | end 45 | end 46 | 47 | def live_view(opts \\ []) do 48 | quote do 49 | opts = Keyword.merge([layout: {GuildaWeb.LayoutView, :live}], unquote(opts)) 50 | use Phoenix.LiveView, opts 51 | 52 | def handle_info({:flash, key, message}, socket) do 53 | {:noreply, put_flash(socket, key, message)} 54 | end 55 | 56 | unquote(view_helpers()) 57 | end 58 | end 59 | 60 | def live_component do 61 | quote do 62 | use Phoenix.LiveComponent 63 | 64 | unquote(view_helpers()) 65 | end 66 | end 67 | 68 | def router do 69 | quote do 70 | use Phoenix.Router 71 | 72 | import Plug.Conn 73 | import Phoenix.Controller 74 | import Phoenix.LiveView.Router 75 | end 76 | end 77 | 78 | defp view_helpers do 79 | quote do 80 | # Use all HTML functionality (forms, tags, etc) 81 | use Phoenix.HTML 82 | 83 | # Import basic rendering functionality (render, render_layout, etc) 84 | import Phoenix.View 85 | 86 | import GuildaWeb.Components 87 | import GuildaWeb.Components.Badge 88 | import GuildaWeb.Components.Button 89 | import GuildaWeb.Components.Dialog 90 | import GuildaWeb.Gettext 91 | 92 | alias GuildaWeb.Icons 93 | alias GuildaWeb.Router.Helpers, as: Routes 94 | alias Phoenix.LiveView.JS 95 | end 96 | end 97 | 98 | @doc """ 99 | When used, dispatch to the appropriate controller/view/etc. 100 | """ 101 | defmacro __using__({which, opts}) when is_atom(which) do 102 | apply(__MODULE__, which, [opts]) 103 | end 104 | 105 | defmacro __using__(which) when is_atom(which) do 106 | apply(__MODULE__, which, []) 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/guilda_web/channels/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.Presence do 2 | @moduledoc """ 3 | Provides presence tracking to channels and processes. 4 | 5 | See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html) 6 | docs for more details. 7 | """ 8 | use Phoenix.Presence, 9 | otp_app: :guilda, 10 | pubsub_server: Guilda.PubSub, 11 | presence: __MODULE__ 12 | 13 | @pubsub Guilda.PubSub 14 | 15 | def track_user(current_user_id) do 16 | track( 17 | self(), 18 | "proxy:" <> topic(), 19 | current_user_id, 20 | %{} 21 | ) 22 | end 23 | 24 | def untrack_user(current_user_id) do 25 | untrack( 26 | self(), 27 | "proxy:" <> topic(), 28 | current_user_id 29 | ) 30 | end 31 | 32 | def init(_opts) do 33 | {:ok, %{}} 34 | end 35 | 36 | def handle_metas(topic, %{joins: joins, leaves: leaves}, presences, state) do 37 | for {user_id, presence} <- joins do 38 | user_data = %{user: presence.user, metas: Map.fetch!(presences, user_id)} 39 | broadcast(topic, {__MODULE__, %{user_joined: user_data}}) 40 | end 41 | 42 | for {user_id, presence} <- leaves do 43 | metas = 44 | case Map.fetch(presences, user_id) do 45 | {:ok, presence_metas} -> presence_metas 46 | :error -> [] 47 | end 48 | 49 | user_data = %{user: presence.user, metas: metas} 50 | 51 | broadcast(topic, {__MODULE__, %{user_left: user_data}}) 52 | end 53 | 54 | {:ok, state} 55 | end 56 | 57 | def subscribe_to_online_users do 58 | Phoenix.PubSub.subscribe(@pubsub, "proxy:" <> topic()) 59 | end 60 | 61 | def list_users(topic) do 62 | list("proxy:" <> topic) 63 | end 64 | 65 | def fetch(_topic, presences) do 66 | for {key, %{metas: metas}} <- presences, into: %{} do 67 | {key, %{metas: metas, user: key}} 68 | end 69 | end 70 | 71 | defp topic do 72 | "online_users" 73 | end 74 | 75 | defp broadcast("proxy:" <> topic, payload) do 76 | Phoenix.PubSub.broadcast(@pubsub, topic, payload) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/guilda_web/components/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.Components.Helpers do 2 | @moduledoc """ 3 | Provides general helpers used in components. 4 | """ 5 | 6 | @doc """ 7 | Remove newlines and extra spaces from a string 8 | """ 9 | def convert_string_to_one_line(string) when is_binary(string) do 10 | string 11 | |> String.replace("\r", " ") 12 | |> String.replace("\n", " ") 13 | |> String.split() 14 | |> Enum.join(" ") 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/guilda_web/controllers/auth_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.AuthController do 2 | use GuildaWeb, :controller 3 | 4 | alias Guilda.Accounts 5 | alias GuildaWeb.UserAuth 6 | 7 | # credo:disable-for-next-line 8 | def telegram_callback(conn, params) do 9 | case verify_telegram_data(params, get_session(conn, :telegram_bot_token)) do 10 | {:ok, params} -> 11 | user = conn.assigns.current_user 12 | user_from_telegram_params = Accounts.get_user_by_telegram_id(params["telegram_id"]) 13 | 14 | cond do 15 | user && user.telegram_id -> 16 | # User is signed in and already connected a Telegram account 17 | conn 18 | |> put_flash(:error, gettext("You already connected a Telegram account.")) 19 | |> redirect(to: Routes.user_settings_path(conn, :index)) 20 | 21 | user && !user_from_telegram_params -> 22 | # User is signed in and there's no other account with the same Telegram ID 23 | # credo:disable-for-next-line 24 | case Accounts.connect_provider(conn.assigns.audit_context, user, :telegram, params["telegram_id"]) do 25 | {:ok, _user} -> 26 | conn 27 | |> put_flash(:info, gettext("Successfully connected your Telegram account.")) 28 | |> redirect(to: Routes.user_settings_path(conn, :index)) 29 | 30 | _ -> 31 | conn 32 | |> put_flash(:error, gettext("Failed to connect your Telegram account.")) 33 | |> redirect(to: Routes.user_settings_path(conn, :index)) 34 | end 35 | 36 | !user && user_from_telegram_params -> 37 | # User is not signed in and there is an account for the given Telegram ID 38 | conn 39 | |> UserAuth.log_in_user(user_from_telegram_params) 40 | |> UserAuth.redirect_user_after_login() 41 | 42 | true -> 43 | # User is not signed in and there is no account for the given Telegram ID 44 | conn 45 | |> put_flash(:error, gettext("You must register or sign in before connecting a Telegram account.")) 46 | |> redirect(to: Routes.user_registration_path(conn, :new)) 47 | end 48 | 49 | _ -> 50 | conn 51 | |> put_flash(:error, gettext("Unable to authenticate. Please try again later.")) 52 | |> redirect(to: Routes.home_path(conn, :index)) 53 | end 54 | end 55 | 56 | def telegram_bot_username do 57 | Application.fetch_env!(:guilda, :auth)[:telegram_bot_username] 58 | end 59 | 60 | def telegram_bot_token do 61 | Application.fetch_env!(:guilda, :auth)[:telegram_bot_token] 62 | end 63 | 64 | if Application.compile_env(:guilda, :environment) == :dev do 65 | def verify_telegram_data(params, _token \\ nil) do 66 | {:ok, Map.put(params, "telegram_id", params["id"])} 67 | end 68 | else 69 | def verify_telegram_data(params, token \\ nil) do 70 | {hash, params} = Map.pop(params, "hash") 71 | 72 | secret_key = :crypto.hash(:sha256, token || telegram_bot_token()) 73 | 74 | data_check_string = Enum.map_join(params, "\n", fn {k, v} -> "#{k}=#{v}" end) 75 | 76 | hmac = :crypto.mac(:hmac, :sha256, secret_key, data_check_string) |> Base.encode16(case: :lower) 77 | 78 | if hmac == hash do 79 | {:ok, Map.put(params, "telegram_id", params["id"])} 80 | else 81 | {:error, :invalid_telegram_data} 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/guilda_web/controllers/feed_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.FeedController do 2 | use GuildaWeb, :controller 3 | 4 | alias Guilda.Podcasts 5 | 6 | def index(conn, _params) do 7 | render(conn, "index.xml", episodes: Podcasts.list_podcast_episodes()) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/guilda_web/controllers/user_confirmation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserConfirmationController do 2 | use GuildaWeb, :controller 3 | 4 | alias Guilda.Accounts 5 | 6 | def new(conn, _params) do 7 | render(conn, "new.html") 8 | end 9 | 10 | def create(conn, %{"user" => %{"email" => email}}) do 11 | if user = Accounts.get_user_by_email(email) do 12 | Accounts.deliver_user_confirmation_instructions( 13 | user, 14 | &Routes.user_confirmation_url(conn, :edit, &1) 15 | ) 16 | end 17 | 18 | conn 19 | |> put_flash( 20 | :info, 21 | gettext( 22 | "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." 23 | ) 24 | ) 25 | |> redirect(to: "/") 26 | end 27 | 28 | def edit(conn, %{"token" => token}) do 29 | render(conn, "edit.html", token: token) 30 | end 31 | 32 | # Do not log in the user after confirmation to avoid a 33 | # leaked token giving the user access to the account. 34 | def update(conn, %{"token" => token}) do 35 | case Accounts.confirm_user(token) do 36 | {:ok, _} -> 37 | conn 38 | |> put_flash(:info, "Account confirmed successfully.") 39 | |> redirect(to: "/") 40 | 41 | :error -> 42 | # If there is a current user and the account was already confirmed, 43 | # then odds are that the confirmation link was already visited, either 44 | # by some automation or by the user themselves, so we redirect without 45 | # a warning message. 46 | case conn.assigns do 47 | %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> 48 | redirect(conn, to: "/") 49 | 50 | %{} -> 51 | conn 52 | |> put_flash(:error, "Account confirmation link is invalid or it has expired.") 53 | |> redirect(to: "/") 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/guilda_web/controllers/user_registration_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserRegistrationController do 2 | use GuildaWeb, :controller 3 | 4 | alias Guilda.Accounts 5 | alias Guilda.Accounts.User 6 | alias GuildaWeb.UserAuth 7 | 8 | def new(conn, _params) do 9 | changeset = Accounts.change_user_registration(%User{}) 10 | render(conn, "new.html", changeset: changeset) 11 | end 12 | 13 | def create(conn, %{"user" => user_params}) do 14 | case Accounts.register_user(conn.assigns.audit_context, user_params) do 15 | {:ok, user} -> 16 | {:ok, _} = 17 | Accounts.deliver_user_confirmation_instructions( 18 | user, 19 | &Routes.user_confirmation_url(conn, :edit, &1) 20 | ) 21 | 22 | conn 23 | |> put_flash(:info, "Account created successfully.") 24 | |> UserAuth.log_in_user(user) 25 | |> UserAuth.redirect_user_after_login() 26 | 27 | {:error, %Ecto.Changeset{} = changeset} -> 28 | render(conn, "new.html", changeset: changeset) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/guilda_web/controllers/user_reset_password_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserResetPasswordController do 2 | use GuildaWeb, :controller 3 | 4 | alias Guilda.Accounts 5 | 6 | plug :get_user_by_reset_password_token when action in [:edit, :update] 7 | 8 | def new(conn, _params) do 9 | render(conn, "new.html") 10 | end 11 | 12 | def create(conn, %{"user" => %{"email" => email}}) do 13 | if user = Accounts.get_user_by_email(email) do 14 | Accounts.deliver_user_reset_password_instructions( 15 | conn.assigns.audit_context, 16 | user, 17 | &Routes.user_reset_password_url(conn, :edit, &1) 18 | ) 19 | end 20 | 21 | conn 22 | |> put_flash( 23 | :info, 24 | "If your email is in our system, you will receive instructions to reset your password shortly." 25 | ) 26 | |> redirect(to: "/") 27 | end 28 | 29 | def edit(conn, _params) do 30 | render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user)) 31 | end 32 | 33 | # Do not log in the user after reset password to avoid a 34 | # leaked token giving the user access to the account. 35 | def update(conn, %{"user" => user_params}) do 36 | case Accounts.reset_user_password(conn.assigns.audit_context, conn.assigns.user, user_params) do 37 | {:ok, _} -> 38 | conn 39 | |> put_flash(:info, "Password reset successfully.") 40 | |> redirect(to: Routes.user_session_path(conn, :new)) 41 | 42 | {:error, changeset} -> 43 | render(conn, "edit.html", changeset: changeset) 44 | end 45 | end 46 | 47 | defp get_user_by_reset_password_token(conn, _opts) do 48 | %{"token" => token} = conn.params 49 | 50 | if user = Accounts.get_user_by_reset_password_token(token) do 51 | conn |> assign(:user, user) |> assign(:token, token) 52 | else 53 | conn 54 | |> put_flash(:error, "Reset password link is invalid or it has expired.") 55 | |> redirect(to: "/") 56 | |> halt() 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/guilda_web/controllers/user_session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserSessionController do 2 | use GuildaWeb, :controller 3 | 4 | alias Guilda.Accounts 5 | alias Guilda.AuditLog 6 | alias GuildaWeb.UserAuth 7 | 8 | def new(conn, _params) do 9 | render(conn, "new.html", error_message: nil) 10 | end 11 | 12 | def create(conn, %{"user" => user_params}) do 13 | %{"email" => email, "password" => password} = user_params 14 | 15 | if user = Accounts.get_user_by_email_and_password(email, password) do 16 | audit_context = %{conn.assigns.audit_context | user: user} 17 | AuditLog.audit!(audit_context, "accounts.login", %{email: email}) 18 | conn = UserAuth.log_in_user(conn, user) 19 | 20 | if Accounts.get_user_totp(user) do 21 | totp_params = Map.take(user_params, ["remember_me"]) 22 | 23 | conn 24 | |> put_session(:user_totp_pending, true) 25 | |> redirect(to: Routes.user_totp_path(conn, :new, user: totp_params)) 26 | else 27 | UserAuth.redirect_user_after_login(conn, user_params) 28 | end 29 | else 30 | # In order to prevent user enumeration attacks, don't disclose whether the email is registered. 31 | render(conn, "new.html", error_message: gettext("Invalid email or password")) 32 | end 33 | end 34 | 35 | def delete(conn, _params) do 36 | conn 37 | |> put_flash(:info, "Signed out successfully.") 38 | |> UserAuth.log_out_user() 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/guilda_web/controllers/user_settings_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserSettingsController do 2 | use GuildaWeb, :controller 3 | 4 | alias Guilda.Accounts 5 | alias GuildaWeb.UserAuth 6 | 7 | def confirm_email(conn, %{"token" => token}) do 8 | case Accounts.update_user_email(conn.assigns.audit_context, conn.assigns.current_user, token) do 9 | :ok -> 10 | conn 11 | |> put_flash(:info, gettext("Email changed successfully.")) 12 | |> redirect(to: Routes.user_settings_path(conn, :index)) 13 | 14 | :error -> 15 | conn 16 | |> put_flash(:error, gettext("Email change link is invalid or it has expired.")) 17 | |> redirect(to: Routes.user_settings_path(conn, :index)) 18 | end 19 | end 20 | 21 | def update_password(conn, %{"current_password" => password, "user" => user_params}) do 22 | user = conn.assigns.current_user 23 | 24 | case Accounts.update_user_password(conn.assigns.audit_context, user, password, user_params) do 25 | {:ok, user} -> 26 | conn 27 | |> put_flash(:info, gettext("Password updated successfully.")) 28 | |> put_session(:user_return_to, Routes.user_settings_path(conn, :index)) 29 | |> UserAuth.log_in_user(user) 30 | 31 | _ -> 32 | conn 33 | |> put_flash(:error, gettext("We were unable to update your password. Please try again.")) 34 | |> redirect(to: Routes.user_settings_path(conn, :index)) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/guilda_web/controllers/user_totp_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserTOTPController do 2 | use GuildaWeb, :controller 3 | 4 | alias Guilda.Accounts 5 | alias GuildaWeb.UserAuth 6 | 7 | plug :redirect_if_totp_is_not_pending 8 | 9 | @pending :user_totp_pending 10 | 11 | def new(conn, _params) do 12 | render(conn, "new.html", error_message: nil) 13 | end 14 | 15 | def create(conn, %{"user" => user_params}) do 16 | audit_context = conn.assigns.audit_context 17 | current_user = conn.assigns.current_user 18 | 19 | case Accounts.validate_user_totp(audit_context, current_user, user_params["code"]) do 20 | :valid_totp -> 21 | conn 22 | |> delete_session(@pending) 23 | |> UserAuth.redirect_user_after_login(user_params) 24 | 25 | {:valid_backup_code, remaining} -> 26 | plural = ngettext("backup code", "backup codes", remaining) 27 | 28 | conn 29 | |> delete_session(@pending) 30 | |> put_flash( 31 | :info, 32 | "You have #{remaining} #{plural} left. " <> 33 | "You can generate new ones under the Two-factor authentication section in the Settings page" 34 | ) 35 | |> UserAuth.redirect_user_after_login(user_params) 36 | 37 | :invalid -> 38 | render(conn, "new.html", error_message: "Invalid two-factor authentication code") 39 | end 40 | end 41 | 42 | defp redirect_if_totp_is_not_pending(conn, _opts) do 43 | if get_session(conn, @pending) do 44 | conn 45 | else 46 | conn 47 | |> UserAuth.redirect_user_after_login() 48 | |> halt() 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/guilda_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :guilda 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_guilda_key", 10 | signing_salt: "JIeS5L0B", 11 | same_site: "Strict" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, 15 | websocket: [connect_info: [:x_headers, :user_agent, :peer_data, session: @session_options]] 16 | 17 | # Serve at "/" the static files from "priv/static" directory. 18 | # 19 | # You should set gzip to true if you are running phx.digest 20 | # when deploying your static files in production. 21 | plug Plug.Static, 22 | at: "/", 23 | from: :guilda, 24 | gzip: false, 25 | only: ~w(css js fonts images favicon.ico robots.txt) 26 | 27 | # Code reloading can be explicitly enabled under the 28 | # :code_reloader configuration of your endpoint. 29 | if code_reloading? do 30 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 31 | plug Phoenix.LiveReloader 32 | plug Phoenix.CodeReloader 33 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :guilda 34 | end 35 | 36 | plug Phoenix.LiveDashboard.RequestLogger, 37 | param_key: "request_logger", 38 | cookie_key: "request_logger" 39 | 40 | plug Plug.RequestId 41 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 42 | 43 | plug Plug.Parsers, 44 | parsers: [:urlencoded, :multipart, :json], 45 | pass: ["*/*"], 46 | json_decoder: Phoenix.json_library() 47 | 48 | plug Plug.MethodOverride 49 | plug Plug.Head 50 | plug Plug.Session, @session_options 51 | plug GuildaWeb.Router 52 | end 53 | -------------------------------------------------------------------------------- /lib/guilda_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.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 GuildaWeb.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: :guilda 24 | end 25 | -------------------------------------------------------------------------------- /lib/guilda_web/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.Helpers do 2 | @moduledoc """ 3 | General helpers used through the app. 4 | """ 5 | use Phoenix.Component 6 | import GuildaWeb.Gettext 7 | 8 | def table(assigns) do 9 | extra = assigns_to_attributes(assigns, [:class, :empty_state, :id, :row_id, :tbody_extra, :col, :rows]) 10 | 11 | assigns = 12 | assigns 13 | |> assign_new(:class, fn -> nil end) 14 | |> assign_new(:empty_state, fn -> gettext("No records to show.") end) 15 | |> assign_new(:id, fn -> false end) 16 | |> assign_new(:row_id, fn -> false end) 17 | |> assign_new(:tbody_extra, fn -> [] end) 18 | |> assign(:col, for(col <- assigns.col, col[:if] != false, do: col)) 19 | |> assign(:extra, extra) 20 | 21 | ~H""" 22 | 23 | 24 | 25 | <%= for col <- @col do %> 26 | 27 | <% end %> 28 | 29 | 30 | 31 | <%= if @rows == [] do %> 32 | 33 | 34 | 35 | <% end %> 36 | <%= for row <- @rows do %> 37 | 38 | <%= for col <- @col do %> 39 | 42 | <% end %> 43 | 44 | <% end %> 45 | 46 |
<%= col.label %>
<%= @empty_state %>
40 | <%= render_slot(col, row) %> 41 |
47 | """ 48 | end 49 | 50 | defp column_extra_attributes(col) do 51 | assigns_to_attributes(col, [:if, :class, :label]) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/guilda_web/live/episode_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.PodcastEpisodeLive.Index do 2 | @moduledoc """ 3 | LiveView Component to list podcast episodes. 4 | """ 5 | use GuildaWeb, :live_view 6 | 7 | alias Guilda.Podcasts 8 | alias Guilda.Podcasts.Episode 9 | 10 | @impl true 11 | def mount(_params, _session, socket) do 12 | {:ok, assign(socket, podcast_episodes: list_podcast_episodes())} 13 | end 14 | 15 | @impl true 16 | def handle_params(params, _url, socket) do 17 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 18 | end 19 | 20 | defp apply_action(socket, :index, _params) do 21 | socket 22 | |> assign(:page_title, "Quem Programa?") 23 | |> assign(:episode, nil) 24 | end 25 | 26 | defp apply_action(socket, :new, _params) do 27 | case Bodyguard.permit(Podcasts, :create_episode, socket.assigns.current_user) do 28 | :ok -> 29 | socket 30 | |> assign(:page_title, gettext("New Episode")) 31 | |> assign(:episode, %Episode{}) 32 | 33 | {:error, error} -> 34 | socket 35 | |> put_flash(:error, Err.message(error)) 36 | |> push_redirect(to: Routes.podcast_episode_index_path(socket, :index)) 37 | end 38 | end 39 | 40 | defp apply_action(socket, :edit, %{"id" => id}) do 41 | case Bodyguard.permit(Podcasts, :update_episode, socket.assigns.current_user) do 42 | :ok -> 43 | socket 44 | |> assign(:page_title, gettext("Edit Episode")) 45 | |> assign(:episode, Podcasts.get_episode!(id)) 46 | 47 | {:error, error} -> 48 | socket 49 | |> put_flash(:error, Err.message(error)) 50 | |> push_redirect(to: Routes.podcast_episode_index_path(socket, :index)) 51 | end 52 | end 53 | 54 | defp list_podcast_episodes do 55 | Podcasts.list_podcast_episodes() 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/guilda_web/live/episode_live/index.html.heex: -------------------------------------------------------------------------------- 1 | 2 |
3 | <.live_component 4 | :if={@live_action in [:new, :edit]} 5 | module={GuildaWeb.Podcasts.PodcastEpisodeLive.FormComponent} 6 | id={@episode.id || :new} 7 | title={@page_title} 8 | action={@live_action} 9 | current_user={@current_user} 10 | episode={@episode} 11 | return_to={Routes.podcast_episode_index_path(@socket, :index)} 12 | /> 13 | 14 |
15 |

16 | <%= gettext("Quem Programa?, Guilda's podcast") %> 17 |

18 |

19 | <%= gettext("Contribute financially to the Guilda's podcast at") %> 20 | 25 | https://apoia.se/guildatech 26 | 33 | 39 | 40 | 41 | 42 |

43 | <.button 44 | :if={Bodyguard.permit?(Podcasts, :create_episode, @current_user)} 45 | class="mt-3" 46 | patch={Routes.podcast_episode_index_path(@socket, :new)} 47 | > 48 | <%= gettext("New Episode") %> 49 | 50 |
51 | 52 |
53 | 54 |

55 | <%= gettext("No episodes to show.") %> 56 |

57 |
58 | 59 |
64 | <.live_component 65 | :for={episode <- @podcast_episodes} 66 | module={GuildaWeb.PodcastEpisodeLive.EpisodeComponent} 67 | id={"episode-card-#{episode.id}"} 68 | episode={episode} 69 | current_user={@current_user} 70 | /> 71 |
72 |
73 | 74 | -------------------------------------------------------------------------------- /lib/guilda_web/live/finance_live/form_component.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.FinanceLive.FormComponent do 2 | @moduledoc """ 3 | LiveView Component to display a form for a transaction. 4 | """ 5 | use GuildaWeb, :live_component 6 | 7 | alias Guilda.Finances 8 | 9 | @impl true 10 | def update(%{transaction: transaction} = assigns, socket) do 11 | changeset = Finances.change_transaction(transaction) |> set_transaction_type() 12 | 13 | {:ok, 14 | socket 15 | |> assign(assigns) 16 | |> assign(:changeset, changeset)} 17 | end 18 | 19 | @impl true 20 | def handle_event("validate", %{"transaction" => transaction_params}, socket) do 21 | changeset = 22 | socket.assigns.transaction 23 | |> Finances.change_transaction(toggle_amount_signal(transaction_params)) 24 | |> Map.put(:action, :validate) 25 | 26 | {:noreply, assign(socket, :changeset, changeset)} 27 | end 28 | 29 | def handle_event("save", %{"transaction" => transaction_params}, socket) do 30 | save_transaction(socket, socket.assigns.action, transaction_params) 31 | end 32 | 33 | defp save_transaction(socket, :edit, transaction_params) do 34 | case Finances.update_transaction( 35 | socket.assigns.current_user, 36 | socket.assigns.transaction, 37 | toggle_amount_signal(transaction_params) 38 | ) do 39 | {:ok, _transaction} -> 40 | {:noreply, 41 | socket 42 | |> put_flash(:info, "Transaction updated successfully.") 43 | |> push_redirect(to: socket.assigns.return_to)} 44 | 45 | {:error, %Ecto.Changeset{} = changeset} -> 46 | {:noreply, assign(socket, :changeset, changeset)} 47 | 48 | {:error, error} -> 49 | {:noreply, 50 | socket 51 | |> put_flash(:error, Err.message(error)) 52 | |> push_redirect(to: Routes.finance_index_path(socket, :index))} 53 | end 54 | end 55 | 56 | defp save_transaction(socket, :new, transaction_params) do 57 | case Finances.create_transaction(socket.assigns.current_user, toggle_amount_signal(transaction_params)) do 58 | {:ok, _transaction} -> 59 | {:noreply, 60 | socket 61 | |> put_flash(:info, "Transaction created successfully.") 62 | |> push_redirect(to: socket.assigns.return_to)} 63 | 64 | {:error, %Ecto.Changeset{} = changeset} -> 65 | {:noreply, assign(socket, changeset: changeset)} 66 | 67 | {:error, error} -> 68 | {:noreply, 69 | socket 70 | |> put_flash(:error, Err.message(error)) 71 | |> push_redirect(to: Routes.finance_index_path(socket, :index))} 72 | end 73 | end 74 | 75 | defp set_transaction_type(changeset) do 76 | negative_amount = Decimal.lt?(Ecto.Changeset.get_field(changeset, :amount), 0) 77 | 78 | if negative_amount do 79 | Ecto.Changeset.put_change(changeset, :transaction_type, :outflow) 80 | else 81 | Ecto.Changeset.put_change(changeset, :transaction_type, :inflow) 82 | end 83 | end 84 | 85 | defp toggle_amount_signal(attrs) do 86 | type = Map.get(attrs, "transaction_type") 87 | 88 | Map.update(attrs, "amount", "", fn 89 | "" -> 90 | "0.00" 91 | 92 | "0" -> 93 | "0.00" 94 | 95 | "0.00" -> 96 | "0.00" 97 | 98 | value when type == "inflow" -> 99 | String.replace(value, "-", "") 100 | 101 | value -> 102 | value = String.replace(value, "-", "") 103 | "-#{value}" 104 | end) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/guilda_web/live/finance_live/form_component.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <.modal id="transaction-form-modal" patch={@return_to} show prevent_click_away> 3 | <:title><%= @title %> 4 | <.form :let={f} for={@changeset} id="transaction-form" phx-target={@myself} phx-change="validate" phx-submit="save"> 5 |
6 | <.input type="text" field={{f, :payee}} label={gettext("Beneficiary")} /> 7 | <.input 8 | type="text" 9 | field={{f, :amount}} 10 | label={gettext("Amount")} 11 | phx-hook="CurrencyMask" 12 | inputmode="numeric" 13 | prefix="$" 14 | /> 15 | <.input type="radio" field={{f, :transaction_type}} label={gettext("Transaction type")}> 16 | <:entry label={gettext("Inflow")} value="inflow" /> 17 | <:entry label={gettext("Outflow")} value="outflow" /> 18 | 19 | <.input field={{f, :date}} type="datepicker" data-remove-min-date="true" label="Date" /> 20 | <.input field={{f, :note}} type="textarea" label={gettext("Note")} /> 21 |
22 | 23 | 24 | <:submit form="transaction-form"><%= gettext("Save") %> 25 | <:cancel><%= gettext("Cancel") %> 26 | 27 |
28 | -------------------------------------------------------------------------------- /lib/guilda_web/live/finance_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.FinanceLive.Index do 2 | @moduledoc """ 3 | LiveView Component to list transactions. 4 | """ 5 | use GuildaWeb, :live_view 6 | 7 | alias Guilda.Finances 8 | alias Guilda.Finances.Transaction 9 | 10 | on_mount GuildaWeb.MountHooks.RequireUser 11 | 12 | @impl true 13 | def mount(_params, _session, socket) do 14 | if connected?(socket), do: Finances.subscribe() 15 | 16 | {:ok, fetch_transactions(socket)} 17 | end 18 | 19 | @impl true 20 | def handle_params(params, _url, socket) do 21 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 22 | end 23 | 24 | defp apply_action(socket, :edit, %{"id" => id}) do 25 | case Bodyguard.permit(Finances, :update_transaction, socket.assigns.current_user) do 26 | :ok -> 27 | socket 28 | |> assign(:page_title, gettext("Edit Transaction")) 29 | |> assign(:transaction, Finances.get_transaction!(id)) 30 | 31 | {:error, error} -> 32 | socket 33 | |> put_flash(:error, Err.message(error)) 34 | |> push_patch(to: Routes.finance_index_path(socket, :index)) 35 | end 36 | end 37 | 38 | defp apply_action(socket, :new, _params) do 39 | case Bodyguard.permit(Finances, :create_transaction, socket.assigns.current_user) do 40 | :ok -> 41 | socket 42 | |> assign(:page_title, gettext("New Transaction")) 43 | |> assign(:transaction, %Transaction{}) 44 | 45 | {:error, error} -> 46 | socket 47 | |> put_flash(:error, Err.message(error)) 48 | |> push_patch(to: Routes.finance_index_path(socket, :index)) 49 | end 50 | end 51 | 52 | defp apply_action(socket, :index, _params) do 53 | socket 54 | |> assign(:page_title, gettext("Finances")) 55 | |> assign(:transaction, nil) 56 | end 57 | 58 | @impl Phoenix.LiveView 59 | def handle_event("delete", %{"id" => id}, socket) do 60 | transaction = Finances.get_transaction!(id) 61 | 62 | case Finances.delete_transaction(socket.assigns.current_user, transaction) do 63 | {:ok, _episode} -> 64 | {:noreply, put_flash(socket, :info, gettext("Transaction removed successfully."))} 65 | 66 | {:error, error} -> 67 | {:noreply, 68 | socket 69 | |> put_flash(:error, Err.message(error)) 70 | |> push_patch(to: Routes.finance_index_path(socket, :index))} 71 | end 72 | end 73 | 74 | @impl true 75 | def handle_info({:transaction_created, _transaction}, socket) do 76 | {:noreply, fetch_transactions(socket)} 77 | end 78 | 79 | def handle_info({:transaction_updated, _transaction}, socket) do 80 | {:noreply, fetch_transactions(socket)} 81 | end 82 | 83 | def handle_info({:transaction_deleted, _transaction}, socket) do 84 | {:noreply, fetch_transactions(socket)} 85 | end 86 | 87 | defp fetch_transactions(socket) do 88 | assign(socket, :transactions, Finances.list_transactions()) 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/guilda_web/live/finance_live/index.html.heex: -------------------------------------------------------------------------------- 1 | <.main_content title={gettext("Finances")} flash={@flash}> 2 | <:header_action :if={Bodyguard.permit?(Finances, :create_transaction, @current_user)}> 3 |
4 | 5 | <.button color="white" patch={Routes.finance_index_path(@socket, :new)}> 6 | <%= gettext("New Transaction") %> 7 | 8 | 9 |
10 | 11 | 12 |
13 | <.table id="transactions-table" rows={@transactions}> 14 | <:col :let={transaction} label={gettext("Date")}> 15 | <%= transaction.date %> 16 | 17 | 18 | <:col :let={transaction} label={gettext("Beneficiary")}> 19 | <%= transaction.payee %> 20 | 21 | 22 | <:col :let={transaction} label={gettext("Note")}> 23 | <%= transaction.note %> 24 | 25 | 26 | <:col :let={transaction} label={gettext("Amount")} align="right"> 27 | <%= transaction.amount %> 28 | 29 | 30 | <:action :let={transaction} :if={Bodyguard.permit?(Finances, :update_transaction, @current_user)}> 31 | <.button size="xs" variant="outline" patch={Routes.finance_index_path(@socket, :edit, transaction)}> 32 | <%= gettext("Edit") %> 33 | 34 | 35 | <:action :let={transaction} :if={Bodyguard.permit?(Finances, :delete_transaction, @current_user)}> 36 | <.button 37 | size="xs" 38 | color="danger" 39 | variant="outline" 40 | id={"delete-transaction-#{transaction.id}"} 41 | phx-click={show_modal("delete-modal-#{transaction.id}")} 42 | > 43 | <%= gettext("Delete") %> 44 | 45 | 46 | 47 |
48 | 49 |
50 | <.modal 51 | :for={transaction <- @transactions} 52 | type="delete" 53 | id={"delete-modal-#{transaction.id}"} 54 | on_confirm={ 55 | JS.push("delete", value: %{id: transaction.id}) 56 | |> hide_modal("delete-modal-#{transaction.id}") 57 | |> hide("#transaction-#{transaction.id}") 58 | } 59 | > 60 | <:title><%= gettext("Delete") %> 61 | <%= gettext("Are you sure you want to remove the record from %{date} with amount %{value}?", 62 | date: transaction.date, 63 | value: transaction.amount 64 | ) %> 65 | <:cancel><%= gettext("Cancel") %> 66 | <:confirm><%= gettext("Delete") %> 67 | 68 |
69 | 70 | <.live_component 71 | :if={@live_action in [:new, :edit]} 72 | module={GuildaWeb.FinanceLive.FormComponent} 73 | id={@transaction.id || :new} 74 | title={@page_title} 75 | action={@live_action} 76 | current_user={@current_user} 77 | transaction={@transaction} 78 | return_to={Routes.finance_index_path(@socket, :index)} 79 | /> 80 | 81 | -------------------------------------------------------------------------------- /lib/guilda_web/live/home_live.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.HomeLive do 2 | @moduledoc """ 3 | LiveView to generate static pages. 4 | """ 5 | use GuildaWeb, {:live_view, container: {:div, class: "flex flex-col flex-grow"}} 6 | alias Guilda.Podcasts 7 | 8 | @impl true 9 | def mount(_params, _session, socket) do 10 | socket = 11 | socket 12 | |> assign(:page_title, gettext("Home")) 13 | |> assign(:featured_episode, Podcasts.most_recent_episode()) 14 | 15 | {:ok, socket} 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/guilda_web/live/home_live.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |

7 | <%= gettext("Welcome to") %> 8 | GuildaTech 9 |

10 |

11 | <%= gettext( 12 | "We are an inclusive community where all kinds of issues are addressed, including programming. " 13 | ) %> 14 |

15 |
16 |
17 |
18 |
19 |
20 |
21 | 22 |
23 |
24 |
25 |
26 |
27 | 28 |
29 |
30 |

31 | <%= gettext("Quem Programa?") %> 32 |

33 |
34 | <%= live_redirect to: Routes.podcast_episode_index_path(@socket, :index), class: "font-medium text-yellow-500 hover:text-yellow-500" do %> 35 | <%= gettext("Listen to the new episode of the GuildaTech podcast") %> 36 | 37 | <% end %> 38 |
39 |
40 | 47 |
48 | 49 |
50 |

<%= gettext("Connect") %>

51 |
52 | <.link navigate="https://github.com/guildatech/" title="GitHub" class="text-yellow-300 hover:text-yellow-400"> 53 | 54 | 55 |
56 |
57 | <.link navigate="https://t.me/guildatech" title="Telegram" class="text-yellow-300 hover:text-yellow-400"> 58 | 59 | 60 |
61 |
62 | <.link navigate="https://twitter.com/guildatech" title="Twitter" class="text-yellow-300 hover:text-yellow-400"> 63 | 64 | 65 |
66 |
67 |
68 |
69 | -------------------------------------------------------------------------------- /lib/guilda_web/live/members_live.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.MembersLive do 2 | @moduledoc """ 3 | LiveView to display a map with all recorded user's locations. 4 | """ 5 | use GuildaWeb, :live_view 6 | 7 | alias Guilda.Accounts 8 | 9 | @impl true 10 | def mount(_params, _session, socket) do 11 | if connected?(socket) do 12 | Phoenix.PubSub.subscribe(Guilda.PubSub, "member_location") 13 | end 14 | 15 | {:ok, 16 | assign(socket, 17 | page_title: gettext("Members list"), 18 | markers: Accounts.list_users_locations(), 19 | bot_name: GuildaWeb.AuthController.telegram_bot_username() 20 | ), temporary_assigns: [markers: []]} 21 | end 22 | 23 | @impl true 24 | def handle_info({Accounts, %Accounts.Events.LocationAdded{} = update}, socket) do 25 | {:noreply, update(socket, :markers, fn markers -> [%{lat: update.lat, lng: update.lng} | markers] end)} 26 | end 27 | 28 | def handle_info({Accounts, _}, socket), do: {:noreply, socket} 29 | end 30 | -------------------------------------------------------------------------------- /lib/guilda_web/live/members_live.html.heex: -------------------------------------------------------------------------------- 1 | <.main_content title={gettext("Members list")} flash={@flash}> 2 | <%= if @markers == [] do %> 3 |
4 | 5 |

<%= gettext("No location shared") %>

6 |

7 | <%= gettext("Submit your location to our bot @%{bot_name} and be the first to appear on our member map!", 8 | bot_name: @bot_name 9 | ) %> 10 |

11 |
12 | <% else %> 13 |
14 | 15 | 16 | 17 |
18 | <% end %> 19 |
20 |

<%= gettext("How to share your location") %>

21 |

22 | <%= raw( 23 | gettext( 24 | "To register on the members map, 1) register your user account, 2) connect your Telegram account in the Settings page and 3) send your geographic location to our bot (%{link}). Sending location depends on the use of GPS, and only Telegram mobile clients (Android and iPhone) support sending coordinates.", 25 | link: safe_to_string(link("@#{@bot_name}", to: "https://t.me/#{@bot_name}")) 26 | ) 27 | ) %> 28 |

29 |

30 | <%= raw( 31 | gettext( 32 | "It is important to send a private message directly to our bot %{link} with your location. Don't send your location to the group!", 33 | link: safe_to_string(link("@#{@bot_name}", to: "https://t.me/#{@bot_name}")) 34 | ) 35 | ) %> 36 |

37 |

<%= gettext("To ensure your privacy, all shared locations are saved with a 10km error margin.") %>

38 |
39 | 40 | -------------------------------------------------------------------------------- /lib/guilda_web/live/menu_live.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.MenuLive do 2 | @moduledoc """ 3 | LiveView to generate the main navigation. 4 | """ 5 | use GuildaWeb, :live_view 6 | 7 | @impl true 8 | def mount(_params, %{"menu" => menu, "current_user" => current_user}, socket) do 9 | {:ok, 10 | assign(socket, 11 | menu: menu, 12 | current_user: current_user, 13 | entries: menu_entries(socket, menu, current_user) 14 | ), layout: {GuildaWeb.LayoutView, :navbar}} 15 | end 16 | 17 | def main_menu_entries(entries) do 18 | Enum.filter(entries, fn entry -> entry.show && entry.position == :main end) 19 | end 20 | 21 | def secondary_menu_entries(entries) do 22 | Enum.filter(entries, fn entry -> entry.show && entry.position == :secondary end) 23 | end 24 | 25 | def menu_entries(socket, menu, current_user) do 26 | [ 27 | %{ 28 | menu: menu, 29 | text: gettext("Podcast"), 30 | module: GuildaWeb.PodcastEpisodeLive, 31 | to: Routes.podcast_episode_index_path(socket, :index), 32 | show: true, 33 | position: :main 34 | }, 35 | %{ 36 | menu: menu, 37 | text: gettext("Members"), 38 | module: GuildaWeb.MapLive, 39 | to: Routes.members_path(socket, :show), 40 | show: true, 41 | position: :main 42 | }, 43 | %{ 44 | menu: menu, 45 | text: gettext("Finances"), 46 | module: GuildaWeb.FinanceLive, 47 | to: Routes.finance_index_path(socket, :index), 48 | show: logged_in?(current_user), 49 | position: :main 50 | }, 51 | %{ 52 | menu: menu, 53 | text: gettext("Settings"), 54 | module: GuildaWeb.UserSettingLive, 55 | to: Routes.user_settings_path(socket, :index), 56 | show: logged_in?(current_user), 57 | position: :main 58 | } 59 | ] 60 | end 61 | 62 | defp logged_in?(nil), do: false 63 | defp logged_in?(_user), do: true 64 | 65 | # Template helpers 66 | def maybe_active_live_redirect(%{menu: menu, text: text, module: module, to: route}, context) do 67 | classes = 68 | if to_string(menu.module) =~ to_string(module) do 69 | active_class(context) 70 | else 71 | inactive_class(context) 72 | end 73 | 74 | live_redirect(text, to: route, class: classes) 75 | end 76 | 77 | def menu_entry(assigns) do 78 | %{entry: %{menu: menu, module: module, to: _route}, context: context} = assigns 79 | 80 | class = 81 | if to_string(menu.module) =~ to_string(module) do 82 | active_class(context) 83 | else 84 | inactive_class(context) 85 | end 86 | 87 | assigns = assign(assigns, :class, class) 88 | 89 | ~H""" 90 | <.link navigate={@entry.to} class={@class}><%= @entry.text %> 91 | """ 92 | end 93 | 94 | defp active_class(:main) do 95 | "inline-flex items-center px-1 pt-1 border-b-2 border-yellow-500 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-yellow-700 transition duration-150 ease-in-out" 96 | end 97 | 98 | defp active_class(:mobile) do 99 | "block pl-3 pr-4 py-2 border-l-4 text-base font-medium text-gray-600 bg-yellow-50 border-yellow-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out" 100 | end 101 | 102 | defp inactive_class(:main) do 103 | "inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out" 104 | end 105 | 106 | defp inactive_class(:mobile) do 107 | "block pl-3 pr-4 py-2 border-l-4 border-transparent text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out" 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/guilda_web/live/online_members_live.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.OnlineMembersLive do 2 | @moduledoc """ 3 | LiveView to display the count of online users. 4 | """ 5 | use GuildaWeb, 6 | {:live_view, 7 | container: {:div, [class: "justify-end fixed inset-0 flex px-4 py-4 pointer-events-none sm:p-4 items-end"]}} 8 | 9 | on_mount GuildaWeb.MountHooks.InitAssigns 10 | 11 | @impl Phoenix.LiveView 12 | def render(assigns) do 13 | ~H""" 14 | <%= if connected?(@socket) do %> 15 | 16 | 17 | 18 | 19 | <%= @online_users_count %> <%= gettext("online") %> 20 | 21 | <% else %> 22 | 23 | 24 | 25 | 26 | <%= gettext("Connecting") %> 27 | 28 | <% end %> 29 | """ 30 | end 31 | 32 | @impl Phoenix.LiveView 33 | def mount(_params, _session, socket) do 34 | GuildaWeb.Presence.subscribe_to_online_users() 35 | 36 | if connected?(socket) do 37 | if user = socket.assigns[:current_user] do 38 | GuildaWeb.Presence.track_user(user.id) 39 | else 40 | if peer_data = get_connect_info(socket, :peer_data) do 41 | GuildaWeb.Presence.track_user(:inet.ntoa(peer_data.address)) 42 | end 43 | end 44 | end 45 | 46 | {:ok, count_users(socket), layout: false} 47 | end 48 | 49 | @impl Phoenix.LiveView 50 | def handle_info(_, socket) do 51 | {:noreply, count_users(socket)} 52 | end 53 | 54 | defp count_users(socket) do 55 | users_count = GuildaWeb.Presence.list_users("online_users") |> Map.keys() |> Kernel.length() 56 | assign(socket, :online_users_count, users_count) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/guilda_web/live/user_setting_live.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserSettingLive do 2 | @moduledoc """ 3 | LiveView to update a user's settings. 4 | """ 5 | use GuildaWeb, :live_view 6 | 7 | alias Guilda.Accounts 8 | 9 | on_mount GuildaWeb.MountHooks.RequireUser 10 | 11 | @impl Phoenix.LiveView 12 | def mount(_params, _session, socket) do 13 | if connected?(socket) do 14 | Accounts.subscribe(socket.assigns.current_user.id) 15 | end 16 | 17 | {:ok, 18 | assign(socket, 19 | password_changeset: Accounts.change_user_password(socket.assigns.current_user), 20 | is_legacy_account?: Accounts.is_legacy_account?(socket.assigns.current_user), 21 | password_trigger_action: false, 22 | current_password: nil, 23 | bot_name: GuildaWeb.AuthController.telegram_bot_username() 24 | )} 25 | end 26 | 27 | @impl Phoenix.LiveView 28 | def handle_params(_params, _url, socket) do 29 | {:noreply, assign(socket, :page_title, gettext("Settings"))} 30 | end 31 | 32 | @impl Phoenix.LiveView 33 | def handle_event( 34 | "validate-password", 35 | %{"current_password" => current_password, "user" => user_params}, 36 | socket 37 | ) do 38 | password_changeset = Accounts.change_user_password(socket.assigns.current_user, current_password, user_params) 39 | 40 | socket = 41 | socket 42 | |> assign(:current_password, current_password) 43 | |> assign(:password_changeset, password_changeset) 44 | 45 | {:noreply, socket} 46 | end 47 | 48 | def handle_event( 49 | "update-password", 50 | %{"current_password" => current_password, "user" => user_params}, 51 | socket 52 | ) do 53 | socket = assign(socket, :current_password, current_password) 54 | 55 | socket.assigns.current_user 56 | |> Accounts.apply_user_password(current_password, user_params) 57 | |> case do 58 | {:ok, _} -> 59 | {:noreply, assign(socket, :password_trigger_action, true)} 60 | 61 | {:error, password_changeset} -> 62 | {:noreply, assign(socket, :password_changeset, password_changeset)} 63 | end 64 | end 65 | 66 | def handle_event("remove-location", _params, socket) do 67 | user = socket.assigns.current_user 68 | 69 | case Accounts.remove_location(socket.assigns.audit_context, user) do 70 | {:ok, user} -> 71 | {:noreply, 72 | socket 73 | |> put_flash(:info, gettext("Your location was removed successfully.")) 74 | |> assign(:current_user, user)} 75 | 76 | {:error, _changeset} -> 77 | {:noreply, put_flash(socket, :error, gettext("Failed to remove your location."))} 78 | end 79 | end 80 | 81 | def handle_event("disconnect-telegram", _params, socket) do 82 | user = socket.assigns.current_user 83 | 84 | case Accounts.disconnect_provider(socket.assigns.audit_context, user, :telegram) do 85 | {:ok, user} -> 86 | {:noreply, 87 | socket 88 | |> put_flash(:info, gettext("Your Telegram account was successfully disconnected.")) 89 | |> assign(:current_user, user)} 90 | 91 | {:error, _changeset} -> 92 | {:noreply, put_flash(socket, :error, gettext("Failed to remove your location."))} 93 | end 94 | end 95 | 96 | def handle_event("resend-confirmation", _params, socket) do 97 | Accounts.deliver_user_confirmation_instructions( 98 | socket.assigns.current_user, 99 | &Routes.user_confirmation_url(socket, :edit, &1) 100 | ) 101 | 102 | {:noreply, 103 | put_flash(socket, :info, gettext("You will receive an email with instructions to confirm your account shortly."))} 104 | end 105 | 106 | @impl Phoenix.LiveView 107 | def handle_info({Accounts, %Accounts.Events.LocationChanged{} = update}, socket) do 108 | {:noreply, assign(socket, current_user: update.user)} 109 | end 110 | 111 | def handle_info({Accounts, _}, socket), do: {:noreply, socket} 112 | end 113 | -------------------------------------------------------------------------------- /lib/guilda_web/live/user_settings_live/add_email_password_component.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserSettingsLive.AddEmailPasswordComponent do 2 | @moduledoc """ 3 | Component used to add email/password to legacy accounts. 4 | """ 5 | use GuildaWeb, :live_component 6 | 7 | alias Guilda.Accounts 8 | 9 | @impl true 10 | def update(assigns, socket) do 11 | {:ok, 12 | socket 13 | |> assign(:audit_context, assigns.audit_context) 14 | |> assign(:current_user, assigns.current_user) 15 | |> assign(:changeset, Accounts.change_user_registration(assigns.current_user))} 16 | end 17 | 18 | @impl true 19 | def render(assigns) do 20 | ~H""" 21 |
22 | <.content_section 23 | title={gettext("Set Email and Password")} 24 | subtitle={gettext("Upgrade from a Telegram-only login to access enhanced features.")} 25 | } 26 | > 27 |
28 |
29 |
30 | 31 | 44 |
45 |
46 |

<%= gettext("Attention needed") %>

47 |
48 |

<%= gettext("You're signed in using an old account that does not have an email or password set.") %>

49 |

50 | <%= gettext("Please add an email to your account to access newer accounts feature like 2FA and Webauthn.") %> 51 |

52 |
53 |
54 |
55 |
56 | <.card class="mt-5"> 57 | <.form :let={f} id="add-email-form" for={@changeset} phx-submit="add-email" phx-target={@myself}> 58 |
59 |
60 |
61 | <.input field={{f, :email}} type="text" label={gettext("Email")} /> 62 | <.input field={{f, :password}} type="password" label={gettext("Password")} /> 63 |
64 |
65 |
66 | 67 | <:footer> 68 | <.button type="submit" form="add-email-form"><%= gettext("Save") %> 69 | 70 | 71 | 72 |
73 | """ 74 | end 75 | 76 | @impl true 77 | def handle_event( 78 | "add-email", 79 | %{"user" => user_params}, 80 | socket 81 | ) do 82 | user = socket.assigns.current_user 83 | 84 | case Accounts.set_email_and_password(socket.assigns.audit_context, user, user_params) do 85 | {:ok, user} -> 86 | Accounts.deliver_user_confirmation_instructions( 87 | user, 88 | &Routes.user_confirmation_url(socket, :edit, &1) 89 | ) 90 | 91 | {:noreply, 92 | socket 93 | |> put_flash( 94 | :info, 95 | gettext("A link to confirm your email change has been sent to the new address.") 96 | ) 97 | |> push_redirect(to: Routes.user_settings_path(socket, :index))} 98 | 99 | {:error, changeset} -> 100 | {:noreply, assign(socket, changeset: changeset)} 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/guilda_web/mjml.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.Mjml do 2 | @moduledoc """ 3 | This module uses the MJML Rust implementation (mrml) to compile templates to HTML 4 | """ 5 | 6 | require Logger 7 | 8 | @doc """ 9 | Compile a MJML template to HTML, where `email_or_template` can be: 10 | 11 | * `Swoosh.Email` 12 | * literal MJML template string 13 | * literal HTML template string 14 | 15 | ## Examples 16 | 17 | iex> compile!("") 18 | "..." 19 | 20 | iex> compile!("") 21 | "..." 22 | 23 | iex> compile!(%Swoosh.Email{html_body: ""}) 24 | %Swoosh.Email{html_body: "..."} 25 | 26 | iex> compile!(%Swoosh.Email{html_body: ""}) 27 | %Swoosh.Email{html_body: "..."} 28 | 29 | """ 30 | @type compiled_template :: String.t() | Swoosh.Email.t() 31 | @spec compile!(String.t() | Swoosh.Email.t()) :: compiled_template() 32 | def compile!(email_or_template) 33 | 34 | def compile!(%Swoosh.Email{html_body: body} = email) do 35 | Map.put(email, :html_body, compile!(body)) 36 | end 37 | 38 | def compile!(email_or_template) when is_binary(email_or_template) do 39 | if email_or_template =~ "", do: do_compile_mjml!(email_or_template), else: email_or_template 40 | end 41 | 42 | def compile!(email_or_template) do 43 | raise ArgumentError, 44 | "Expected either a %Swoosh.Email{} or valid HTML or MJML template, got: #{inspect(email_or_template)}" 45 | end 46 | 47 | defp do_compile_mjml!(template) do 48 | case Mjml.to_html(template) do 49 | {:ok, html} -> html 50 | {:error, error} -> raise "Mjml compilation returned #{inspect(error)}, mail has not been compiled." 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/guilda_web/mount_hooks/init_assigns.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.MountHooks.InitAssigns do 2 | @moduledoc """ 3 | Ensures common `assigns` are applied to all LiveViews attaching this hook. 4 | """ 5 | import Phoenix.Component, only: [assign: 2, assign_new: 3] 6 | alias Guilda.Accounts 7 | alias GuildaWeb.RequestContext 8 | 9 | def on_mount(_any, _params, session, socket) do 10 | socket = 11 | socket 12 | |> assign_menu() 13 | |> assign_user(session) 14 | |> RequestContext.put_audit_context() 15 | 16 | {:cont, socket} 17 | end 18 | 19 | defp assign_menu(socket) do 20 | assign(socket, menu: %{action: socket.assigns.live_action, module: socket.view}) 21 | end 22 | 23 | defp assign_user(socket, %{"user_token" => user_token}) do 24 | socket 25 | |> assign_new(:user_token, fn -> user_token end) 26 | |> assign_new(:current_user, fn -> Accounts.get_user_by_session_token(user_token) end) 27 | end 28 | 29 | defp assign_user(socket, _) do 30 | assign(socket, current_user: nil) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/guilda_web/mount_hooks/require_user.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.MountHooks.RequireUser do 2 | @moduledoc """ 3 | Ensures common `assigns` are applied to all LiveViews attaching this hook. 4 | """ 5 | import Phoenix.LiveView 6 | import GuildaWeb.Gettext 7 | 8 | alias GuildaWeb.Router.Helpers, as: Routes 9 | 10 | def on_mount(_any, _params, _session, socket) do 11 | if socket.assigns[:current_user] do 12 | {:cont, socket} 13 | else 14 | socket = 15 | socket 16 | |> put_flash(:error, gettext("You must be signed in to access this page.")) 17 | |> redirect(to: Routes.user_session_path(socket, :new)) 18 | 19 | {:halt, socket} 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/guilda_web/mount_hooks/track_presence.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.MountHooks.TrackPresence do 2 | @moduledoc """ 3 | Ensures common `assigns` are applied to all LiveViews attaching this hook. 4 | """ 5 | 6 | def on_mount(_any, _params, _session, socket) do 7 | if user = socket.assigns[:current_user] do 8 | GuildaWeb.Presence.track_user(user.id) 9 | end 10 | 11 | {:cont, socket} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/guilda_web/request_context.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.RequestContext do 2 | @moduledoc false 3 | alias Guilda.AuditLog 4 | alias Guilda.Extensions.Ecto.IPAddress 5 | 6 | def put_audit_context(conn_or_socket, opts \\ []) 7 | 8 | def put_audit_context(%Plug.Conn{} = conn, _) do 9 | user_agent = 10 | case List.keyfind(conn.req_headers, "user-agent", 0) do 11 | {_, value} -> value 12 | _ -> nil 13 | end 14 | 15 | Plug.Conn.assign(conn, :audit_context, %AuditLog{ 16 | user_agent: user_agent, 17 | ip_address: get_ip(conn.req_headers), 18 | user: conn.assigns[:current_user] 19 | }) 20 | end 21 | 22 | def put_audit_context(%Phoenix.LiveView.Socket{} = socket, _) do 23 | audit_context = %AuditLog{ 24 | user: socket.assigns[:current_user] 25 | } 26 | 27 | Phoenix.Component.assign_new(socket, :audit_context, fn -> audit_context end) 28 | end 29 | 30 | defp get_ip(headers) do 31 | with {_, ip} <- List.keyfind(headers, "x-forwarded-for", 0), 32 | [ip | _] = String.split(ip, ","), 33 | {:ok, address} <- IPAddress.cast(ip) do 34 | address 35 | else 36 | _ -> 37 | nil 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/guilda_web/simple_s3_upload.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.SimpleS3Upload do 2 | @moduledoc """ 3 | Dependency-free S3 Form Upload using HTTP POST sigv4 4 | https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html 5 | """ 6 | 7 | @doc """ 8 | Signs a form upload. 9 | The configuration is a map which must contain the following keys: 10 | * `:region` - The AWS region, such as "us-east-1" 11 | * `:access_key_id` - The AWS access key id 12 | * `:secret_access_key` - The AWS secret access key 13 | Returns a map of form fields to be used on the client via the JavaScript `FormData` API. 14 | 15 | ## Options 16 | * `:key` - The required key of the object to be uploaded. 17 | * `:max_file_size` - The required maximum allowed file size in bytes. 18 | * `:content_type` - The required MIME type of the file to be uploaded. 19 | * `:expires_in` - The required expiration time in milliseconds from now 20 | before the signed upload expires. 21 | ## Examples 22 | config = %{ 23 | region: "us-east-1", 24 | access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"), 25 | secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY") 26 | } 27 | {:ok, fields} = 28 | SimpleS3Upload.sign_form_upload(config, "my-bucket", 29 | key: "public/my-file-name", 30 | content_type: "image/png", 31 | max_file_size: 10_000, 32 | expires_in: :timer.hours(1) 33 | ) 34 | """ 35 | def sign_form_upload(config, bucket, opts) do 36 | key = Keyword.fetch!(opts, :key) 37 | max_file_size = Keyword.fetch!(opts, :max_file_size) 38 | content_type = Keyword.fetch!(opts, :content_type) 39 | expires_in = Keyword.fetch!(opts, :expires_in) 40 | 41 | expires_at = DateTime.add(DateTime.utc_now(), expires_in, :millisecond) 42 | amz_date = amz_date(expires_at) 43 | credential = credential(config, expires_at) 44 | 45 | encoded_policy = 46 | Base.encode64(""" 47 | { 48 | "expiration": "#{DateTime.to_iso8601(expires_at)}", 49 | "conditions": [ 50 | {"bucket": "#{bucket}"}, 51 | ["eq", "$key", "#{key}"], 52 | {"acl": "public-read"}, 53 | ["eq", "$Content-Type", "#{content_type}"], 54 | ["content-length-range", 0, #{max_file_size}], 55 | {"x-amz-server-side-encryption": "AES256"}, 56 | {"x-amz-credential": "#{credential}"}, 57 | {"x-amz-algorithm": "AWS4-HMAC-SHA256"}, 58 | {"x-amz-date": "#{amz_date}"} 59 | ] 60 | } 61 | """) 62 | 63 | fields = %{ 64 | "key" => key, 65 | "acl" => "public-read", 66 | "content-type" => content_type, 67 | "x-amz-server-side-encryption" => "AES256", 68 | "x-amz-credential" => credential, 69 | "x-amz-algorithm" => "AWS4-HMAC-SHA256", 70 | "x-amz-date" => amz_date, 71 | "policy" => encoded_policy, 72 | "x-amz-signature" => signature(config, expires_at, encoded_policy) 73 | } 74 | 75 | {:ok, fields} 76 | end 77 | 78 | defp amz_date(time) do 79 | time 80 | |> NaiveDateTime.to_iso8601() 81 | |> String.split(".") 82 | |> List.first() 83 | |> String.replace("-", "") 84 | |> String.replace(":", "") 85 | |> Kernel.<>("Z") 86 | end 87 | 88 | defp credential(%{} = config, %DateTime{} = expires_at) do 89 | "#{config.access_key_id}/#{short_date(expires_at)}/#{config.region}/s3/aws4_request" 90 | end 91 | 92 | defp signature(config, %DateTime{} = expires_at, encoded_policy) do 93 | config 94 | |> signing_key(expires_at, "s3") 95 | |> sha256(encoded_policy) 96 | |> Base.encode16(case: :lower) 97 | end 98 | 99 | defp signing_key(%{} = config, %DateTime{} = expires_at, service) when service in ["s3"] do 100 | amz_date = short_date(expires_at) 101 | %{secret_access_key: secret, region: region} = config 102 | 103 | ("AWS4" <> secret) 104 | |> sha256(amz_date) 105 | |> sha256(region) 106 | |> sha256(service) 107 | |> sha256("aws4_request") 108 | end 109 | 110 | defp short_date(%DateTime{} = expires_at) do 111 | expires_at 112 | |> amz_date() 113 | |> String.slice(0..7) 114 | end 115 | 116 | defp sha256(secret, msg), do: :crypto.mac(:hmac, :sha256, secret, msg) 117 | end 118 | -------------------------------------------------------------------------------- /lib/guilda_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.Telemetry do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | import Telemetry.Metrics 6 | 7 | def start_link(arg) do 8 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 9 | end 10 | 11 | @impl true 12 | def init(_arg) do 13 | children = [ 14 | # Telemetry poller will execute the given period measurements 15 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 16 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 17 | # Add reporters as children of your supervision tree. 18 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 19 | ] 20 | 21 | Supervisor.init(children, strategy: :one_for_one) 22 | end 23 | 24 | def metrics do 25 | [ 26 | # Phoenix Metrics 27 | summary("phoenix.endpoint.stop.duration", 28 | unit: {:native, :millisecond} 29 | ), 30 | summary("phoenix.router_dispatch.stop.duration", 31 | tags: [:route], 32 | unit: {:native, :millisecond} 33 | ), 34 | 35 | # Database Metrics 36 | summary("guilda.repo.query.total_time", unit: {:native, :millisecond}), 37 | summary("guilda.repo.query.decode_time", unit: {:native, :millisecond}), 38 | summary("guilda.repo.query.query_time", unit: {:native, :millisecond}), 39 | summary("guilda.repo.query.queue_time", unit: {:native, :millisecond}), 40 | summary("guilda.repo.query.idle_time", unit: {:native, :millisecond}), 41 | 42 | # VM Metrics 43 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 44 | summary("vm.total_run_queue_lengths.total"), 45 | summary("vm.total_run_queue_lengths.cpu"), 46 | summary("vm.total_run_queue_lengths.io") 47 | ] 48 | end 49 | 50 | defp periodic_measurements do 51 | [ 52 | # A module, function and arguments to be invoked periodically. 53 | # This function must call :telemetry.execute/3 and a metric must be added above. 54 | # {GuildaWeb, :count_users, []} 55 | ] 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/feed/index.xml.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Quem Programa? 5 | https://guildatech.com/podcast 6 | pt-br 7 | © 2021 GuildaTech 8 | O podcast que fala da vida na TI sem ser complicado, mostrando a trajetória das pessoas por trás da tecnologia. 9 | O podcast da GuildaTech 10 | GuildaTech 11 | 12 | GuildaTech 13 | podcast@guildatech.com 14 | 15 | false 16 | " /> 17 | 18 | 19 | 20 | 21 | <%= for episode <- @episodes do %> 22 | 23 | <%= episode.id %> 24 | <%= episode.title %> 25 | <%= Timex.format!(episode.aired_date, "%a, %d %b %Y %T %z", :strftime) %> 26 | <%= Routes.podcast_episode_index_url(@conn, :index) %> 27 | 28 | 29 | ]]> 30 | 31 | <%= episode.hosts %> 32 | <%= GuildaWeb.ViewHelpers.format_seconds(episode.length) %> 33 | false 34 | 35 | <% end %> 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 | <%= live_render(@conn, GuildaWeb.MenuLive, id: "menu", session: %{"menu" => @menu, "current_user" => @current_user}) %> 2 | 3 | <%= if msg = get_flash(@conn, :info) do %> 4 | 9 | <% end %> 10 | <%= if msg = get_flash(@conn, :error) do %> 11 | 16 | <% end %> 17 | 18 | <%= @inner_content %> 19 | <%= live_render(@conn, GuildaWeb.OnlineMembersLive, id: "online-members", session: %{}, sticky: true) %> 20 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/layout/email.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= @email.subject %> 4 | 5 | 6 | 7 | 8 | 19 | 20 | 21 | 22 | 23 | 24 | 35 | <%= @email.subject %> 36 | 37 | 38 | 39 | 40 | 41 | <%= @inner_content %> 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/layout/email.text.eex: -------------------------------------------------------------------------------- 1 | <%= @email.subject %> 2 | 3 | ========================= 4 | 5 | <%= @inner_content %> 6 | 7 | ========================= 8 | 9 | Thanks, 10 | The GuildaTech Team -------------------------------------------------------------------------------- /lib/guilda_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 | <%= live_render(@socket, GuildaWeb.MenuLive, id: "menu", session: %{"menu" => @menu, "current_user" => @current_user}) %> 2 | 3 | <%= if msg = live_flash(@flash, :info) do %> 4 | 9 | <% end %> 10 | <%= if msg = live_flash(@flash, :error) do %> 11 | 16 | <% end %> 17 | 18 | <%= @inner_content %> 19 | <%= live_render(@socket, GuildaWeb.OnlineMembersLive, id: "online-members", session: %{}, sticky: true) %> 20 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/layout/navbar.html.heex: -------------------------------------------------------------------------------- 1 | <%= @inner_content %> 2 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <%= live_title_tag(assigns[:page_title] || "GuildaTech", suffix: " · #GuildaTech") %> 9 | 10 | 11 | 14 | 16 | 18 | 21 | 22 | 23 | <%= @inner_content %> 24 | 25 | 26 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_confirmation/edit.html.heex: -------------------------------------------------------------------------------- 1 | <.guest_content title="Confirm account" flash={get_flash(@conn)}> 2 |
3 |
4 | <.form :let={_f} for={:user} action={Routes.user_confirmation_path(@conn, :update, @token)}> 5 |
6 |
7 | <.button type="submit" class="w-full"><%= gettext("Confirm my account") %> 8 |
9 | 10 |
11 |
12 | <.link 13 | navigate={Routes.user_registration_path(@conn, :new)} 14 | class="font-medium text-primary-600 hover:text-primary-500" 15 | > 16 | <%= gettext("Register") %> 17 | 18 | | 19 | <.link 20 | navigate={Routes.user_session_path(@conn, :new)} 21 | class="font-medium text-primary-600 hover:text-primary-500" 22 | > 23 | <%= gettext("Sign in") %> 24 | 25 |
26 |
27 |
28 | 29 |
30 |
31 | 32 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_confirmation/new.html.heex: -------------------------------------------------------------------------------- 1 | <.guest_content title="Resend confirmation instructions" flash={get_flash(@conn)}> 2 |
3 |
4 | <.form :let={f} for={:user} action={Routes.user_confirmation_path(@conn, :create)}> 5 |
6 |
7 | <%= label(f, :email, class: "block text-sm font-medium text-gray-700") %> 8 |
9 | <%= email_input(f, :email, 10 | required: true, 11 | class: 12 | "block w-full px-3 py-2 placeholder-gray-400 border border-gray-300 rounded-md shadow-sm appearance-none focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" 13 | ) %> 14 |
15 |
16 | 17 |
18 |
19 | <.link 20 | navigate={Routes.user_registration_path(@conn, :new)} 21 | class="font-medium text-primary-600 hover:text-primary-500" 22 | > 23 | <%= gettext("Register") %> 24 | 25 | | 26 | <.link 27 | navigate={Routes.user_session_path(@conn, :new)} 28 | class="font-medium text-primary-600 hover:text-primary-500" 29 | > 30 | <%= gettext("Sign in") %> 31 | 32 |
33 |
34 | 35 |
36 | <.button type="submit" class="w-full"><%= gettext("Resend confirmation instructions") %> 37 |
38 |
39 | 40 |
41 |
42 | 43 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_notifier/confirmation_instructions.html.heex: -------------------------------------------------------------------------------- 1 | 9 | Hi <%= @user.email %> 10 | 11 | 19 | You can confirm your account by visiting the URL below: 20 | 21 | 31 | Confirm account 32 | 33 | 41 | If you didn't create an account with us, please ignore this. 42 | 43 | 51 | Thanks,
The GuildaTech Team 52 |
53 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_notifier/confirmation_instructions.text.eex: -------------------------------------------------------------------------------- 1 | Hi <%= @user.email %>, 2 | 3 | You can confirm your account by visiting the URL below: 4 | 5 | <%= @url %> 6 | 7 | If you didn't create an account with us, please ignore this. -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_notifier/reset_password_instructions.html.heex: -------------------------------------------------------------------------------- 1 | 9 | Hi <%= @user.email %> 10 | 11 | 19 | You can reset your password by visiting the URL below: 20 | 21 | 31 | Reset password 32 | 33 | 41 | If you didn't request this change, please ignore this. 42 | 43 | 51 | Thanks,
The GuildaTech Team 52 |
53 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_notifier/reset_password_instructions.text.eex: -------------------------------------------------------------------------------- 1 | Hi <%= @user.email %>, 2 | 3 | You can reset your password by visiting the URL below: 4 | 5 | <%= @url %> 6 | 7 | If you didn't request this change, please ignore this. -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_notifier/update_email_instructions.html.heex: -------------------------------------------------------------------------------- 1 | 9 | Hi <%= @user.email %> 10 | 11 | 19 | You can change your email by visiting the URL below: 20 | 21 | 31 | Reset password 32 | 33 | 41 | If you didn't request this change, please ignore this. 42 | 43 | 51 | Thanks,
The GuildaTech Team 52 |
53 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_notifier/update_email_instructions.text.eex: -------------------------------------------------------------------------------- 1 | Hi <%= @user.email %>, 2 | 3 | You can change your email by visiting the URL below: 4 | 5 | <%= @url %> 6 | 7 | If you didn't request this change, please ignore this. -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_registration/new.html.heex: -------------------------------------------------------------------------------- 1 | <.guest_content title={gettext("Register")} flash={get_flash(@conn)}> 2 |
3 |
4 | <.form :let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create)}> 5 |
6 | <.error 7 | :if={@changeset.action} 8 | message={gettext("Oops, something went wrong! Please check the errors below.")} 9 | /> 10 | 11 |
12 | <.input field={{f, :email}} label={gettext("Email")} /> 13 |
14 | 15 |
16 | <.input type="password" field={{f, :password}} label={gettext("Password")} /> 17 |
18 | 19 |
20 |
21 | <.link 22 | navigate={Routes.user_session_path(@conn, :new)} 23 | class="font-medium text-primary-600 hover:text-primary-500" 24 | > 25 | <%= gettext("Sign in") %> 26 | 27 | | 28 | <.link 29 | navigate={Routes.user_reset_password_path(@conn, :new)} 30 | class="font-medium text-primary-600 hover:text-primary-500" 31 | > 32 | <%= gettext("Forgot your password?") %> 33 | 34 |
35 |
36 | 37 |
38 | <.button type="submit" class="w-full"><%= gettext("Register") %> 39 |
40 |
41 | 42 |
43 |
44 | 45 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_reset_password/edit.html.heex: -------------------------------------------------------------------------------- 1 | <.guest_content title="Reset password" flash={get_flash(@conn)}> 2 |
3 |
4 | <.form :let={f} for={@changeset} action={Routes.user_reset_password_path(@conn, :update, @token)}> 5 |
6 | <%= if @changeset.action do %> 7 |
8 |

<%= gettext("Oops, something went wrong! Please check the errors below.") %>

9 |
10 | <% end %> 11 |
12 | <.input type="password" field={{f, :password}} label={gettext("New password")} /> 13 |
14 | 15 |
16 | <.input type="password" field={{f, :password_confirmation}} label={gettext("Confirm new password")} /> 17 |
18 | 19 |
20 |
21 | <.link 22 | navigate={Routes.user_registration_path(@conn, :new)} 23 | class="font-medium text-primary-600 hover:text-primary-500" 24 | > 25 | <%= gettext("Register") %> 26 | 27 | | 28 | <.link 29 | navigate={Routes.user_session_path(@conn, :new)} 30 | class="font-medium text-primary-600 hover:text-primary-500" 31 | > 32 | <%= gettext("Sign in") %> 33 | 34 |
35 |
36 | 37 |
38 | <.button type="submit" class="w-full"><%= gettext("Reset password") %> 39 |
40 |
41 | 42 |
43 |
44 | 45 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_reset_password/new.html.heex: -------------------------------------------------------------------------------- 1 | <.guest_content title="Forgot your password?" flash={get_flash(@conn)}> 2 |
3 |
4 | <.form :let={f} for={:user} action={Routes.user_reset_password_path(@conn, :create)}> 5 |
6 |
7 | <.input field={{f, :email}} label={gettext("Email")} /> 8 |
9 | 10 |
11 |
12 | <.link 13 | navigate={Routes.user_registration_path(@conn, :new)} 14 | class="font-medium text-primary-600 hover:text-primary-500" 15 | > 16 | <%= gettext("Register") %> 17 | 18 | | 19 | <.link 20 | navigate={Routes.user_session_path(@conn, :new)} 21 | class="font-medium text-primary-600 hover:text-primary-500" 22 | > 23 | <%= gettext("Sign in") %> 24 | 25 |
26 |
27 | 28 |
29 | <.button type="submit" class="w-full"><%= gettext("Send instructions to reset password") %> 30 |
31 |
32 | 33 |
34 |
35 | 36 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_session/new.html.heex: -------------------------------------------------------------------------------- 1 | <.guest_content title="Sign in" flash={get_flash(@conn)}> 2 |
3 |
4 | <.form :let={f} for={@conn} action={Routes.user_session_path(@conn, :create)} as={:user}> 5 |
6 | <%= if @error_message do %> 7 |
8 |

<%= @error_message %>

9 |
10 | <% end %> 11 | 12 |
13 | <.input field={{f, :email}} label={gettext("Email")} /> 14 |
15 | 16 |
17 | <.input type="password" field={{f, :password}} label={gettext("Password")} /> 18 |
19 | 20 |
21 |
22 | <.link 23 | navigate={Routes.user_reset_password_path(@conn, :new)} 24 | class="font-medium text-primary-600 hover:text-primary-500" 25 | > 26 | <%= gettext("Forgot your password?") %> 27 | 28 | | 29 | <.link 30 | navigate={Routes.user_registration_path(@conn, :new)} 31 | class="font-medium text-primary-600 hover:text-primary-500" 32 | > 33 | <%= gettext("Register") %> 34 | 35 |
36 |
37 | 38 |
39 | <.button type="submit" class="w-full"><%= gettext("Sign in") %> 40 |
41 |
42 | 43 |
44 |
45 |
46 |
47 |
48 |
49 | <%= gettext("Or continue with") %> 50 |
51 |
52 | 53 |
54 |
55 |
56 | 65 |
66 |
67 |
68 |
69 |
70 |
71 | 72 | -------------------------------------------------------------------------------- /lib/guilda_web/templates/user_totp/new.html.heex: -------------------------------------------------------------------------------- 1 | <.guest_content title={gettext("Two-factor authentication")} flash={get_flash(@conn)}> 2 |
3 |
4 | <.form :let={f} for={@conn} as={:user} action={Routes.user_totp_path(@conn, :create)}> 5 |
6 | <%= if @error_message do %> 7 |
8 |

<%= @error_message %>

9 |
10 | <% end %> 11 | 12 |

13 | <%= gettext( 14 | "Enter the six-digit code from your device or any of your eight-character backup codes to finish logging in." 15 | ) %> 16 |

17 | 18 |
19 | <.input field={{f, :code}} label={gettext("Code")} /> 20 |
21 | 22 |
23 | <.button type="submit" class="w-full"><%= gettext("Verify code and sign in") %> 24 | 25 | or 26 | <.link 27 | navigate={Routes.user_session_path(@conn, :delete)} 28 | method="delete" 29 | class="font-medium text-primary-800" 30 | > 31 | <%= gettext("click here to sign out") %> 32 | 33 | 34 |
35 |
36 | 37 |
38 |
39 | 40 | -------------------------------------------------------------------------------- /lib/guilda_web/views/email_view.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.EmailView do 2 | use GuildaWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/guilda_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.ErrorView do 2 | use GuildaWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/guilda_web/views/feed_view.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.FeedView do 2 | use GuildaWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/guilda_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.LayoutView do 2 | use GuildaWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/guilda_web/views/user_confirmation_view.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserConfirmationView do 2 | use GuildaWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/guilda_web/views/user_notifier_view.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserNotifierView do 2 | use GuildaWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/guilda_web/views/user_registration_view.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserRegistrationView do 2 | use GuildaWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/guilda_web/views/user_reset_password_view.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserResetPasswordView do 2 | use GuildaWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/guilda_web/views/user_session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserSessionView do 2 | use GuildaWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/guilda_web/views/user_settings_view.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserSettingsView do 2 | use GuildaWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/guilda_web/views/user_totp_view.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserTOTPView do 2 | use GuildaWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/guilda_web/views/view_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.ViewHelpers do 2 | @moduledoc """ 3 | View Helpers used across the app. 4 | """ 5 | 6 | @spec format_date(Timex.Types.valid_datetime()) :: String.t() | no_return() 7 | def format_date(date) do 8 | Timex.format!(date, "{0D}/{0M}/{YYYY}") 9 | end 10 | 11 | @doc """ 12 | Format the given number of seconds in hh:mm:ss. 13 | """ 14 | def format_seconds(seconds) do 15 | seconds |> Timex.Duration.from_seconds() |> Timex.Duration.to_time!() 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/sizeable.ex: -------------------------------------------------------------------------------- 1 | defmodule Sizeable do 2 | @moduledoc """ 3 | A library to make file sizes human-readable. 4 | 5 | Forked from https://github.com/arvidkahl/sizeable under MIT. 6 | """ 7 | 8 | @bytes ~w(B KB MB GB TB PB EB ZB YB) 9 | 10 | @doc """ 11 | see `filesize(value, options)` 12 | """ 13 | def filesize(value) do 14 | filesize(value, []) 15 | end 16 | 17 | def filesize(value, options) when is_bitstring(value) do 18 | case Integer.parse(value) do 19 | {parsed, _rem} -> filesize(parsed, options) 20 | :error -> raise "Value is not a number" 21 | end 22 | end 23 | 24 | def filesize(value, options) when is_integer(value) do 25 | {parsed, _rem} = value |> Integer.to_string() |> Float.parse() 26 | filesize(parsed, options) 27 | end 28 | 29 | def filesize(0.0, _options) do 30 | {:ok, unit} = Enum.fetch(@bytes, 0) 31 | 32 | filesize_output(0, unit) 33 | end 34 | 35 | @doc """ 36 | Returns a human-readable string for the given numeric value. 37 | 38 | ## Arguments: 39 | - `value` (Integer/Float/String) representing the filesize to be converted. 40 | - `options` (Struct) representing the options to determine base, rounding and units. 41 | 42 | ## Options 43 | - `round`: the precision that the number should be rounded down to. Defaults to `2`. 44 | - `base`: the base for exponent calculation. `2` for binary-based numbers, any other Integer can be used. Defaults to `2`. 45 | """ 46 | def filesize(value, options) when is_float(value) and is_list(options) do 47 | base = Keyword.get(options, :base, 2) 48 | round = Keyword.get(options, :round, 2) 49 | 50 | ceil = 51 | if base > 2 do 52 | 1000 53 | else 54 | 1024 55 | end 56 | 57 | neg = value < 0 58 | 59 | value = 60 | case neg do 61 | true -> -value 62 | false -> value 63 | end 64 | 65 | {exponent, _rem} = 66 | (:math.log(value) / :math.log(ceil)) 67 | |> Float.floor() 68 | |> Float.to_string() 69 | |> Integer.parse() 70 | 71 | result = Float.round(value / :math.pow(ceil, exponent), base) 72 | 73 | result = 74 | if Float.floor(result) == result do 75 | round(result) 76 | else 77 | Float.round(result, round) 78 | end 79 | 80 | {:ok, unit} = Enum.fetch(@bytes, exponent) 81 | 82 | result = 83 | case neg do 84 | true -> result * -1 85 | false -> result 86 | end 87 | 88 | filesize_output(result, unit) 89 | end 90 | 91 | def filesize(_value, options) when is_list(options) do 92 | raise "Invalid value" 93 | end 94 | 95 | def filesize(_value, _options) do 96 | raise "Invalid options argument" 97 | end 98 | 99 | def filesize_output(result, unit) do 100 | Enum.join([result, unit], " ") 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /phoenix_static_buildpack.config: -------------------------------------------------------------------------------- 1 | node_version=12.16.3 2 | -------------------------------------------------------------------------------- /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 | "Plural-Forms: nplurals=2\n" 13 | 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | msgid "has already been taken" 18 | msgstr "" 19 | 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | msgid "must be accepted" 24 | msgstr "" 25 | 26 | msgid "has invalid format" 27 | msgstr "" 28 | 29 | msgid "has an invalid entry" 30 | msgstr "" 31 | 32 | msgid "is reserved" 33 | msgstr "" 34 | 35 | msgid "does not match confirmation" 36 | msgstr "" 37 | 38 | msgid "is still associated with this entry" 39 | msgstr "" 40 | 41 | msgid "are still associated with this entry" 42 | msgstr "" 43 | 44 | msgid "should be %{count} character(s)" 45 | msgid_plural "should be %{count} character(s)" 46 | msgstr[0] "" 47 | msgstr[1] "" 48 | 49 | msgid "should have %{count} item(s)" 50 | msgid_plural "should have %{count} item(s)" 51 | msgstr[0] "" 52 | msgstr[1] "" 53 | 54 | msgid "should be at least %{count} character(s)" 55 | msgid_plural "should be at least %{count} character(s)" 56 | msgstr[0] "" 57 | msgstr[1] "" 58 | 59 | msgid "should have at least %{count} item(s)" 60 | msgid_plural "should have at least %{count} item(s)" 61 | msgstr[0] "" 62 | msgstr[1] "" 63 | 64 | msgid "should be at most %{count} character(s)" 65 | msgid_plural "should be at most %{count} character(s)" 66 | msgstr[0] "" 67 | msgstr[1] "" 68 | 69 | msgid "should have at most %{count} item(s)" 70 | msgid_plural "should have at most %{count} item(s)" 71 | msgstr[0] "" 72 | msgstr[1] "" 73 | 74 | msgid "must be less than %{number}" 75 | msgstr "" 76 | 77 | msgid "must be greater than %{number}" 78 | msgstr "" 79 | 80 | msgid "must be less than or equal to %{number}" 81 | msgstr "" 82 | 83 | msgid "must be greater than or equal to %{number}" 84 | msgstr "" 85 | 86 | msgid "must be equal to %{number}" 87 | msgstr "" 88 | -------------------------------------------------------------------------------- /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/20200905004133_enable_extensions.exs: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Repo.Migrations.EnableExtensions do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"") 6 | execute("CREATE EXTENSION IF NOT EXISTS pgcrypto") 7 | execute("CREATE EXTENSION IF NOT EXISTS citext") 8 | end 9 | 10 | def down do 11 | execute("DROP EXTENSION IF EXISTS citext") 12 | execute("DROP EXTENSION IF EXISTS pgcrypto") 13 | execute("DROP EXTENSION IF EXISTS \"uuid-ossp\"") 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200905004134_create_users_auth_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Repo.Migrations.CreateUsersAuthTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :telegram_id, :string, null: false 8 | add :username, :citext 9 | add :first_name, :string 10 | add :last_name, :string 11 | add :email, :citext 12 | add :confirmed_at, :naive_datetime 13 | timestamps() 14 | end 15 | 16 | create unique_index(:users, [:telegram_id]) 17 | create unique_index(:users, [:email]) 18 | 19 | create table(:users_tokens, primary_key: false) do 20 | add :id, :binary_id, primary_key: true 21 | add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false 22 | add :token, :binary, null: false 23 | add :context, :string, null: false 24 | add :sent_to, :string 25 | timestamps(updated_at: false) 26 | end 27 | 28 | create index(:users_tokens, [:user_id]) 29 | create unique_index(:users_tokens, [:context, :token]) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200913191141_create_transactions.exs: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Repo.Migrations.CreateTransactions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:transactions, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :date, :date, null: false 8 | add :amount, :decimal, null: false, default: 0 9 | add :payee, :string, null: false 10 | add :note, :string 11 | 12 | timestamps() 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210118225544_create_podcast_episodes.exs: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Repo.Migrations.CreatePodcastEpisodes do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:podcast_episodes, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :title, :string, null: false 8 | add :slug, :string, null: false 9 | add :description, :text, null: false 10 | add :aired_date, :date, null: false 11 | add :hosts, :string, null: false 12 | add :cover_url, :string, null: false 13 | add :cover_name, :string, null: false 14 | add :cover_type, :string, null: false 15 | add :cover_size, :integer, null: false 16 | add :file_url, :string, null: false 17 | add :file_name, :string, null: false 18 | add :file_type, :string, null: false 19 | add :file_size, :integer, null: false 20 | add :length, :integer, default: 0 21 | add :play_count, :integer, default: 0 22 | 23 | timestamps() 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210126233220_add_admin_flag_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Repo.Migrations.AddAdminFlagToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :is_admin, :boolean, default: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220201042033_enable_postgis.exs: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Repo.Migrations.EnablePostgis do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute "CREATE EXTENSION IF NOT EXISTS postgis" 6 | end 7 | 8 | def down do 9 | execute "DROP EXTENSION IF EXISTS postgis" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220201042045_add_geom_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Repo.Migrations.AddGeomToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :geom, :geometry 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220326135109_add_hashed_password_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.Repo.Migrations.AddHashedPasswordToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | modify :telegram_id, :string, null: true 7 | add :hashed_password, :string 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220328022131_create_audit_logs.exs: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Repo.Migrations.CreateAuditLogs do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:audit_logs) do 6 | add :action, :string, null: false 7 | add :ip_address, :inet 8 | add :user_agent, :string 9 | add :user_email, :string 10 | add :params, :map, null: false 11 | add :user_id, references(:users, on_delete: :nilify_all) 12 | timestamps(updated_at: false) 13 | end 14 | 15 | create index(:audit_logs, [:user_id]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220328204819_create_users_totps.exs: -------------------------------------------------------------------------------- 1 | defmodule Guilda.Repo.Migrations.CreateUsersTotps do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users_totps) do 6 | add :user_id, references(:users, on_delete: :delete_all), null: false 7 | add :secret, :binary 8 | add :backup_codes, :map 9 | timestamps() 10 | end 11 | 12 | create unique_index(:users_totps, [:user_id]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /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 | # Guilda.Repo.insert!(%Guilda.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guildatech/GuildaEx/75f8bcd5f7638625e3d9c41c4c8ece8904f7d085/priv/static/favicon.ico -------------------------------------------------------------------------------- /priv/static/images/guilda-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guildatech/GuildaEx/75f8bcd5f7638625e3d9c41c4c8ece8904f7d085/priv/static/images/guilda-logo.png -------------------------------------------------------------------------------- /priv/static/images/guildacast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guildatech/GuildaEx/75f8bcd5f7638625e3d9c41c4c8ece8904f7d085/priv/static/images/guildacast.png -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | exec ./guilda eval Guilda.Release.migrate -------------------------------------------------------------------------------- /rel/overlays/bin/migrate.bat: -------------------------------------------------------------------------------- 1 | call "%~dp0\guilda" eval Guilda.Release.migrate -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | PHX_SERVER=true exec ./guilda start 4 | -------------------------------------------------------------------------------- /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\guilda" start -------------------------------------------------------------------------------- /test/guilda/finances_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Guilda.FinancesTest do 2 | use Guilda.DataCase 3 | 4 | alias Guilda.Finances 5 | 6 | describe "transactions" do 7 | alias Guilda.Finances.Transaction 8 | 9 | @valid_attrs %{amount: "120.5", date: ~D[2010-04-17], note: "some note", payee: "some payee"} 10 | @update_attrs %{amount: "456.7", date: ~D[2011-05-18], note: "some updated note", payee: "some updated payee"} 11 | @invalid_attrs %{amount: nil, date: nil, note: nil, payee: nil} 12 | 13 | def transaction_fixture(attrs \\ %{}) do 14 | {:ok, transaction} = 15 | attrs 16 | |> Enum.into(@valid_attrs) 17 | |> Finances.create_transaction() 18 | 19 | transaction 20 | end 21 | 22 | test "list_transactions/0 returns all transactions" do 23 | transaction = transaction_fixture() 24 | assert Finances.list_transactions() == [transaction] 25 | end 26 | 27 | test "get_transaction!/1 returns the transaction with given id" do 28 | transaction = transaction_fixture() 29 | assert Finances.get_transaction!(transaction.id) == transaction 30 | end 31 | 32 | test "create_transaction/1 with valid data creates a transaction" do 33 | assert {:ok, %Transaction{} = transaction} = Finances.create_transaction(@valid_attrs) 34 | assert transaction.amount == Decimal.new("120.5") 35 | assert transaction.date == ~D[2010-04-17] 36 | assert transaction.note == "some note" 37 | assert transaction.payee == "some payee" 38 | end 39 | 40 | test "create_transaction/1 with invalid data returns error changeset" do 41 | assert {:error, %Ecto.Changeset{}} = Finances.create_transaction(@invalid_attrs) 42 | end 43 | 44 | test "update_transaction/2 with valid data updates the transaction" do 45 | transaction = transaction_fixture() 46 | assert {:ok, %Transaction{} = transaction} = Finances.update_transaction(transaction, @update_attrs) 47 | assert transaction.amount == Decimal.new("456.7") 48 | assert transaction.date == ~D[2011-05-18] 49 | assert transaction.note == "some updated note" 50 | assert transaction.payee == "some updated payee" 51 | end 52 | 53 | test "update_transaction/2 with invalid data returns error changeset" do 54 | transaction = transaction_fixture() 55 | assert {:error, %Ecto.Changeset{}} = Finances.update_transaction(transaction, @invalid_attrs) 56 | assert transaction == Finances.get_transaction!(transaction.id) 57 | end 58 | 59 | test "delete_transaction/1 deletes the transaction" do 60 | transaction = transaction_fixture() 61 | assert {:ok, %Transaction{}} = Finances.delete_transaction(transaction) 62 | assert_raise Ecto.NoResultsError, fn -> Finances.get_transaction!(transaction.id) end 63 | end 64 | 65 | test "change_transaction/1 returns a transaction changeset" do 66 | transaction = transaction_fixture() 67 | assert %Ecto.Changeset{} = Finances.change_transaction(transaction) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/guilda/podcasts_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Guilda.PodcastsTest do 2 | use Guilda.DataCase 3 | 4 | alias Guilda.Podcasts 5 | alias Guilda.Podcasts.Episode 6 | import Guilda.PodcastsFixtures 7 | 8 | describe "episodes" do 9 | test "most_recent_episode/0 returns the most recent episode" do 10 | assert Podcasts.most_recent_episode() == nil 11 | _episode_march = insert(:episode, aired_date: "2020-03-03") 12 | episode_june = insert(:episode, aired_date: "2020-06-06") 13 | assert Podcasts.most_recent_episode() == episode_june 14 | end 15 | 16 | test "list_episodes/0 returns all episodes" do 17 | episode = insert(:episode) 18 | assert Podcasts.list_podcast_episodes() == [episode] 19 | end 20 | 21 | test "list_episodes/0 returns a list ordered by aired date" do 22 | episode_march = insert(:episode, aired_date: "2020-03-03") 23 | episode_june = insert(:episode, aired_date: "2020-06-06") 24 | assert Podcasts.list_podcast_episodes() == [episode_june, episode_march] 25 | end 26 | 27 | test "get_episode!/1 returns the episode with given id" do 28 | episode = insert(:episode) 29 | assert Podcasts.get_episode!(episode.id) == episode 30 | end 31 | 32 | test "create_episode/1 with valid adata creates an episode" do 33 | assert {:ok, %Episode{}} = Podcasts.create_episode(params_for(:episode)) 34 | end 35 | 36 | test "create_episode/1 with invalid data returns an error changeset" do 37 | assert {:error, %Ecto.Changeset{}} = Podcasts.create_episode(params_for(:episode, %{title: ""})) 38 | end 39 | 40 | test "update_episode/2 with invalid data returns error changeset" do 41 | episode = insert(:episode) 42 | assert {:error, %Ecto.Changeset{}} = Podcasts.update_episode(episode, params_for(:episode, %{title: ""})) 43 | assert episode == Podcasts.get_episode!(episode.id) 44 | end 45 | 46 | test "delete_episode/1 deletes the episode" do 47 | episode = insert(:episode) 48 | assert {:ok, %Episode{}} = Podcasts.delete_episode(episode) 49 | assert_raise Ecto.NoResultsError, fn -> Podcasts.get_episode!(episode.id) end 50 | end 51 | 52 | test "change_episode/1 returns a episode changeset" do 53 | episode = insert(:episode) 54 | assert %Ecto.Changeset{} = Podcasts.change_episode(episode) 55 | end 56 | 57 | test "increase_play_count/1" do 58 | episode = insert(:episode) 59 | assert {1, nil} = Podcasts.increase_play_count(episode) 60 | assert Podcasts.get_episode!(episode.id).play_count == episode.play_count + 1 61 | end 62 | 63 | test "should_mark_as_viewed?/2" do 64 | assert Podcasts.should_mark_as_viewed?(%Episode{length: 10}, 3) 65 | assert Podcasts.should_mark_as_viewed?(%Episode{length: 10}, 2) 66 | refute Podcasts.should_mark_as_viewed?(%Episode{length: 10}, 1) 67 | refute Podcasts.should_mark_as_viewed?(%Episode{length: 10}, 0) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/guilda/sizeable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SizeableTest do 2 | use ExUnit.Case, async: true 3 | require Sizeable 4 | 5 | doctest Sizeable 6 | 7 | @kilobit 500 8 | @kilobit_float 500.0 9 | @kilobit_string "500.0" 10 | @kilobyte 1024 11 | @neg -1024 12 | @zero 0 13 | @byte 1 14 | @edgecase 1023 15 | 16 | @fail_value_string "abc" 17 | @fail_value_atom :abc 18 | @fail_options {:bits, true} 19 | 20 | # Test for erroneous values 21 | test "fail" do 22 | assert_raise RuntimeError, "Value is not a number", fn -> 23 | Sizeable.filesize(@fail_value_string) 24 | end 25 | end 26 | 27 | test "fail atom" do 28 | assert_raise RuntimeError, "Invalid value", fn -> 29 | Sizeable.filesize(@fail_value_atom) 30 | end 31 | end 32 | 33 | test "fail options" do 34 | assert_raise RuntimeError, "Invalid options argument", fn -> 35 | Sizeable.filesize(@kilobyte, @fail_options) 36 | end 37 | end 38 | 39 | # Tests for kilobit values 40 | test "500 B" do 41 | assert Sizeable.filesize(@kilobit) == "500 B" 42 | end 43 | 44 | test "500 B float" do 45 | assert Sizeable.filesize(@kilobit_float) == "500 B" 46 | end 47 | 48 | test "500 B string" do 49 | assert Sizeable.filesize(@kilobit_string) == "500 B" 50 | end 51 | 52 | # Tests for Kilobyte values 53 | test "1 KB" do 54 | assert Sizeable.filesize(@kilobyte) == "1 KB" 55 | end 56 | 57 | test "1 KB round" do 58 | assert Sizeable.filesize(@kilobyte, round: 1) == "1 KB" 59 | end 60 | 61 | # Tests for negative values 62 | test "neg" do 63 | assert Sizeable.filesize(@neg) == "-1 KB" 64 | end 65 | 66 | test "neg round" do 67 | assert Sizeable.filesize(@neg, round: 1) == "-1 KB" 68 | end 69 | 70 | # Tests for 0 71 | test "zero round" do 72 | assert Sizeable.filesize(@zero, round: 1) == "0 B" 73 | end 74 | 75 | # Tests for the 1023 edge case 76 | test "edgecase" do 77 | assert Sizeable.filesize(@edgecase) == "1023 B" 78 | end 79 | 80 | test "edgecase round" do 81 | assert Sizeable.filesize(@edgecase, round: 1) == "1023 B" 82 | end 83 | 84 | # Tests for byte values 85 | test "byte" do 86 | assert Sizeable.filesize(@byte) == "1 B" 87 | end 88 | 89 | test "byte round" do 90 | assert Sizeable.filesize(@byte, round: 1) == "1 B" 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/guilda_web/controllers/user_registration_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserRegistrationControllerTest do 2 | use GuildaWeb.ConnCase, async: true 3 | 4 | import Guilda.AccountsFixtures 5 | 6 | describe "GET /users/register" do 7 | test "renders registration page", %{conn: conn} do 8 | conn = get(conn, Routes.user_registration_path(conn, :new)) 9 | response = html_response(conn, 200) 10 | assert response =~ "Register" 11 | end 12 | 13 | test "redirects if already logged in", %{conn: conn} do 14 | conn = conn |> log_in_user(user_fixture()) |> get(Routes.user_registration_path(conn, :new)) 15 | assert redirected_to(conn) == "/" 16 | end 17 | end 18 | 19 | describe "POST /users/register" do 20 | @tag :capture_log 21 | test "creates account and logs the user in", %{conn: conn} do 22 | email = unique_user_email() 23 | 24 | conn = 25 | post(conn, Routes.user_registration_path(conn, :create), %{ 26 | "user" => valid_user_attributes(email: email) 27 | }) 28 | 29 | assert get_session(conn, :user_token) 30 | assert redirected_to(conn) == "/" 31 | 32 | # Now do a logged in request and assert on the menu 33 | conn = get(conn, "/") 34 | response = html_response(conn, 200) 35 | assert response =~ email 36 | assert response =~ "Settings" 37 | assert response =~ "Sign out\n" 38 | end 39 | 40 | test "render errors for invalid data", %{conn: conn} do 41 | conn = 42 | post(conn, Routes.user_registration_path(conn, :create), %{ 43 | "user" => %{"email" => "with spaces", "password" => "too short"} 44 | }) 45 | 46 | response = html_response(conn, 200) 47 | assert response =~ "Register" 48 | assert response =~ "must have the @ sign and no spaces" 49 | assert response =~ "should be at least 12 character" 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/guilda_web/controllers/user_session_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserSessionControllerTest do 2 | use GuildaWeb.ConnCase, async: true 3 | 4 | import Guilda.AccountsFixtures 5 | 6 | setup do 7 | %{user: user_fixture()} 8 | end 9 | 10 | describe "DELETE /users/log_out" do 11 | test "logs the user out", %{conn: conn, user: user} do 12 | conn = conn |> log_in_user(user) |> delete(Routes.user_session_path(conn, :delete)) 13 | assert redirected_to(conn) == "/" 14 | refute get_session(conn, :user_token) 15 | assert get_flash(conn, :info) =~ "Signed out successfully." 16 | end 17 | 18 | test "succeeds even if the user is not logged in", %{conn: conn} do 19 | conn = delete(conn, Routes.user_session_path(conn, :delete)) 20 | assert redirected_to(conn) == "/" 21 | refute get_session(conn, :user_token) 22 | assert get_flash(conn, :info) =~ "Signed out successfully." 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/guilda_web/controllers/user_settings_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserSettingsControllerTest do 2 | use GuildaWeb.ConnCase, async: true 3 | import Ecto.Query 4 | import Guilda.AccountsFixtures 5 | alias Guilda.Accounts 6 | alias Guilda.Accounts.User 7 | alias Guilda.AuditLog 8 | alias Guilda.Repo 9 | 10 | setup :register_and_log_in_user 11 | 12 | setup %{user: %{id: id} = user} do 13 | user = %{user | email: unique_user_email()} 14 | 15 | from(u in User, where: u.id == ^id) 16 | |> Repo.update_all(set: [email: user.email]) 17 | 18 | %{user: user} 19 | end 20 | 21 | def get_user_by_email(email), do: Repo.get_by(User, email: email) 22 | 23 | describe "GET /users/settings/confirm_email/:token" do 24 | setup %{user: user} do 25 | email = unique_user_email() 26 | 27 | token = 28 | extract_user_token(fn url -> 29 | Accounts.deliver_update_email_instructions(AuditLog.system(), %{user | email: email}, user.email, url) 30 | end) 31 | 32 | %{token: token, email: email} 33 | end 34 | 35 | test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do 36 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 37 | assert redirected_to(conn) == Routes.user_settings_path(conn, :index) 38 | assert get_flash(conn, :info) =~ "Email changed successfully" 39 | refute get_user_by_email(user.email) 40 | assert get_user_by_email(email) 41 | 42 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 43 | assert redirected_to(conn) == Routes.user_settings_path(conn, :index) 44 | assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" 45 | end 46 | 47 | test "does not update email with invalid token", %{conn: conn, user: user} do 48 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops")) 49 | assert redirected_to(conn) == Routes.user_settings_path(conn, :index) 50 | assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" 51 | assert get_user_by_email(user.email) 52 | end 53 | 54 | test "redirects if user is not logged in", %{token: token} do 55 | conn = build_conn() 56 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 57 | assert redirected_to(conn) == Routes.user_session_path(conn, :new) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/guilda_web/controllers/user_totp_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.UserTOTPControllerTest do 2 | use GuildaWeb.ConnCase, async: true 3 | 4 | import Guilda.AccountsFixtures 5 | @pending :user_totp_pending 6 | 7 | setup %{conn: conn} do 8 | user = user_fixture() 9 | conn = conn |> log_in_user(user) |> put_session(@pending, true) 10 | %{user: user, totp: user_totp_fixture(user), conn: conn} 11 | end 12 | 13 | describe "GET /users/totp" do 14 | test "renders totp page", %{conn: conn} do 15 | conn = get(conn, Routes.user_totp_path(conn, :new)) 16 | response = html_response(conn, 200) 17 | assert response =~ "Two-factor authentication" 18 | end 19 | 20 | test "redirects to login if not logged in" do 21 | conn = build_conn() 22 | 23 | assert conn 24 | |> get(Routes.user_totp_path(conn, :new)) 25 | |> redirected_to() == 26 | Routes.user_session_path(conn, :new) 27 | end 28 | 29 | test "can logout while totp is pending", %{conn: conn} do 30 | conn = delete(conn, Routes.user_session_path(conn, :delete)) 31 | assert redirected_to(conn) == Routes.home_path(conn, :index) 32 | refute get_session(conn, :user_token) 33 | assert get_flash(conn, :info) =~ "Signed out successfully" 34 | end 35 | 36 | test "redirects to dashboard if totp is not pending", %{conn: conn} do 37 | assert conn 38 | |> delete_session(@pending) 39 | |> get(Routes.user_totp_path(conn, :new)) 40 | |> redirected_to() == 41 | Routes.home_path(conn, :index) 42 | end 43 | end 44 | 45 | describe "POST /users/totp" do 46 | test "validates totp", %{conn: conn, totp: totp} do 47 | code = NimbleTOTP.verification_code(totp.secret) 48 | conn = post(conn, Routes.user_totp_path(conn, :create), %{"user" => %{"code" => code}}) 49 | assert redirected_to(conn) == Routes.home_path(conn, :index) 50 | assert get_session(conn, @pending) == nil 51 | end 52 | 53 | test "validates backup code with flash message", %{conn: conn, totp: totp} do 54 | code = Enum.random(totp.backup_codes).code 55 | 56 | new_conn = post(conn, Routes.user_totp_path(conn, :create), %{"user" => %{"code" => code}}) 57 | assert redirected_to(new_conn) == Routes.home_path(new_conn, :index) 58 | assert get_session(new_conn, @pending) == nil 59 | assert get_flash(new_conn, :info) =~ "You have 9 backup codes left" 60 | 61 | # Cannot reuse the code 62 | new_conn = post(conn, Routes.user_totp_path(conn, :create), %{"user" => %{"code" => code}}) 63 | assert html_response(new_conn, 200) =~ "Invalid two-factor authentication code" 64 | assert get_session(new_conn, @pending) 65 | end 66 | 67 | test "logs the user in with return to", %{conn: conn, totp: totp} do 68 | code = Enum.random(totp.backup_codes).code 69 | 70 | conn = 71 | conn 72 | |> put_session(:user_return_to, "/hello") 73 | |> post(Routes.user_totp_path(conn, :create), %{"user" => %{"code" => code}}) 74 | 75 | assert redirected_to(conn) == "/hello" 76 | assert get_session(conn, @pending) == nil 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/guilda_web/live/finance/finance_live_as_guest_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.FinanceLiveAsGuestTest do 2 | use GuildaWeb.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | import Guilda.FinancesFixtures 7 | 8 | def create_transaction(_) do 9 | %{transaction: insert(:transaction)} 10 | end 11 | 12 | defp path(:index, assigns) do 13 | Routes.finance_index_path(assigns.conn, :index) 14 | end 15 | 16 | defp path(:new, assigns) do 17 | Routes.finance_index_path(assigns.conn, :new) 18 | end 19 | 20 | defp path(:edit, transaction, assigns) do 21 | Routes.finance_index_path(assigns.conn, :edit, transaction) 22 | end 23 | 24 | describe "index" do 25 | test "redirects", %{conn: conn} = opts do 26 | assert {:error, {:redirect, %{to: "/users/log_in"}}} = live(conn, path(:index, opts)) 27 | end 28 | end 29 | 30 | describe "adding a new transaction" do 31 | test "redirects", %{conn: conn} = opts do 32 | assert {:error, {:redirect, %{to: "/users/log_in"}}} = live(conn, path(:new, opts)) 33 | end 34 | end 35 | 36 | describe "editting a transaction" do 37 | setup :create_transaction 38 | 39 | test "redirects", %{conn: conn, transaction: transaction} = opts do 40 | assert {:error, {:redirect, %{to: "/users/log_in"}}} = live(conn, path(:edit, transaction, opts)) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/guilda_web/live/finance/finance_live_as_user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.FinanceLiveAsUserTest do 2 | use GuildaWeb.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | import Guilda.FinancesFixtures 7 | 8 | setup :register_and_log_in_user 9 | 10 | def create_transaction(_) do 11 | %{transaction: insert(:transaction)} 12 | end 13 | 14 | defp path(:index, assigns) do 15 | Routes.finance_index_path(assigns.conn, :index) 16 | end 17 | 18 | defp path(:new, assigns) do 19 | Routes.finance_index_path(assigns.conn, :new) 20 | end 21 | 22 | defp path(:edit, transaction, assigns) do 23 | Routes.finance_index_path(assigns.conn, :edit, transaction) 24 | end 25 | 26 | describe "index" do 27 | test "disconnected and connected render", %{conn: conn} = opts do 28 | {:ok, view, disconnected_html} = live(conn, path(:index, opts)) 29 | assert disconnected_html =~ "Finances" 30 | assert render(view) =~ "Finances" 31 | assert disconnected_html =~ "Beneficiary" 32 | assert render(view) =~ "Beneficiary" 33 | end 34 | 35 | test "does not display a button to add a new transaction", %{conn: conn} = opts do 36 | {:ok, view, _html} = live(conn, path(:index, opts)) 37 | refute view |> element("a[href='#{path(:new, opts)}']") |> has_element?() 38 | end 39 | end 40 | 41 | describe "index with transactions" do 42 | setup :create_transaction 43 | 44 | test "displays the transactions", %{conn: conn, transaction: transaction} = opts do 45 | {:ok, _view, html} = live(conn, path(:index, opts)) 46 | assert html =~ transaction.payee 47 | end 48 | 49 | test "does not display a button to edit the transaction", %{conn: conn, transaction: transaction} = opts do 50 | {:ok, view, _html} = live(conn, path(:index, opts)) 51 | refute view |> element("a[href='#{path(:edit, transaction, opts)}']") |> has_element?() 52 | end 53 | 54 | test "does not display a button to delete the transaction", %{conn: conn, transaction: transaction} = opts do 55 | {:ok, view, _html} = live(conn, path(:index, opts)) 56 | refute view |> element("button[phx-click=delete][phx-value-id='#{transaction.id}']") |> has_element?() 57 | end 58 | end 59 | 60 | describe "adding a new transaction" do 61 | test "redirects", %{conn: conn} = opts do 62 | finances_index = path(:index, opts) 63 | assert {:error, {:live_redirect, %{to: ^finances_index}}} = live(conn, path(:new, opts)) 64 | end 65 | end 66 | 67 | describe "editting a transaction" do 68 | setup :create_transaction 69 | 70 | test "redirects", %{conn: conn, transaction: transaction} = opts do 71 | finances_index = path(:index, opts) 72 | assert {:error, {:live_redirect, %{to: ^finances_index}}} = live(conn, path(:edit, transaction, opts)) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/guilda_web/live/home_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.HomeLiveTest do 2 | use GuildaWeb.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | import Guilda.PodcastsFixtures 7 | 8 | describe "index" do 9 | test "disconnected and connected render", %{conn: conn} do 10 | {:ok, view, disconnected_html} = live(conn, "/") 11 | refute disconnected_html =~ "Listen to the new" 12 | assert disconnected_html =~ "Welcome to" 13 | assert render(view) =~ "Welcome to" 14 | end 15 | 16 | test "displays a link to podcasts if there are episodes", %{conn: conn} do 17 | insert(:episode) 18 | {:ok, _live, html} = live(conn, "/") 19 | assert html =~ "Listen to the new" 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/guilda_web/live/podcast/podcast_episode_live_as_guest_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.PodcastEpisodeAsGuestLiveTest do 2 | use GuildaWeb.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | import Guilda.PodcastsFixtures 7 | 8 | defp create_episode(_) do 9 | %{episode: insert(:episode)} 10 | end 11 | 12 | defp path(:index, assigns) do 13 | Routes.podcast_episode_index_path(assigns.conn, :index) 14 | end 15 | 16 | defp path(:new, assigns) do 17 | Routes.podcast_episode_index_path(assigns.conn, :new) 18 | end 19 | 20 | defp path(:edit, episode, assigns) do 21 | Routes.podcast_episode_index_path(assigns.conn, :edit, episode) 22 | end 23 | 24 | describe "index" do 25 | setup :create_episode 26 | 27 | test "list all podcast episodes", %{conn: conn, episode: episode} = opts do 28 | {:ok, view, html} = live(conn, path(:index, opts)) 29 | 30 | assert has_element?(view, "h2", "Quem Programa?, Guilda's podcast") 31 | assert html =~ episode.title 32 | end 33 | 34 | test "does not display a button to add a new episode", %{conn: conn} = opts do 35 | {:ok, view, _html} = live(conn, path(:index, opts)) 36 | refute has_element?(view, "a[href='#{path(:new, opts)}']") 37 | end 38 | 39 | test "does not display a button to edit the episode", %{conn: conn, episode: episode} = opts do 40 | {:ok, view, _html} = live(conn, path(:index, opts)) 41 | refute has_element?(view, "a[href='#{path(:edit, episode, opts)}']") 42 | end 43 | 44 | test "does not display a button to delete the episode", %{conn: conn, episode: episode} = opts do 45 | {:ok, view, _html} = live(conn, path(:index, opts)) 46 | refute has_element?(view, "button[phx-click=delete][phx-value-id='#{episode.id}']") 47 | end 48 | end 49 | 50 | describe "adding a new episode" do 51 | test "redirects", %{conn: conn} = opts do 52 | podcast_index = path(:index, opts) 53 | assert {:error, {:live_redirect, %{to: ^podcast_index}}} = live(conn, path(:new, opts)) 54 | end 55 | end 56 | 57 | describe "editting an episode" do 58 | setup :create_episode 59 | 60 | test "redirects", %{conn: conn} = opts do 61 | podcast_index = path(:index, opts) 62 | assert {:error, {:live_redirect, %{to: ^podcast_index}}} = live(conn, path(:new, opts)) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/guilda_web/live/podcast/podcast_episode_live_as_user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.PodcastEpisodeAsUserLiveTest do 2 | use GuildaWeb.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | import Guilda.PodcastsFixtures 7 | 8 | setup :register_and_log_in_user 9 | 10 | defp create_episode(_) do 11 | %{episode: insert(:episode)} 12 | end 13 | 14 | defp path(:index, assigns) do 15 | Routes.podcast_episode_index_path(assigns.conn, :index) 16 | end 17 | 18 | defp path(:new, assigns) do 19 | Routes.podcast_episode_index_path(assigns.conn, :new) 20 | end 21 | 22 | defp path(:edit, episode, assigns) do 23 | Routes.podcast_episode_index_path(assigns.conn, :edit, episode) 24 | end 25 | 26 | describe "index" do 27 | setup :create_episode 28 | 29 | test "list all podcast episodes", %{conn: conn, episode: episode} = opts do 30 | {:ok, view, html} = live(conn, path(:index, opts)) 31 | 32 | assert has_element?(view, "h2", "Quem Programa?, Guilda's podcast") 33 | assert html =~ episode.title 34 | end 35 | 36 | test "does not display a button to add a new episode", %{conn: conn} = opts do 37 | {:ok, view, _html} = live(conn, path(:index, opts)) 38 | refute has_element?(view, "a[href='#{path(:new, opts)}']") 39 | end 40 | 41 | test "does not display a button to edit the episode", %{conn: conn, episode: episode} = opts do 42 | {:ok, view, _html} = live(conn, path(:index, opts)) 43 | refute has_element?(view, "a[href='#{path(:edit, episode, opts)}']") 44 | end 45 | 46 | test "does not display a button to delete the episode", %{conn: conn, episode: episode} = opts do 47 | {:ok, view, _html} = live(conn, path(:index, opts)) 48 | refute has_element?(view, "button[phx-click=delete][phx-value-id='#{episode.id}']") 49 | end 50 | end 51 | 52 | describe "adding a new episode" do 53 | test "redirects", %{conn: conn} = opts do 54 | podcast_index = path(:index, opts) 55 | assert {:error, {:live_redirect, %{to: ^podcast_index}}} = live(conn, path(:new, opts)) 56 | end 57 | end 58 | 59 | describe "editting an episode" do 60 | setup :create_episode 61 | 62 | test "redirects", %{conn: conn} = opts do 63 | podcast_index = path(:index, opts) 64 | assert {:error, {:live_redirect, %{to: ^podcast_index}}} = live(conn, path(:new, opts)) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/guilda_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.ErrorViewTest do 2 | use GuildaWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(GuildaWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(GuildaWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/guilda_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.LayoutViewTest do 2 | use GuildaWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | 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 GuildaWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | alias Ecto.Adapters.SQL.Sandbox 21 | 22 | using do 23 | quote do 24 | # Import conveniences for testing with channels 25 | import Phoenix.ChannelTest 26 | import GuildaWeb.ChannelCase 27 | 28 | # The default endpoint for testing 29 | @endpoint GuildaWeb.Endpoint 30 | end 31 | end 32 | 33 | setup tags do 34 | :ok = Sandbox.checkout(Guilda.Repo) 35 | 36 | unless tags[:async] do 37 | Sandbox.mode(Guilda.Repo, {:shared, self()}) 38 | end 39 | 40 | :ok 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule GuildaWeb.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 GuildaWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | alias Ecto.Adapters.SQL.Sandbox 21 | 22 | using do 23 | quote do 24 | # Import conveniences for testing with connections 25 | import Plug.Conn 26 | import Phoenix.ConnTest 27 | import GuildaWeb.ConnCase 28 | 29 | alias GuildaWeb.Router.Helpers, as: Routes 30 | 31 | # The default endpoint for testing 32 | @endpoint GuildaWeb.Endpoint 33 | end 34 | end 35 | 36 | defp wait_for_children(children_lookup) when is_function(children_lookup) do 37 | Process.sleep(100) 38 | 39 | for pid <- children_lookup.() do 40 | ref = Process.monitor(pid) 41 | assert_receive {:DOWN, ^ref, _, _, _}, 1000 42 | end 43 | end 44 | 45 | setup tags do 46 | pid = Sandbox.start_owner!(Guilda.Repo, shared: not tags[:async]) 47 | on_exit(fn -> Sandbox.stop_owner(pid) end) 48 | 49 | on_exit(fn -> 50 | wait_for_children(fn -> GuildaWeb.Presence.fetchers_pids() end) 51 | end) 52 | 53 | {:ok, conn: Phoenix.ConnTest.build_conn()} 54 | end 55 | 56 | @doc """ 57 | Setup helper that registers and logs in users. 58 | 59 | setup :register_and_log_in_user 60 | 61 | It stores an updated connection and a registered user in the 62 | test context. 63 | """ 64 | def register_and_log_in_user(%{conn: conn}) do 65 | user = Guilda.AccountsFixtures.user_fixture() 66 | %{conn: log_in_user(conn, user), user: user} 67 | end 68 | 69 | @doc """ 70 | Setup helper that registers and logs in an admin user. 71 | 72 | setup :register_and_log_in_admin_user 73 | 74 | It stores an updated connection and a registered user in the 75 | test context. 76 | """ 77 | def register_and_log_in_admin_user(%{conn: conn}) do 78 | user = Guilda.AccountsFixtures.user_fixture() 79 | Guilda.Accounts.give_admin(user) 80 | 81 | %{conn: log_in_user(conn, user), user: user} 82 | end 83 | 84 | @doc """ 85 | Logs the given `user` into the `conn`. 86 | 87 | It returns an updated `conn`. 88 | """ 89 | def log_in_user(conn, user) do 90 | token = Guilda.Accounts.generate_user_session_token(user) 91 | 92 | conn 93 | |> Phoenix.ConnTest.init_test_session(%{}) 94 | |> Plug.Conn.put_session(:user_token, token) 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.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 Guilda.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | alias Ecto.Adapters.SQL.Sandbox 20 | 21 | using do 22 | quote do 23 | alias Guilda.Repo 24 | 25 | import Ecto 26 | import Ecto.Changeset 27 | import Ecto.Query 28 | import Guilda.DataCase 29 | import Guilda.AuditLog, only: [system: 0] 30 | end 31 | end 32 | 33 | setup tags do 34 | :ok = Sandbox.checkout(Guilda.Repo) 35 | 36 | unless tags[:async] do 37 | Sandbox.mode(Guilda.Repo, {:shared, self()}) 38 | end 39 | 40 | :ok 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/fixtures/accounts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.AccountsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Guilda.Accounts` context. 5 | """ 6 | alias Guilda.Accounts 7 | alias Guilda.Accounts.UserTOTP 8 | alias Guilda.AuditLog 9 | alias Guilda.Repo 10 | 11 | @totp_secret Base.decode32!("PTEPUGZ7DUWTBGMW4WLKB6U63MGKKMCA") 12 | 13 | def unique_user_telegram_id, do: System.unique_integer() |> Integer.to_string() 14 | def unique_user_email, do: "user#{System.unique_integer()}@example.com" 15 | def valid_user_password, do: "hello world!" 16 | def valid_totp_secret, do: @totp_secret 17 | 18 | def user_fixture(attrs \\ %{}) do 19 | {confirmed, attrs} = attrs |> Map.new() |> Map.pop(:confirmed, true) 20 | {:ok, user} = Accounts.register_user(AuditLog.system(), valid_user_attributes(attrs)) 21 | 22 | if confirmed do 23 | confirm(user) 24 | else 25 | user 26 | end 27 | end 28 | 29 | def user_totp_fixture(user) do 30 | %UserTOTP{} 31 | |> Ecto.Changeset.change(user_id: user.id, secret: valid_totp_secret()) 32 | |> UserTOTP.ensure_backup_codes() 33 | |> Repo.insert!() 34 | end 35 | 36 | def extract_user_token(fun) do 37 | {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") 38 | [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") 39 | token 40 | end 41 | 42 | def valid_user_attributes(attrs \\ %{}) do 43 | Enum.into(attrs, %{ 44 | email: unique_user_email(), 45 | password: valid_user_password() 46 | }) 47 | end 48 | 49 | defp confirm(user) do 50 | token = 51 | extract_user_token(fn url -> 52 | Accounts.deliver_user_confirmation_instructions(user, url) 53 | end) 54 | 55 | {:ok, user} = Accounts.confirm_user(token) 56 | user 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/support/fixtures/finances_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.FinancesFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Guilda.Finances` context. 5 | """ 6 | 7 | use ExMachina.Ecto, repo: Guilda.Repo 8 | 9 | def transaction_factory do 10 | %Guilda.Finances.Transaction{ 11 | amount: 20, 12 | date: Timex.today(), 13 | note: "some note", 14 | payee: "some payee" 15 | } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/fixtures/podcasts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.PodcastsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Guilda.Podcasts` context. 5 | """ 6 | 7 | use ExMachina.Ecto, repo: Guilda.Repo 8 | 9 | def episode_factory do 10 | %Guilda.Podcasts.Episode{ 11 | title: "some title", 12 | slug: "some slug", 13 | description: "some description", 14 | hosts: "some hosts", 15 | aired_date: "2020-01-01", 16 | cover_url: "some cover url", 17 | cover_name: "some cover name", 18 | cover_type: "some cover type", 19 | cover_size: 42, 20 | file_url: "some file url", 21 | file_name: "some file name", 22 | file_type: "some file type", 23 | file_size: 42, 24 | length: 42, 25 | play_count: 42 26 | } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/presence/client_mock.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.Presence.Client.Mock do 2 | @moduledoc false 3 | def init(_opts) do 4 | {:ok, %{}} 5 | end 6 | 7 | def handle_join(_topic, _key, _meta, state) do 8 | {:ok, state} 9 | end 10 | 11 | def handle_leave(_topic, _key, _meta, state) do 12 | {:ok, state} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/support/presence/presence_mock.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.Presence.Client.PresenceMock do 2 | @moduledoc false 3 | use GenServer 4 | alias Phoenix.Presence.Client 5 | 6 | def start_link(opts \\ []) do 7 | GenServer.start_link(__MODULE__, opts[:id], opts) 8 | end 9 | 10 | @impl true 11 | def init(id) do 12 | {:ok, %{id: id}} 13 | end 14 | 15 | def track(client_pid, pid, topic, key, meta \\ %{}) do 16 | GenServer.cast(pid, {:track, client_pid, topic, key, meta}) 17 | end 18 | 19 | @impl true 20 | def handle_info(:quit, state) do 21 | {:stop, :normal, state} 22 | end 23 | 24 | @impl true 25 | def handle_cast({:track, client_pid, topic, key, meta}, state) do 26 | Client.track(client_pid, topic, key, meta) 27 | {:noreply, state} 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/support/swoosh_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Guilda.SwooshHelpers do 2 | @moduledoc false 3 | alias Swoosh.Adapters.Local.Storage.Memory 4 | 5 | @doc """ 6 | Asserts exactly one email with given `attributes` was received. 7 | 8 | Attributes can be `:to` (required) and `:subject`. 9 | 10 | Returns the matched email. 11 | """ 12 | def assert_received_email(attributes) do 13 | to = Keyword.fetch!(attributes, :to) 14 | subject = Keyword.get(attributes, :subject) 15 | html_body = Keyword.get(attributes, :html_body) 16 | 17 | emails = 18 | for email <- Memory.all(), 19 | to in Enum.map(email.to, &elem(&1, 1)), 20 | (!subject or email.subject == subject) and (!html_body or email.html_body =~ html_body) do 21 | email 22 | end 23 | 24 | case emails do 25 | [email] -> 26 | email 27 | 28 | [] -> 29 | raise "expected exactly one email with #{inspect(attributes)}, got none" 30 | 31 | other -> 32 | raise """ 33 | expected exactly one email with #{inspect(attributes)}, got: 34 | 35 | #{inspect(other, pretty: true)} 36 | """ 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Guilda.Repo, :manual) 3 | --------------------------------------------------------------------------------