├── .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 | <%= col.label %>
27 | <% end %>
28 |
29 |
30 |
31 | <%= if @rows == [] do %>
32 |
33 | <%= @empty_state %>
34 |
35 | <% end %>
36 | <%= for row <- @rows do %>
37 |
38 | <%= for col <- @col do %>
39 |
40 | <%= render_slot(col, row) %>
41 |
42 | <% end %>
43 |
44 | <% end %>
45 |
46 |
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 |
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 |
10 |
11 | <%= gettext(
12 | "We are an inclusive community where all kinds of issues are addressed, including programming. "
13 | ) %>
14 |
15 |
16 |
17 |
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 |
41 |
46 |
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 |
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 |
5 |
6 | <%= msg %>
7 |
8 |
9 | <% end %>
10 | <%= if msg = get_flash(@conn, :error) do %>
11 |
12 |
13 | <%= msg %>
14 |
15 |
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 |
5 |
6 | <%= msg %>
7 |
8 |
9 | <% end %>
10 | <%= if msg = live_flash(@flash, :error) do %>
11 |
12 |
13 | <%= msg %>
14 |
15 |
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 |
48 |
49 | <%= gettext("Or continue with") %>
50 |
51 |
52 |
53 |
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 |
--------------------------------------------------------------------------------