├── .dockerignore ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── e2e.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── apps ├── cf │ ├── README.md │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── lib │ │ ├── accounts │ │ │ ├── accounts.ex │ │ │ ├── invitations.ex │ │ │ ├── user_permissions.ex │ │ │ └── username_generator.ex │ │ ├── actions │ │ │ ├── action_creator.ex │ │ │ ├── actions.ex │ │ │ ├── reputation_change.ex │ │ │ ├── reputation_change_config_loader.ex │ │ │ ├── validator.ex │ │ │ └── validator_base.ex │ │ ├── algolia │ │ │ ├── SpeakersIndex.ex │ │ │ ├── StatementsIndex.ex │ │ │ └── VideosIndex.ex │ │ ├── application.ex │ │ ├── authenticator │ │ │ ├── authenticator.ex │ │ │ ├── guardian_impl.ex │ │ │ ├── oauth.ex │ │ │ ├── oauth │ │ │ │ └── facebook.ex │ │ │ └── provider_infos.ex │ │ ├── comments │ │ │ └── comments.ex │ │ ├── errors │ │ │ └── errors.ex │ │ ├── gettext.ex │ │ ├── llms │ │ │ ├── statements_creator.ex │ │ │ └── templates │ │ │ │ ├── statements_extractor_system_prompt.eex │ │ │ │ └── statements_extractor_user_prompt.eex │ │ ├── mailer │ │ │ ├── email.ex │ │ │ ├── formatter.ex │ │ │ ├── mailer.ex │ │ │ ├── templates │ │ │ │ ├── _layout.html.eex │ │ │ │ ├── _layout.text.eex │ │ │ │ ├── invitation.en.html.eex │ │ │ │ ├── invitation.en.text.eex │ │ │ │ ├── invitation.fr.html.eex │ │ │ │ ├── invitation.fr.text.eex │ │ │ │ ├── newsletter.en.html.eex │ │ │ │ ├── newsletter.en.text.eex │ │ │ │ ├── newsletter.fr.html.eex │ │ │ │ ├── newsletter.fr.text.eex │ │ │ │ ├── reputation_loss.en.html.eex │ │ │ │ ├── reputation_loss.en.text.eex │ │ │ │ ├── reputation_loss.fr.html.eex │ │ │ │ ├── reputation_loss.fr.text.eex │ │ │ │ ├── reset_password.en.html.eex │ │ │ │ ├── reset_password.en.text.eex │ │ │ │ ├── reset_password.fr.html.eex │ │ │ │ ├── reset_password.fr.text.eex │ │ │ │ ├── welcome.en.html.eex │ │ │ │ ├── welcome.en.text.eex │ │ │ │ ├── welcome.fr.html.eex │ │ │ │ └── welcome.fr.text.eex │ │ │ └── view.ex │ │ ├── moderation │ │ │ ├── flagger.ex │ │ │ ├── moderation.ex │ │ │ └── moderation_entry.ex │ │ ├── notifications │ │ │ ├── notification_builder.ex │ │ │ ├── notifications.ex │ │ │ ├── subscriptions.ex │ │ │ └── subscriptions_matcher.ex │ │ ├── sources │ │ │ ├── fetcher.ex │ │ │ └── sources.ex │ │ ├── speakers │ │ │ └── speakers.ex │ │ ├── statements │ │ │ └── statements.ex │ │ ├── utils │ │ │ ├── frontend_router.ex │ │ │ └── utils.ex │ │ ├── video_debate │ │ │ └── history.ex │ │ └── videos │ │ │ ├── captions_fetcher.ex │ │ │ ├── captions_fetcher_test.ex │ │ │ ├── captions_fetcher_youtube.ex │ │ │ ├── captions_srv1_parser.ex │ │ │ ├── metadata_fetcher.ex │ │ │ ├── metadata_fetcher_opengraph.ex │ │ │ ├── metadata_fetcher_test.ex │ │ │ ├── metadata_fetcher_youtube.ex │ │ │ └── videos.ex │ ├── mix.exs │ ├── priv │ │ ├── gettext │ │ │ ├── fr │ │ │ │ └── LC_MESSAGES │ │ │ │ │ └── mail.po │ │ │ └── mail.pot │ │ ├── limitations.yaml │ │ └── reputation_changes.yaml │ └── test │ │ ├── accounts │ │ ├── accounts_test.exs │ │ ├── invitations_test.exs │ │ └── user_permissions_test.exs │ │ ├── actions │ │ ├── flagger_test.exs │ │ ├── reputation_change_config_loader_test.exs │ │ └── reputation_change_test.exs │ │ ├── algolia │ │ ├── speakers_index_test.exs │ │ ├── statements_index_test.exs │ │ └── videos_index_test.exs │ │ ├── authenticator │ │ ├── authenticator_test.exs │ │ ├── oauth │ │ │ └── facebook_test.exs │ │ └── oauth_test.exs │ │ ├── comments │ │ └── comments_test.exs │ │ ├── mailer │ │ ├── email_test.exs │ │ └── fomatter_test.exs │ │ ├── moderation │ │ └── moderation_test.exs │ │ ├── notifications │ │ ├── notification_builder_test.exs │ │ ├── notifications_test.exs │ │ ├── subscribtions_test.exs │ │ └── subscriptions_matcher_test.exs │ │ ├── sources │ │ ├── fetcher_test.exs │ │ └── sources_test.exs │ │ ├── speakers │ │ └── speakers_test.exs │ │ ├── support │ │ ├── data_case.ex │ │ ├── meta_page.ex │ │ └── test_utils.ex │ │ ├── test_helper.exs │ │ ├── utils │ │ ├── cf_utils_test.exs │ │ └── frontend_router_test.exs │ │ └── videos │ │ └── videos_test.exs ├── cf_atom_feed │ ├── .gitignore │ ├── README.md │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── lib │ │ ├── application.ex │ │ ├── comments.ex │ │ ├── common.ex │ │ ├── flags.ex │ │ ├── router.ex │ │ ├── statements.ex │ │ └── videos.ex │ ├── mix.exs │ └── test │ │ ├── comments_test.exs │ │ ├── statements_test.exs │ │ └── test_helper.exs ├── cf_graphql │ ├── .gitignore │ ├── README.md │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── lib │ │ ├── application.ex │ │ ├── auth_pipeline.ex │ │ ├── captain_fact_graphql_web.ex │ │ ├── endpoint.ex │ │ ├── resolvers │ │ │ ├── app_info.ex │ │ │ ├── comments.ex │ │ │ ├── notifications.ex │ │ │ ├── speakers.ex │ │ │ ├── statements.ex │ │ │ ├── statistics.ex │ │ │ ├── users.ex │ │ │ └── videos.ex │ │ ├── router.ex │ │ └── schema │ │ │ ├── input_objects │ │ │ ├── statement_filter.ex │ │ │ └── video_filter.ex │ │ │ ├── middleware │ │ │ ├── require_authentication.ex │ │ │ └── require_reputation.ex │ │ │ ├── schema.ex │ │ │ ├── types │ │ │ ├── app_info.ex │ │ │ ├── comment.ex │ │ │ ├── notification.ex │ │ │ ├── paginated.ex │ │ │ ├── source.ex │ │ │ ├── speaker.ex │ │ │ ├── statement.ex │ │ │ ├── statistics.ex │ │ │ ├── subscription.ex │ │ │ ├── user.ex │ │ │ ├── user_action.ex │ │ │ ├── video.ex │ │ │ └── video_caption.ex │ │ │ └── utils.ex │ ├── mix.exs │ ├── priv │ │ ├── keys │ │ │ ├── fullchain.pem │ │ │ └── privkey.pem │ │ └── secrets │ │ │ ├── basic_auth_password │ │ │ ├── host │ │ │ └── secret_key_base │ └── test │ │ ├── support │ │ ├── conn_case.ex │ │ └── data_case.ex │ │ └── test_helper.exs ├── cf_jobs │ ├── README.md │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── lib │ │ ├── application.ex │ │ ├── job.ex │ │ ├── jobs │ │ │ ├── create_notifications.ex │ │ │ ├── download_captions.ex │ │ │ ├── flags.ex │ │ │ ├── moderation.ex │ │ │ └── reputation.ex │ │ ├── report_manager.ex │ │ └── scheduler.ex │ ├── mix.exs │ └── test │ │ ├── jobs │ │ ├── create_notifications_test.exs │ │ ├── moderation_test.exs │ │ └── reputation_test.exs │ │ ├── support │ │ └── data_case.ex │ │ └── test_helper.exs ├── cf_rest_api │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── lib │ │ ├── application.ex │ │ ├── channels │ │ │ ├── comments_channel.ex │ │ │ ├── presence.ex │ │ │ ├── statements_channel.ex │ │ │ ├── user_socket.ex │ │ │ ├── video_debate_channel.ex │ │ │ └── video_debate_history_channel.ex │ │ ├── controllers │ │ │ ├── api_info_controller.ex │ │ │ ├── auth_controller.ex │ │ │ ├── fallback_controller.ex │ │ │ ├── moderation_controller.ex │ │ │ ├── speaker_controller.ex │ │ │ ├── statement_controller.ex │ │ │ ├── user_controller.ex │ │ │ └── video_controller.ex │ │ ├── cors.ex │ │ ├── endpoint.ex │ │ ├── rest_api.ex │ │ ├── router.ex │ │ ├── security_headers.ex │ │ └── views │ │ │ ├── changeset_view.ex │ │ │ ├── comment_view.ex │ │ │ ├── error_helpers.ex │ │ │ ├── error_view.ex │ │ │ ├── flag_view.ex │ │ │ ├── moderation_entry_view.ex │ │ │ ├── speaker_view.ex │ │ │ ├── statement_view.ex │ │ │ ├── user_action_view.ex │ │ │ ├── user_view.ex │ │ │ ├── video_view.ex │ │ │ └── vote_view.ex │ ├── mix.exs │ ├── priv │ │ └── keys │ │ │ ├── fullchain.pem │ │ │ └── privkey.pem │ └── test │ │ ├── channels │ │ ├── comments_channel_test.exs │ │ └── video_debate_channel_test.exs │ │ ├── controllers │ │ ├── api_info_controller_test.exs │ │ ├── auth_controller_test.exs │ │ ├── moderation_controller_test.exs │ │ ├── user_controller_test.exs │ │ └── video_controller_test.exs │ │ ├── support │ │ ├── channel_case.ex │ │ └── conn_case.ex │ │ ├── test_helper.exs │ │ └── views │ │ └── error_view_test.exs ├── cf_reverse_proxy │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ └── test.exs │ ├── lib │ │ ├── application.ex │ │ └── plug.ex │ └── mix.exs └── db │ ├── README.md │ ├── config │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ └── test.exs │ ├── lib │ ├── db │ │ ├── application.ex │ │ ├── release_tasks.ex │ │ ├── repo.ex │ │ └── statistics.ex │ ├── db_schema │ │ ├── comment.ex │ │ ├── flag.ex │ │ ├── invitation_request.ex │ │ ├── moderation_user_feedback.ex │ │ ├── notification.ex │ │ ├── reset_password_request.ex │ │ ├── source.ex │ │ ├── speaker.ex │ │ ├── statement.ex │ │ ├── subscription.ex │ │ ├── user.ex │ │ ├── user_action.ex │ │ ├── users_actions_report.ex │ │ ├── video.ex │ │ ├── video_caption.ex │ │ ├── video_speaker.ex │ │ └── vote.ex │ ├── db_type │ │ ├── achievement.ex │ │ ├── entity.ex │ │ ├── flag_reason.ex │ │ ├── notification_type.ex │ │ ├── speaker_picture.ex │ │ ├── subscription_reason.ex │ │ ├── user_action_type.ex │ │ ├── user_picture.ex │ │ └── video_hash_id.ex │ ├── db_utils │ │ ├── string.ex │ │ └── token_generator.ex │ └── query │ │ └── query.ex │ ├── mix.exs │ ├── priv │ ├── repo │ │ ├── migrations │ │ │ ├── 20170118223600_create_postgres_extensions.exs │ │ │ ├── 20170118223631_create_users.exs │ │ │ ├── 20170125235612_create_videos.exs │ │ │ ├── 20170206062334_create_speakers.exs │ │ │ ├── 20170206063137_create_statements.exs │ │ │ ├── 20170221035619_create_video_speaker.exs │ │ │ ├── 20170309214200_create_source.exs │ │ │ ├── 20170309214307_create_comment.exs │ │ │ ├── 20170316233954_create_vote.exs │ │ │ ├── 20170428062411_create_video_debate_action.exs │ │ │ ├── 20170611075306_create_flag.exs │ │ │ ├── 20170726224741_create_accounts_reset_password_request.exs │ │ │ ├── 20170730064848_create_invitation_requests.exs │ │ │ ├── 20170928043353_add_language_to_videos.exs │ │ │ ├── 20171003220327_create_achievements.exs │ │ │ ├── 20171003220416_add_achievements_to_users.exs │ │ │ ├── 20171004100258_create_users_actions.exs │ │ │ ├── 20171005001838_create_users_actions_reports.exs │ │ │ ├── 20171005215001_rename_flag_type_to_flag_entity_and_remove_unused_indexes.exs │ │ │ ├── 20171009065840_delete_video_debate_action.exs │ │ │ ├── 20171026222425_add_today_reputation_gain_to_user.exs │ │ │ ├── 20171105124655_change_flags_to_flag_actions.exs │ │ │ ├── 20171109105152_create_moderation_users_feedbacks.exs │ │ │ ├── 20171110040302_allow_null_user_on_user_action.exs │ │ │ ├── 20171110212108_rename_comment_is_banned_to_is_reported.exs │ │ │ ├── 20171117131508_add_slug_to_speakers.exs │ │ │ ├── 20171119075520_delete_achievements.exs │ │ │ ├── 20171205174328_add_newsletter_to_user.exs │ │ │ ├── 20180131002547_add_is_publisher_to_users.exs │ │ │ ├── 20180302024059_nilify_user_on_user_action_when_deleting.exs │ │ │ ├── 20180317062636_add_unlisted_to_videos.exs │ │ │ ├── 20180330204602_add_og_url_to_source.exs │ │ │ ├── 20180409035326_add_flag_reason_to_moderation_users_feedbacks.exs │ │ │ ├── 20180503083056_add_locale_to_invitation_request.exs │ │ │ ├── 20180516170544_add_is_partner_to_video.exs │ │ │ ├── 20180605085958_add_completed_onboarding_to_users.exs │ │ │ ├── 20180605144832_add_speaker_to_user.exs │ │ │ ├── 20180730092029_allow_null_user_for_comment.exs │ │ │ ├── 20180801105246_guardiandb.exs │ │ │ ├── 20180802155107_make_speaker_slug_case_insensitive.exs │ │ │ ├── 20180803143819_increase_max_comment_text_length.exs │ │ │ ├── 20180816112748_change_wikidata_item_id_type_to_string.exs │ │ │ ├── 20180816115534_remove_speaker_is_user_defined_column.exs │ │ │ ├── 20180827123706_add_hash_id_to_videos.exs │ │ │ ├── 20180828124124_add_relationships_to_user_actions.exs │ │ │ ├── 20181010105152_create_video_captions.exs │ │ │ ├── 20181109223648_increate_source_max_url_length.exs │ │ │ ├── 20181109233422_add_file_type_to_source.exs │ │ │ ├── 20181209205427_videos_providers_as_columns.exs │ │ │ ├── 20190110165430_create_subscriptions.exs │ │ │ ├── 20190110171405_create_notifications.exs │ │ │ ├── 20200224124536_add-facebook-id-to-videos.exs │ │ │ ├── 20200224211412_add-thumbnail-to-videos.exs │ │ │ ├── 20210709102556_add-reputation-to-user-actions.exs │ │ │ ├── 20210930122534_increase_statement_max_length.exs │ │ │ ├── 20240618055503_update_video_captions.exs │ │ │ └── 20240915080224_add-draft-to-statements.exs │ │ ├── seed_data │ │ │ └── politicians_born_after_1945_having_a_picture.csv │ │ ├── seed_politicians.exs │ │ ├── seed_with_csv.exs │ │ ├── seeds.exs │ │ └── structure.sql │ └── secrets │ │ ├── db_hostname │ │ ├── db_name │ │ ├── db_password │ │ └── db_username │ └── test │ ├── db │ └── statistics_test.exs │ ├── db_schema │ ├── comment_test.exs │ ├── flag_test.exs │ ├── moderation_user_feedback_test.exs │ ├── notification_test.exs │ ├── source_test.exs │ ├── speaker_test.exs │ ├── statement_test.exs │ ├── subscribtion_test.exs │ ├── user_action_test.exs │ ├── user_test.exs │ ├── video_test.exs │ └── vote_test.exs │ ├── db_type │ ├── achievement_test.exs │ ├── user_picture.ex │ └── video_hash_id_test.exs │ ├── db_utils │ └── string_test.exs │ ├── support │ ├── data_case.ex │ └── factory.ex │ └── test_helper.exs ├── config ├── config.exs └── releases.exs ├── mix.exs ├── mix.lock └── scripts ├── download-captions.sh ├── run_e2e_ci.sh └── test_release.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .idea 4 | .travis.yml 5 | 6 | # Elixir 7 | _build 8 | .elixir_ls 9 | deps 10 | apps/*/test 11 | 12 | # Project-specific 13 | resources 14 | dev 15 | priv 16 | 17 | # Docker 18 | .dockerignore 19 | Dockerfile 20 | 21 | # Secrets 22 | apps/*/priv/secrets/* 23 | !apps/*/priv/secrets/.keep 24 | 25 | # File types 26 | tmp 27 | erl_crash.dump 28 | *.ez 29 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | "apps/*/{lib,config,test}/**/*.{ex,exs}", 4 | "apps/*/mix.exs", 5 | "mix.exs" 6 | ] 7 | ] 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | day: wednesday 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | env: 6 | MIX_ENV: test 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-20.04 # Remember to update this when changing Erlang version. See https://github.com/erlef/setup-beam 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: erlef/setup-beam@v1 14 | with: 15 | version-file: .tool-versions 16 | version-type: strict 17 | - name: Check format 18 | run: mix format --check-formatted 19 | 20 | test: 21 | runs-on: ubuntu-20.04 # Remember to update this when changing Erlang version. See https://github.com/erlef/setup-beam 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | services: 25 | db: 26 | image: postgres:9.6 27 | ports: ["5432:5432"] 28 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 29 | env: 30 | POSTGRES_USER: postgres 31 | POSTGRES_PASSWORD: postgres 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: actions/cache@v4 35 | with: 36 | path: deps 37 | key: ${{ runner.os }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 38 | restore-keys: | 39 | ${{ runner.os }}-mix- 40 | - uses: erlef/setup-beam@v1 41 | with: 42 | version-file: .tool-versions 43 | version-type: strict 44 | - name: Install dependencies 45 | run: mix deps.get 46 | - name: Prepare DB 47 | run: mix ecto.create && mix ecto.migrate 48 | - name: Run tests 49 | run: mix coveralls.github --umbrella 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | deps 3 | erl_crash.dump 4 | 5 | # Local uploads 6 | dev/resources 7 | resources 8 | 9 | # Release binaries 10 | rel/docker/*.tar 11 | 12 | # Coverage reports 13 | apps/*/cover 14 | 15 | # Misc 16 | until_fail.sh 17 | 18 | # Secrets 19 | apps/*/priv/secrets/* 20 | !apps/*/priv/secrets/.keep 21 | **/*.secret.exs 22 | 23 | # Elixir LS 24 | .elixir_ls 25 | 26 | # IDE 27 | .history 28 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 23.3.4.18 2 | elixir 1.12.3-otp-23 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Welcome to the CaptainFact community 2 | 3 | Accept and offer criticism constructively. Let anyone have the privacy they desire. 4 | 5 | Settle differences within these boundaries. 6 | 7 | Finding yourself unable to do so, e-mail contact@captainfact.io, answered by the project team. 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elixir:1.12.3-alpine 2 | RUN apk update && apk upgrade 3 | RUN apk add bash imagemagick curl gcc make libc-dev libgcc && rm -rf /var/cache/apk/* 4 | 5 | ARG MIX_ENV=prod 6 | ENV HOME=/opt/app/ SHELL=/bin/bash MIX_ENV=$MIX_ENV 7 | WORKDIR /opt/build 8 | 9 | # Cache dependencies 10 | COPY mix.exs mix.lock ./ 11 | COPY apps/cf/mix.exs ./apps/cf/ 12 | COPY apps/cf_atom_feed/mix.exs ./apps/cf_atom_feed/ 13 | COPY apps/cf_graphql/mix.exs ./apps/cf_graphql/ 14 | COPY apps/cf_jobs/mix.exs ./apps/cf_jobs/ 15 | COPY apps/cf_rest_api/mix.exs ./apps/cf_rest_api/ 16 | COPY apps/cf_reverse_proxy/mix.exs ./apps/cf_reverse_proxy/ 17 | COPY apps/db/mix.exs ./apps/db/ 18 | RUN mix local.hex --force 19 | RUN mix local.rebar --force 20 | RUN HEX_HTTP_CONCURRENCY=4 HEX_HTTP_TIMEOUT=180 mix deps.get --only $MIX_ENV 21 | 22 | # Build dependencies 23 | COPY . . 24 | RUN mix deps.compile 25 | 26 | # Build app 27 | RUN mix compile 28 | RUN mix release 29 | 30 | # Copy app to workdir and remove build files 31 | WORKDIR /opt/app 32 | RUN mv /opt/build/_build/$MIX_ENV/rel/full_app/* /opt/app/ 33 | RUN rm -rf /opt/build 34 | RUN ln -s /opt/app/bin/full_app /opt/app/entrypoint 35 | RUN ls 36 | 37 | EXPOSE 80 38 | ENTRYPOINT /opt/app/entrypoint start 39 | -------------------------------------------------------------------------------- /apps/cf/README.md: -------------------------------------------------------------------------------- 1 | # [CaptainFact App] CF 2 | 3 | This is CaptainFact main library. It holds most helpers and functions used to 4 | manipulate data from DB. 5 | 6 | ## Secrets 7 | 8 | Following secrets must be configured in production: 9 | 10 | - db_hostname 11 | - db_username 12 | - db_password 13 | - db_name 14 | - frontend_url 15 | - youtube_api_key 16 | - facebook_app_id 17 | - facebook_app_secret 18 | - secret_key_base 19 | - mailgun_domain 20 | - mailgun_api_key 21 | -------------------------------------------------------------------------------- /apps/cf/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 | use Mix.Config 7 | 8 | # General application configuration 9 | config :cf, 10 | env: Mix.env(), 11 | ecto_repos: [DB.Repo], 12 | oauth: [facebook: []], 13 | invitation_system: false, 14 | soft_limitations_period: 15 * 60, 15 | hard_limitations_period: 3 * 60 * 60 16 | 17 | # Configure mailer 18 | config :cf, CF.Mailer, adapter: Bamboo.MailgunAdapter 19 | 20 | # Configures Elixir's Logger 21 | config :logger, :console, 22 | format: "$time $metadata[$level] $message\n", 23 | metadata: [:request_id] 24 | 25 | # Configure Guardian (authentication) 26 | config :cf, CF.Authenticator.Guardian, 27 | issuer: "CaptainFact", 28 | ttl: {30, :days}, 29 | serializer: CF.Accounts.GuardianSerializer, 30 | permissions: %{default: [:read, :write]} 31 | 32 | config :cf, 33 | captions_fetcher: CF.Videos.CaptionsFetcherYoutube 34 | 35 | config :guardian, Guardian.DB, repo: DB.Repo 36 | 37 | # To send records to Algolia (search engine) 38 | config :algoliax, 39 | batch_size: 500, 40 | recv_timeout: 5000, 41 | application_id: "N5GW2EAIFX" 42 | 43 | # Import environment specific config 44 | import_config "#{Mix.env()}.exs" 45 | 46 | config :cf, 47 | openai_model: "gpt-4o" 48 | 49 | config :openai, 50 | beta: "assistants=v2", 51 | http_options: [recv_timeout: 30_000, timeout: 30_000] 52 | -------------------------------------------------------------------------------- /apps/cf/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | dev_secret = "8C6FsJwjV11d+1WPUIbkEH6gB/VavJrcXWoPLujgpclfxjkLkoNFSjVU9XfeNm6s" 4 | 5 | # General config 6 | config :cf, 7 | deploy_env: "dev", 8 | frontend_url: "http://localhost:3333/", 9 | oauth: [ 10 | facebook: [ 11 | client_id: "506726596325615", 12 | client_secret: "4b320056746b8e57144c889f3baf0424", 13 | redirect_uri: "http://localhost:3333/login/callback/facebook" 14 | ] 15 | ] 16 | 17 | # Guardian 18 | config :cf, 19 | CF.Authenticator.GuardianImpl, 20 | secret_key: dev_secret 21 | 22 | # Do not include metadata nor timestamps in development logs 23 | config :logger, :console, format: "[$level] $message\n" 24 | 25 | # Set a higher stacktrace during development. Avoid configuring such 26 | # in production as building large stacktraces may be expensive. 27 | config :phoenix, :stacktrace_depth, 20 28 | config :phoenix, :json_library, Jason 29 | 30 | # Mails 31 | config :cf, CF.Mailer, adapter: Bamboo.LocalAdapter 32 | 33 | # Import local secrets if any - use wildcard to ignore errors 34 | for config <- "*dev.secret.exs" |> Path.expand(__DIR__) |> Path.wildcard() do 35 | import_config config 36 | end 37 | -------------------------------------------------------------------------------- /apps/cf/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Do not print debug messages in production 4 | config :logger, level: :info 5 | -------------------------------------------------------------------------------- /apps/cf/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # General config 4 | config :cf, frontend_url: "https://TEST_FRONTEND/", deploy_env: "test" 5 | 6 | # Don't fetch user picture on test environment 7 | config :cf, fetch_default_user_picture: false 8 | 9 | # Configure Guardian (authentication) 10 | config :cf, 11 | CF.Authenticator.GuardianImpl, 12 | secret_key: "psZ6n/fq0b444U533yKtve2R0rpjk/IxRGpuanNE92phSDy8/Z2I8lHaIugCMOY7" 13 | 14 | # Print only warnings and errors during test 15 | config :logger, level: :warn 16 | 17 | # Mails 18 | config :cf, CF.Mailer, adapter: Bamboo.TestAdapter 19 | 20 | # Reduce the number of round for encryption during tests 21 | config :bcrypt_elixir, :log_rounds, 4 22 | 23 | # Behaviours mock for testing 24 | config :cf, captions_fetcher: CF.Videos.CaptionsFetcherTest 25 | config :cf, use_test_video_metadata_fetcher: true 26 | -------------------------------------------------------------------------------- /apps/cf/lib/accounts/username_generator.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Accounts.UsernameGenerator do 2 | @moduledoc """ 3 | Generates a unique username based on user id 4 | """ 5 | 6 | @name __MODULE__ 7 | @username_prefix "NewUser-" 8 | 9 | def start_link do 10 | Agent.start_link( 11 | fn -> 12 | Hashids.new( 13 | alphabet: "123456789abcdefghijklmnopqrstuvwxyz", 14 | salt: "C4pt41nUser" 15 | ) 16 | end, 17 | name: @name 18 | ) 19 | end 20 | 21 | def generate(id) do 22 | @username_prefix <> encode(id) 23 | end 24 | 25 | def username_prefix(), do: @username_prefix 26 | 27 | @doc """ 28 | Encode a given id 29 | ## Examples 30 | iex> CF.Accounts.UsernameGenerator.encode(42) 31 | "py7" 32 | """ 33 | @spec encode(Integer.t()) :: String.t() 34 | def encode(id) do 35 | Agent.get(@name, &Hashids.encode(&1, id)) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/cf/lib/actions/reputation_change_config_loader.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Actions.ReputationChangeConfigLoader do 2 | @moduledoc """ 3 | Config loader for reputation changes 4 | """ 5 | 6 | @doc """ 7 | Load a config and convert it using `convert/1` 8 | """ 9 | def load(filename) do 10 | filename 11 | |> CF.Utils.load_yaml_config() 12 | |> convert() 13 | end 14 | 15 | @doc """ 16 | Convert a config with atom keys to a config with integer indexes and tuples 17 | instead of lists. 18 | 19 | ## Examples 20 | 21 | iex> import CF.Actions.ReputationChangeConfigLoader, only: [convert: 1] 22 | iex> convert(%{abused_flag: [0,-5], vote_up: %{comment: [0, 2], fact: [0, 3]}}) 23 | %{abused_flag: {0, -5}, vote_up: %{comment: {0, 2}, fact: {0, 3}}} 24 | """ 25 | def convert(base_config) do 26 | Enum.reduce(base_config, %{}, fn {atom_action_type, value}, actions_map -> 27 | if DB.Type.UserActionType.valid_value?(atom_action_type) do 28 | Map.put(actions_map, atom_action_type, convert_value(value)) 29 | else 30 | raise "Unknown action type in YAML reputation changes config: #{atom_action_type}" 31 | end 32 | end) 33 | end 34 | 35 | defp convert_value(value) when is_list(value) do 36 | List.to_tuple(value) 37 | end 38 | 39 | defp convert_value(value) when is_map(value) do 40 | Enum.reduce(value, %{}, fn {entity, change_list}, action_changes -> 41 | if DB.Type.Entity.valid_value?(entity) do 42 | Map.put(action_changes, entity, List.to_tuple(change_list)) 43 | else 44 | raise "Unknown entity in YAML reputation changes config: #{entity}" 45 | end 46 | end) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /apps/cf/lib/actions/validator.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Actions.Validator do 2 | @moduledoc """ 3 | `UserAction` format and especially `changes` key are subject 4 | to change accross time. This module ensure all actions are 5 | correctly formatted. 6 | """ 7 | 8 | use CF.Actions.ValidatorBase 9 | 10 | # Check entities keys based on entity type. Note that the first matching type 11 | # will be the only one executed of all `check_entity_wildcard` 12 | # so you can't define multiple matching clauses per type 13 | check_entity_wildcard(:video, ~w(video_id)a) 14 | check_entity_wildcard(:speaker, ~w(speaker_id)a) 15 | check_entity_wildcard(:statement, ~w(statement_id video_id)a) 16 | check_entity_wildcard(:comment, ~w(comment_id statement_id video_id)a, exclude: [:delete]) 17 | 18 | # Same here, only the first pattern will match 19 | check_action_changes(:video, :add, required: ["url"]) 20 | check_action_changes(:video, :update, whitelist: ~w(statements_time)) 21 | check_action_changes(:speaker, :add, has_changes: false) 22 | check_action_changes(:speaker, :create, required: ["full_name"], whitelist: ["title"]) 23 | check_action_changes(:speaker, :remove, has_changes: false) 24 | check_action_changes(:speaker, :delete, has_changes: false) 25 | check_action_changes(:speaker, :update, whitelist: ~w(title full_name wikidata_item_id picture)) 26 | check_action_changes(:statement, :create, required: ["time", "text"], whitelist: ["speaker_id"]) 27 | check_action_changes(:statement, :update, whitelist: ["speaker_id", "text", "time"]) 28 | check_action_changes(:statement, :remove, has_changes: false) 29 | check_action_changes(:comment, :delete, has_changes: false) 30 | check_action_changes(:comment, :vote_up, has_changes: false) 31 | 32 | ignore_others_actions() 33 | end 34 | -------------------------------------------------------------------------------- /apps/cf/lib/algolia/SpeakersIndex.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Algolia.SpeakersIndex do 2 | use Algoliax.Indexer, 3 | index_name: :get_index_name, 4 | repo: DB.Repo, 5 | schemas: [DB.Schema.Speaker] 6 | 7 | @doc """ 8 | ## Examples 9 | 10 | iex> CF.Algolia.SpeakersIndex.get_index_name() 11 | :test_speakers 12 | """ 13 | def get_index_name do 14 | String.to_atom("#{Application.get_env(:cf, :deploy_env)}_speakers") 15 | end 16 | 17 | @impl Algoliax.Indexer 18 | def build_object(speaker) do 19 | Map.take(speaker, ~w(id full_name title slug country wikidata_item_id)a) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/cf/lib/algolia/StatementsIndex.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Algolia.StatementsIndex do 2 | import Ecto.Query 3 | 4 | use Algoliax.Indexer, 5 | index_name: :get_index_name, 6 | repo: DB.Repo, 7 | schemas: [DB.Schema.Statement] 8 | 9 | @doc """ 10 | ## Examples 11 | 12 | iex> CF.Algolia.StatementsIndex.get_index_name() 13 | :test_statements 14 | """ 15 | def get_index_name do 16 | String.to_atom("#{Application.get_env(:cf, :deploy_env)}_statements") 17 | end 18 | 19 | @doc """ 20 | ## Examples 21 | 22 | iex> CF.Algolia.StatementsIndex.to_be_indexed?(%DB.Schema.Statement{is_removed: true}) 23 | false 24 | iex> CF.Algolia.StatementsIndex.to_be_indexed?(%DB.Schema.Statement{is_removed: false}) 25 | true 26 | """ 27 | @impl Algoliax.Indexer 28 | def to_be_indexed?(statement) do 29 | not (statement.is_removed or statement.is_draft) 30 | end 31 | 32 | @impl Algoliax.Indexer 33 | def build_object(statement) do 34 | statement 35 | |> DB.Repo.preload([:video, :speaker]) 36 | |> Map.update!(:video, &build_video(&1)) 37 | |> Map.update!(:speaker, &build_speaker(&1)) 38 | |> Map.take(~w(id text time video speaker)a) 39 | end 40 | 41 | def reindex_all_speaker_statements(speaker_id) do 42 | DB.Schema.Statement 43 | |> where([s], s.speaker_id == ^speaker_id) 44 | |> DB.Repo.all() 45 | |> save_objects() 46 | end 47 | 48 | defp build_video(video) do 49 | Map.take(video, ~w(id title hash_id youtube_id facebook_id url)a) 50 | end 51 | 52 | defp build_speaker(nil), do: nil 53 | defp build_speaker(speaker), do: CF.Algolia.SpeakersIndex.build_object(speaker) 54 | end 55 | -------------------------------------------------------------------------------- /apps/cf/lib/application.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Application do 2 | use Application 3 | 4 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec 8 | 9 | # Define workers and child supervisors to be supervised 10 | children = [ 11 | # Other custom supervisors 12 | supervisor(CF.Sources.Fetcher, []), 13 | # Misc workers 14 | worker(CF.Accounts.UsernameGenerator, []), 15 | # Sweep tokens from db 16 | worker(Guardian.DB.Token.SweeperServer, []) 17 | ] 18 | 19 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 20 | # for other strategies and supported options 21 | opts = [strategy: :one_for_one, name: CF.Supervisor] 22 | Supervisor.start_link(children, opts) 23 | end 24 | 25 | def version() do 26 | case :application.get_key(:cf, :vsn) do 27 | {:ok, version} -> to_string(version) 28 | _ -> "unknown" 29 | end 30 | end 31 | 32 | @doc """ 33 | If Mix is available, returns Mix.env(). If not available (in releases) return :prod 34 | """ 35 | @deprecated "use Application.get_env(:cf, :env)" 36 | def env() do 37 | (Kernel.function_exported?(Mix, :env, 0) && Mix.env()) || :prod 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/cf/lib/authenticator/provider_infos.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Authenticator.ProviderInfos do 2 | # Atom representing the provider 3 | defstruct provider: nil, 4 | # The best display name known to the strategy. Usually a concatenation of first and last name, but may also be an arbitrary designator or nickname for some strategies 5 | name: nil, 6 | # The username of an authenticating user (such as your @-name from Twitter or GitHub account name) 7 | nickname: nil, 8 | # The e-mail of the authenticating user. Should be provided if at all possible (but some sites such as Twitter do not provide this information) 9 | email: nil, 10 | # User locale 11 | locale: nil, 12 | # A URL representing a profile image of the authenticating user. Where possible, should be specified to a square, roughly 50x50 pixel image. 13 | picture_url: nil, 14 | # Unique user id on given platform 15 | uid: nil 16 | end 17 | -------------------------------------------------------------------------------- /apps/cf/lib/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Gettext do 2 | use Gettext, otp_app: :cf 3 | 4 | defmacro with_user_locale(user, do: expression) do 5 | quote do 6 | locale = Map.get(unquote(user), :locale) || "en" 7 | 8 | Gettext.with_locale(CF.Gettext, locale, fn -> 9 | unquote(expression) 10 | end) 11 | end 12 | end 13 | 14 | defmacro gettext_mail(msgid, vars \\ []) do 15 | quote do 16 | CF.Gettext.dgettext("mail", unquote(msgid), unquote(vars)) 17 | end 18 | end 19 | 20 | defmacro gettext_mail_user(user, msgid, vars \\ []) do 21 | quote do 22 | locale = Map.get(unquote(user), :locale) || "en" 23 | 24 | Gettext.with_locale(CF.Gettext, locale, fn -> 25 | CF.Gettext.dgettext("mail", unquote(msgid), unquote(vars)) 26 | end) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/cf/lib/llms/templates/statements_extractor_user_prompt.eex: -------------------------------------------------------------------------------- 1 | ```json 2 | { 3 | "video": { 4 | "title": "<%= video.id %>" 5 | }, 6 | "captions": <%= captions |> Enum.map(fn caption -> %{ 7 | start: floor(caption["start"]), 8 | text: String.trim(caption["text"]) 9 | } end) |> Jason.encode! %> 10 | } 11 | ``` 12 | -------------------------------------------------------------------------------- /apps/cf/lib/mailer/formatter.ex: -------------------------------------------------------------------------------- 1 | defimpl Bamboo.Formatter, for: DB.Schema.User do 2 | def format_email_address(user, _opts) do 3 | {DB.Schema.User.user_appelation(user), user.email} 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /apps/cf/lib/mailer/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Mailer do 2 | use Bamboo.Mailer, otp_app: :cf 3 | end 4 | -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/_layout.text.eex: -------------------------------------------------------------------------------- 1 | <%= render @view_module, @view_template, assigns %> 2 | 3 | 4 | -------------------------------------------------------------------------------- 5 | 6 | <%= gettext_mail("CaptainFact is a free and open source project.") %> 7 | <%= gettext_mail("If you like it, please share the word!") %> 8 | 9 | Forum: https://forum.captainfact.io/ 10 | Discord: https://discord.captainfact.io 11 | Github: https://github.com/CaptainFact 12 | Facebook: https://www.facebook.com/CaptainFact.io/ 13 | Twitter: https://twitter.com/CaptainFact_io -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/invitation.en.html.eex: -------------------------------------------------------------------------------- 1 | Your invitation to try CaptainFact is ready !
2 |
3 | <%= unless is_nil(@invited_by) do %> 4 | <%= user_appelation(@invited_by) %> invited you to join CaptainFact.io, a 5 | platform to collaboratively fact-check content on the Internet. 6 |

7 | <% end %> 8 | Please follow this link to create your account: 9 | 10 | Create account 11 | 12 |
-------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/invitation.en.text.eex: -------------------------------------------------------------------------------- 1 | Your invitation to try CaptainFact is ready ! 2 | 3 | <%= unless is_nil(@invited_by) do %> 4 | <%= user_appelation(@invited_by) %> invited you to join CaptainFact.io, a 5 | platform to collaboratively fact-check content on the Internet. 6 | <% end %> 7 | 8 | Please follow this link to create your account: 9 | <%= invitation_url(@invitation_token) %> 10 | -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/invitation.fr.html.eex: -------------------------------------------------------------------------------- 1 | Votre invitation pour rejoindre CaptainFact est prête !
2 |
3 | Pour créer un compte dès maintenant, utilisez votre lien d'invitation unique : 4 | 5 | Je m'inscris 6 | 7 |
8 | A très vite ! 9 |
-------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/invitation.fr.text.eex: -------------------------------------------------------------------------------- 1 | Votre invitation pour rejoindre CaptainFact est prête ! 2 | 3 | Pour créer un compte dès maintenant, utilisez votre lien d'invitation unique : 4 | <%= invitation_url(@invitation_token) %> 5 | 6 | A très vite ! -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/newsletter.en.html.eex: -------------------------------------------------------------------------------- 1 | <%= raw @content %> 2 | 3 |

4 | 5 | 7 | Unsubscribe from this newsletter 8 | -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/newsletter.en.text.eex: -------------------------------------------------------------------------------- 1 | <%= @text_content %> 2 | 3 | -------------------------------------------------------------------------------- 4 | 5 | Unsubscribe from this newsletter: <%= unsubscribe_newsletter_url(@user.newsletter_subscription_token) %> 6 | -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/newsletter.fr.html.eex: -------------------------------------------------------------------------------- 1 | <%= raw @content %> 2 | 3 |

4 | 5 | 7 | Se désinscrire de cette newsletter 8 | -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/newsletter.fr.text.eex: -------------------------------------------------------------------------------- 1 | <%= @text_content %> 2 | 3 | -------------------------------------------------------------------------------- 4 | 5 | Se désinscrire de cette newsletter: <%= unsubscribe_newsletter_url(@user.newsletter_subscription_token) %> 6 | -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/reset_password.en.html.eex: -------------------------------------------------------------------------------- 1 | You recently asked to reset your password on CF. 2 | 3 | You can do so by following this link: 4 | 5 | 6 | Reset password 7 | 8 | 9 |

10 | 11 | Please ignore this email if the request is not comming from you. 12 | 13 |

14 | 15 | 16 | Reset requested by IP: <%= @source_ip %> 17 | -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/reset_password.en.text.eex: -------------------------------------------------------------------------------- 1 | You recently asked to reset your password on CF. 2 | 3 | You can do so by following this link: 4 | <%= reset_password_url(@reset_password_token) %> 5 | 6 | Please ignore this email if the request is not comming from you. 7 | 8 | (Requested by IP: <%= @source_ip %>) -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/reset_password.fr.html.eex: -------------------------------------------------------------------------------- 1 | Vous avez demandé la réinitialisation de votre mot de passe sur CF. 2 | Vous pouvez procéder au changement de mot de passe en suivant ce lien : 3 | 4 | 5 | Changer mon mot de passe 6 | 7 | 8 |

9 | 10 | Merci d'ignorer cet email si la requête ne vient pas de vous. 11 | 12 |

13 | 14 | 15 | Origine de la demande : <%= @source_ip %> 16 | -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/reset_password.fr.text.eex: -------------------------------------------------------------------------------- 1 | Vous avez demandé la réinitialisation de votre mot de passe sur CF. 2 | 3 | Vous pouvez procéder au changement de mot de passe en suivant ce lien : 4 | <%= reset_password_url(@reset_password_token) %> 5 | 6 | Merci d'ignorer cet email si la requête ne vient pas de vous. 7 | 8 | (Origine de la demande : <%= @source_ip %>) -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/welcome.en.html.eex: -------------------------------------------------------------------------------- 1 | Welcome to CaptainFact.io!
2 |
3 | To confirm your email and gain a bonus of 4 | +<%= @confirm_email_reputation %> reputation, click on the following link: 5 | 6 | Confirm my email 7 | 8 |

9 | You can learn more about how the system works by 10 | checking the help pages. 11 |

12 | Feel free to contact us 13 | at contact@captainfact.io.
14 |
15 | See you soon on CaptainFact ! 16 |
-------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/welcome.en.text.eex: -------------------------------------------------------------------------------- 1 | Welcome to CaptainFact.io! 2 | 3 | To confirm your email and gain a bonus of 4 | +<%= @confirm_email_reputation %> reputation, click on the following link: 5 | 6 | <%= confirm_email_url(@user.email_confirmation_token) %> 7 | 8 | You can learn more about how the system works and the whys of CaptainFact by 9 | checking the help pages at <%= help_url() %> 10 | 11 | Feel free to contact us at contact@captainfact.io 12 | 13 | See you soon on CaptainFact ! -------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/welcome.fr.html.eex: -------------------------------------------------------------------------------- 1 | Bienvenue sur CaptainFact !
2 |
3 | Pour confirmer votre adresse email et obtenir un bonus de 4 | +<%= @confirm_email_reputation %> de réputation, cliquez sur le lien suivant : 5 | 6 | Confirmer mon adresse email 7 | 8 |

9 | Vous pouvez en apprendre plus sur le fonctionnement du système en 10 | allant voir les pages d'aide. 11 |

12 | Vous pouvez également nous contacter 13 | sur contact@captainfact.io.
14 |
15 | Bon fact-checking ! 16 |
-------------------------------------------------------------------------------- /apps/cf/lib/mailer/templates/welcome.fr.text.eex: -------------------------------------------------------------------------------- 1 | Bienvenue sur CaptainFact ! 2 | 3 | Pour confirmer votre adresse email et obtenir un bonus de 4 | +<%= @confirm_email_reputation %> de réputation, cliquez sur le lien suivant : 5 | 6 | <%= confirm_email_url(@user.email_confirmation_token) %> 7 | 8 | Vous pouvez en apprendre plus sur le fonctionnement du système en 9 | allant voir les pages d'aide : <%= help_url() %> 10 | 11 | Vous pouvez également nous contacter contact@captainfact.io 12 | 13 | Bon fact-checking ! 14 | -------------------------------------------------------------------------------- /apps/cf/lib/mailer/view.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Mailer.View do 2 | use Phoenix.View, root: "lib/mailer/templates", namespace: CF.Mailer 3 | use Phoenix.HTML 4 | 5 | import CF.Gettext 6 | import CF.Utils.FrontendRouter 7 | 8 | def user_appelation(user) do 9 | DB.Schema.User.user_appelation(user) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /apps/cf/lib/moderation/moderation_entry.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Moderation.ModerationEntry do 2 | defstruct action: nil, flags: [] 3 | end 4 | -------------------------------------------------------------------------------- /apps/cf/lib/sources/sources.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Sources do 2 | @moduledoc """ 3 | Functions to manage sources and their metadata. 4 | """ 5 | 6 | alias DB.Repo 7 | alias DB.Schema.Source 8 | 9 | alias CF.Sources.Fetcher 10 | 11 | @doc """ 12 | Get a source from `DB` from its URL. Returns nil if no source exist for 13 | this URL. 14 | """ 15 | @spec get_by_url(binary()) :: Source.t() | nil 16 | def get_by_url(url) do 17 | Repo.get_by(Source, url: url) 18 | end 19 | 20 | @doc """ 21 | Fetch a source metadata using `CF.Sources.Fetcher`, update source with it then 22 | call `callback` (if any) with the update source. 23 | """ 24 | def update_source_metadata(base_source = %Source{}, callback \\ nil) do 25 | Fetcher.fetch_source_metadata(base_source.url, fn 26 | metadata when metadata == %{} -> 27 | nil 28 | 29 | metadata -> 30 | updated_source = Repo.update!(Source.changeset_fetched(base_source, metadata)) 31 | if !is_nil(callback), do: callback.(updated_source) 32 | end) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /apps/cf/lib/statements/statements.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Statements do 2 | @moduledoc """ 3 | Functions to manipulate statements 4 | """ 5 | 6 | alias Ecto.Multi 7 | alias Kaur.Result 8 | 9 | alias DB.Schema.Statement 10 | alias DB.Repo 11 | 12 | alias CF.Accounts.UserPermissions 13 | import CF.Actions.ActionCreator, only: [action_update: 2] 14 | 15 | @doc """ 16 | Update given statement. Will raise if user doesn't have 17 | permission to do that. 18 | """ 19 | def update!(user_id, statement = %Statement{is_removed: false}, changes) do 20 | UserPermissions.check!(user_id, :update, :statement) 21 | changeset = Statement.changeset(statement, changes) 22 | 23 | if changeset.changes == %{} do 24 | Result.ok(statement) 25 | else 26 | Multi.new() 27 | |> Multi.update(:statement, changeset) 28 | |> Multi.insert(:action_update, action_update(user_id, changeset)) 29 | |> Repo.transaction() 30 | |> case do 31 | {:ok, %{statement: updated_statement}} -> 32 | Result.ok(updated_statement) 33 | 34 | {:error, _operation, reason, _changes} -> 35 | Result.error(reason) 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/cf/lib/utils/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Utils do 2 | @moduledoc """ 3 | Helpers / utils functions 4 | """ 5 | 6 | @doc """ 7 | Load a YAML map from given file and recursively convert all keys to atoms. 8 | (!) This function uses `String.to_existing_atom/1` so atom must already exist 9 | """ 10 | def load_yaml_config(filename) do 11 | filename 12 | |> YamlElixir.read_all_from_file!() 13 | |> List.first() 14 | |> map_string_keys_to_atom_keys() 15 | end 16 | 17 | @doc """ 18 | Transform all map binary indexes to atoms. Use this function carefuly, 19 | generating too much atoms (for example when accepting user's input) can 20 | result in terrible performances issues. 21 | 22 | ## Examples 23 | 24 | iex> CF.Utils.map_string_keys_to_atom_keys(%{"test" => %{"ok" => 42}}) 25 | %{test: %{ok: 42}} 26 | 27 | """ 28 | def map_string_keys_to_atom_keys(map) when is_map(map) do 29 | Enum.reduce(map, %{}, fn {key, value}, result -> 30 | atom_key = convert_key_to_atom(key) 31 | converted_value = map_string_keys_to_atom_keys(value) 32 | Map.put(result, atom_key, converted_value) 33 | end) 34 | end 35 | 36 | def map_string_keys_to_atom_keys(value), 37 | do: value 38 | 39 | def truncate(text, max_length, replacement \\ "…") do 40 | if String.length(text) > max_length do 41 | String.slice(text, 0, max_length - String.length(replacement)) <> replacement 42 | else 43 | text 44 | end 45 | end 46 | 47 | # Convert key to atom if key is in binary format 48 | 49 | defp convert_key_to_atom(key) when is_binary(key), 50 | do: String.to_atom(key) 51 | 52 | defp convert_key_to_atom(key) when is_atom(key), 53 | do: key 54 | end 55 | -------------------------------------------------------------------------------- /apps/cf/lib/video_debate/history.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.VideoDebate.History do 2 | import Ecto.Query 3 | 4 | alias DB.Repo 5 | alias DB.Schema.UserAction 6 | 7 | @allowed_entities [:statement, :speaker, :video] 8 | 9 | def video_history(video_id) do 10 | UserAction 11 | |> preload(:user) 12 | |> where([a], a.video_id == ^video_id) 13 | |> where([a], a.entity in ^@allowed_entities) 14 | |> Repo.all() 15 | end 16 | 17 | def statement_history(statement_id) do 18 | UserAction 19 | |> preload(:user) 20 | |> where([a], a.entity == ^:statement) 21 | |> where([a], a.statement_id == ^statement_id) 22 | |> Repo.all() 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/cf/lib/videos/captions_fetcher.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Videos.CaptionsFetcher do 2 | @moduledoc """ 3 | Fetch captions for videos. 4 | """ 5 | 6 | @callback fetch(DB.Schema.Video.t()) :: 7 | {:ok, %{raw: String.t(), parsed: String.t(), format: String.t()}} | {:error, term()} 8 | end 9 | -------------------------------------------------------------------------------- /apps/cf/lib/videos/captions_fetcher_test.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Videos.CaptionsFetcherTest do 2 | @moduledoc """ 3 | A mock for faking captions fetching requests. 4 | """ 5 | 6 | @behaviour CF.Videos.CaptionsFetcher 7 | 8 | @impl true 9 | def fetch(_video) do 10 | captions = %{ 11 | raw: "__TEST-CONTENT__", 12 | format: "custom", 13 | parsed: [ 14 | %{ 15 | "text" => "__TEST-CONTENT__", 16 | "start" => 0.0, 17 | "duration" => 1.0 18 | } 19 | ] 20 | } 21 | 22 | {:ok, captions} 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/cf/lib/videos/captions_srv1_parser.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Videos.CaptionsSrv1Parser do 2 | @moduledoc """ 3 | A captions parser for the srv1 format. 4 | """ 5 | 6 | require Logger 7 | import SweetXml 8 | 9 | def parse_file(content) do 10 | content 11 | |> SweetXml.xpath( 12 | ~x"//transcript/text"l, 13 | text: ~x"./text()"s |> transform_by(&clean_text/1), 14 | start: ~x"./@start"s |> transform_by(&parse_float/1), 15 | duration: ~x"./@dur"os |> transform_by(&parse_float/1) 16 | ) 17 | |> Enum.filter(fn %{text: text, start: start} -> 18 | # Filter out text in brackets, like "[Music]" 19 | start != nil and text != nil and text != "" and 20 | String.match?(text, ~r/^\[.*\]$/) == false 21 | end) 22 | end 23 | 24 | defp clean_text(text) do 25 | text 26 | |> String.replace("&", "&") 27 | |> HtmlEntities.decode() 28 | |> String.trim() 29 | end 30 | 31 | defp parse_float(val) do 32 | case Float.parse(val) do 33 | {num, _} -> num 34 | _ -> nil 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/cf/lib/videos/metadata_fetcher.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Videos.MetadataFetcher do 2 | @moduledoc """ 3 | Fetch metadata for video. 4 | """ 5 | 6 | @type video_metadata :: %{ 7 | title: String.t(), 8 | language: String.t(), 9 | url: String.t() 10 | } 11 | 12 | @doc """ 13 | Takes an URL, fetch the metadata and return them 14 | """ 15 | @callback fetch_video_metadata(String.t()) :: {:ok, video_metadata} | {:error, binary()} 16 | end 17 | -------------------------------------------------------------------------------- /apps/cf/lib/videos/metadata_fetcher_opengraph.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Videos.MetadataFetcher.Opengraph do 2 | @moduledoc """ 3 | Methods to fetch metadata (title, language) from videos 4 | """ 5 | 6 | @behaviour CF.Videos.MetadataFetcher 7 | 8 | @doc """ 9 | Fetch metadata from video using OpenGraph tags. 10 | """ 11 | def fetch_video_metadata(url) do 12 | case HTTPoison.get(url) do 13 | {:ok, %HTTPoison.Response{body: body}} -> 14 | meta = Floki.attribute(body, "meta[property='og:title']", "content") 15 | 16 | case meta do 17 | [] -> {:error, "Page does not contains an OpenGraph title attribute"} 18 | [title] -> {:ok, %{title: HtmlEntities.decode(title), url: url}} 19 | end 20 | 21 | {_, _} -> 22 | {:error, "Remote URL didn't respond correctly"} 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/cf/lib/videos/metadata_fetcher_test.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Videos.MetadataFetcher.Test do 2 | @moduledoc """ 3 | Methods to fetch metadata (title, language) from videos 4 | """ 5 | 6 | @behaviour CF.Videos.MetadataFetcher 7 | 8 | @doc """ 9 | Fetch metadata from video using OpenGraph tags. 10 | """ 11 | def fetch_video_metadata(url) do 12 | {:ok, 13 | %{ 14 | title: "__TEST-TITLE__", 15 | url: url 16 | }} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/cf/priv/gettext/mail.pot: -------------------------------------------------------------------------------- 1 | ## This file 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 as no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | msgid "" 11 | msgstr "" 12 | 13 | #: lib/mailer/email.ex:93 lib/mailer/email.ex:93 14 | msgid "%{name} invited you to try CaptainFact.io!" 15 | msgstr "" 16 | 17 | #: lib/mailer/email.ex:60 lib/mailer/email.ex:60 18 | msgid "CaptainFact.io - Reset your password" 19 | msgstr "" 20 | 21 | #: lib/mailer/email.ex:73 lib/mailer/email.ex:73 22 | msgid "About your recent loss of reputation on CaptainFact" 23 | msgstr "" 24 | 25 | #: lib/mailer/templates/_layout.html.eex:189 26 | #: lib/mailer/templates/_layout.html.eex:189 27 | #: lib/mailer/templates/_layout.text.eex:6 28 | #: lib/mailer/templates/_layout.text.eex:6 29 | msgid "CaptainFact is a free and open source project." 30 | msgstr "" 31 | 32 | #: lib/mailer/templates/_layout.html.eex:190 33 | #: lib/mailer/templates/_layout.html.eex:190 34 | #: lib/mailer/templates/_layout.text.eex:7 35 | #: lib/mailer/templates/_layout.text.eex:7 36 | msgid "If you like it, please share the word!" 37 | msgstr "" 38 | 39 | #: lib/mailer/email.ex:32 lib/mailer/email.ex:32 40 | msgid "Confirm your CaptainFact account" 41 | msgstr "" 42 | 43 | #: lib/mailer/email.ex:89 lib/mailer/email.ex:89 44 | msgid "Your invitation to try CaptainFact.io is ready!" 45 | msgstr "" 46 | -------------------------------------------------------------------------------- /apps/cf/priv/reputation_changes.yaml: -------------------------------------------------------------------------------- 1 | # This file describe reputation changes for given actions and optionals entity 2 | # A change is defined as a tuple like [self_change, other_user_change] 3 | 4 | # ---- Votes ---- 5 | 6 | # Vote UP. Please ensure vote_up and revert_vote_up values match ! 7 | 8 | vote_up: 9 | comment: [0, 2] 10 | fact: [0, 3] 11 | 12 | revert_vote_up: 13 | comment: [0, -2] 14 | fact: [0, -3] 15 | 16 | # Vote DOWN. Please ensure vote_down and revert_vote_down values match ! 17 | 18 | vote_down: 19 | comment: [-1, -2] 20 | fact: [-1, -3] 21 | 22 | revert_vote_down: 23 | comment: [+1 , +2] 24 | fact: [+1 , +3] 25 | 26 | # ---- Moderation ---- 27 | 28 | # Target user got its comment banned 29 | 30 | action_banned_bad_language: [0, -25] 31 | action_banned_spam: [0, -30] 32 | action_banned_irrelevant: [0, -10] 33 | action_banned_not_constructive: [0, -5] 34 | 35 | # Source user (who made the flag) has made a good or bad flag 36 | 37 | abused_flag: [0, -5] 38 | confirmed_flag: [0, +3] 39 | 40 | # ---- Misc ---- 41 | 42 | email_confirmed: [0, +15] 43 | -------------------------------------------------------------------------------- /apps/cf/test/actions/flagger_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Moderation.FlaggerTest do 2 | use CF.DataCase 3 | 4 | alias DB.Schema.User 5 | alias DB.Schema.Comment 6 | alias DB.Schema.Flag 7 | 8 | alias CF.Moderation.Flagger 9 | alias CF.Jobs.{Flags, Reputation} 10 | alias CF.Moderation 11 | 12 | @nb_flags_to_report Moderation.nb_flags_to_report(:create, :comment) 13 | 14 | setup do 15 | Repo.delete_all(Flag) 16 | Repo.delete_all(User) 17 | target_user = insert(:user, %{reputation: 10_000}) 18 | comment = insert(:comment, %{user: target_user}) |> with_action 19 | source_users = insert_list(@nb_flags_to_report, :user, %{reputation: 10_000}) 20 | {:ok, [source_users: source_users, target_user: target_user, comment: comment]} 21 | end 22 | 23 | test "flags get inserted in DB", context do 24 | source = List.first(context[:source_users]) 25 | comment = context[:comment] 26 | 27 | Flagger.flag!(source.id, comment.statement.video_id, comment, 1) 28 | Reputation.update() 29 | Flags.update() 30 | assert Flagger.get_nb_flags(comment) == 1 31 | end 32 | 33 | test "comment reported after x flags", context do 34 | comment = context[:comment] 35 | 36 | for source <- context[:source_users], 37 | do: Flagger.flag!(source.id, comment.statement.video_id, comment, 1) 38 | 39 | Reputation.update() 40 | Flags.update() 41 | assert Repo.get(Comment, comment.id).is_reported == true 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /apps/cf/test/actions/reputation_change_config_loader_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Actions.ReputationChangeConfigLoaderTest do 2 | use CF.DataCase 3 | doctest CF.Actions.ReputationChangeConfigLoader 4 | end 5 | -------------------------------------------------------------------------------- /apps/cf/test/algolia/speakers_index_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Algolia.SpeakersIndexTest do 2 | use CF.DataCase 3 | doctest CF.Algolia.SpeakersIndex 4 | end 5 | -------------------------------------------------------------------------------- /apps/cf/test/algolia/statements_index_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Algolia.StatementsIndexTest do 2 | use CF.DataCase 3 | doctest CF.Algolia.StatementsIndex 4 | end 5 | -------------------------------------------------------------------------------- /apps/cf/test/algolia/videos_index_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Algolia.VideosIndexTest do 2 | use CF.DataCase 3 | doctest CF.Algolia.VideosIndex 4 | end 5 | -------------------------------------------------------------------------------- /apps/cf/test/authenticator/authenticator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.AuthenticatorTest do 2 | use CF.DataCase 3 | use ExUnitProperties 4 | 5 | alias CF.Authenticator 6 | 7 | describe "Identity" do 8 | test "can login with email" do 9 | password = "password458" 10 | user = insert_user_with_custom_password(password) 11 | authenticated_user = Authenticator.get_user_for_email_or_name_password(user.email, password) 12 | 13 | assert user.id == authenticated_user.id 14 | end 15 | 16 | test "can login with name" do 17 | password = "password458" 18 | user = insert_user_with_custom_password(password) 19 | 20 | authenticated_user = 21 | Authenticator.get_user_for_email_or_name_password(user.username, password) 22 | 23 | assert user.id == authenticated_user.id 24 | end 25 | 26 | property "password must be correct" do 27 | password = 28 | "IfPropertyTestingFailsWithThisString,itIsNotABugButAVeryVeryRareCase,iMeanAlmostImpossible!!!" 29 | 30 | user = insert_user_with_custom_password(password) 31 | 32 | check all(password <- binary(), max_runs: 3) do 33 | assert is_nil(Authenticator.get_user_for_email_or_name_password(user.email, password)) 34 | end 35 | end 36 | end 37 | 38 | defp insert_user_with_custom_password(password) do 39 | insert(:user, %{encrypted_password: Bcrypt.hash_pwd_salt(password)}) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /apps/cf/test/authenticator/oauth/facebook_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Authenticator.OAuth.FacebookTest do 2 | import DB.Factory 3 | use CF.DataCase 4 | import Mock 5 | alias CF.Authenticator.OAuth.Facebook 6 | 7 | doctest Facebook 8 | 9 | # TODO add arity 10 | describe "revoke_permissions/1" do 11 | test "sends an HTTP DELETE request to facebook" do 12 | user = 13 | :user 14 | |> build 15 | |> with_fb_user_id 16 | |> insert 17 | 18 | facebook_user_perms_url = "/#{user.fb_user_id}/permissions" 19 | 20 | # defining Mock for OAuth2 Client module 21 | with_mock OAuth2.Client, 22 | # Unmocked functions will be pass to original module 23 | [:passthrough], 24 | # mock delete function 25 | delete: fn _client, url when facebook_user_perms_url == url -> 26 | {:ok, %OAuth2.Response{status_code: 200, body: %{data: "success"}}} 27 | end do 28 | Facebook.revoke_permissions(user) 29 | 30 | # Check that the call was made as we expected 31 | assert called(OAuth2.Client.delete(:_, facebook_user_perms_url)) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /apps/cf/test/mailer/fomatter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Mailer.FormatterTest do 2 | use CF.DataCase 3 | 4 | test "format email address" do 5 | user = DB.Factory.build(:user) 6 | {appelation, email} = Bamboo.Formatter.format_email_address(user, []) 7 | assert email == user.email 8 | assert appelation =~ user.username 9 | assert appelation =~ user.name 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /apps/cf/test/notifications/notification_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Notifications.NotificationBuilderTest do 2 | use CF.DataCase 3 | alias DB.Schema.Subscription 4 | alias DB.Schema.UserAction 5 | alias CF.Notifications.NotificationBuilder 6 | doctest CF.Notifications.NotificationBuilder 7 | end 8 | -------------------------------------------------------------------------------- /apps/cf/test/notifications/notifications_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.NotificationsTest do 2 | use CF.DataCase 3 | 4 | alias CF.Notifications 5 | 6 | describe "all" do 7 | setup do 8 | user = insert(:user) 9 | notifs = insert_list(5, :notification, user: user) 10 | 11 | sorted_notifs_ids = 12 | notifs |> Enum.sort_by(& &1.inserted_at, &>=/2) |> Enum.map(&Map.get(&1, :id)) 13 | 14 | [user: user, notifs: notifs, sorted_notifs_ids: sorted_notifs_ids] 15 | end 16 | 17 | test "sorts notifications", %{user: user, sorted_notifs_ids: sorted_notifs_ids} do 18 | returned_ids = Enum.map(Notifications.all(user, 1, 5), & &1.id) 19 | assert returned_ids == sorted_notifs_ids 20 | end 21 | end 22 | 23 | describe "create!" do 24 | end 25 | 26 | describe "mark_as_seen/1" do 27 | test "mark the notification as seen" do 28 | notification = insert(:notification, seen_at: nil) 29 | {:ok, updated} = Notifications.mark_as_seen(notification, true) 30 | refute is_nil(updated.seen_at) 31 | end 32 | 33 | test "mark the notification as unseen" do 34 | notification = insert(:notification, seen_at: DateTime.utc_now()) 35 | {:ok, updated} = Notifications.mark_as_seen(notification, false) 36 | assert is_nil(updated.seen_at) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/cf/test/sources/sources_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.SourcesTest do 2 | end 3 | -------------------------------------------------------------------------------- /apps/cf/test/support/test_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.TestUtils do 2 | import DB.Factory 3 | import Ecto.Query 4 | import ExUnit.Assertions 5 | 6 | alias DB.Repo 7 | alias DB.Schema.Comment 8 | alias DB.Schema.UserAction 9 | 10 | def flag_comments(comments, nb_flags, reason \\ 1) do 11 | users = insert_list(nb_flags, :user, %{reputation: 1000}) 12 | 13 | flags = 14 | Enum.map(comments, fn comment -> 15 | full_comment = Repo.preload(comment, :statement) 16 | 17 | Enum.map(users, fn user -> 18 | CF.Moderation.Flagger.flag!( 19 | user.id, 20 | full_comment.statement.video_id, 21 | full_comment, 22 | reason 23 | ) 24 | end) 25 | end) 26 | 27 | List.flatten(flags) 28 | end 29 | 30 | def assert_deleted(%Comment{id: id}, check_actions \\ true) do 31 | {comment, actions} = get_comment_and_actions(id) 32 | assert is_nil(comment) 33 | 34 | if check_actions do 35 | assert Enum.count(actions) == 0 36 | end 37 | end 38 | 39 | def assert_not_deleted(%Comment{id: id}) do 40 | {comment, _} = get_comment_and_actions(id) 41 | assert comment != nil 42 | 43 | assert Repo.get_by( 44 | UserAction, 45 | entity: :comment, 46 | type: :delete, 47 | comment_id: id 48 | ) == nil 49 | end 50 | 51 | defp get_comment_and_actions(id) do 52 | actions = 53 | UserAction 54 | |> where([a], a.entity == ^:comment and a.comment_id == ^id) 55 | |> Repo.all() 56 | 57 | {Repo.get(Comment, id), actions} 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /apps/cf/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Start everything 2 | 3 | Ecto.Adapters.SQL.Sandbox.mode(DB.Repo, {:shared, self()}) 4 | {:ok, _} = Application.ensure_all_started(:bypass) 5 | 6 | ExUnit.start() 7 | -------------------------------------------------------------------------------- /apps/cf/test/utils/cf_utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.UtilsTest do 2 | use ExUnit.Case 3 | doctest CF.Utils 4 | 5 | describe "map_string_keys_to_atom_keys" do 6 | test "convert map recursively" do 7 | base_map = %{ 8 | "test" => %{ 9 | "hello" => :ok 10 | }, 11 | "test2" => :ok 12 | } 13 | 14 | expected_map = %{ 15 | test: %{ 16 | hello: :ok 17 | }, 18 | test2: :ok 19 | } 20 | 21 | assert CF.Utils.map_string_keys_to_atom_keys(base_map) == expected_map 22 | end 23 | 24 | test "works with empty map" do 25 | assert CF.Utils.map_string_keys_to_atom_keys(%{}) == %{} 26 | end 27 | 28 | test "doesn't crash if binary and atom keys are mixed" do 29 | base_map = %{ 30 | "test" => %{ 31 | hello: 42 32 | }, 33 | test_again: :ok 34 | } 35 | 36 | expected_map = %{ 37 | test: %{ 38 | hello: 42 39 | }, 40 | test_again: :ok 41 | } 42 | 43 | assert CF.Utils.map_string_keys_to_atom_keys(base_map) == expected_map 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /apps/cf/test/utils/frontend_router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Utils.FrontendRouterTest do 2 | use ExUnit.Case 3 | doctest CF.Utils.FrontendRouter 4 | end 5 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generated on crash by the VM 8 | erl_crash.dump 9 | 10 | # Files matching config/*.secret.exs pattern contain sensitive 11 | # data and you should not commit them into version control. 12 | # 13 | # Alternatively, you may comment the line below and commit the 14 | # secrets files as long as you replace their contents by environment 15 | # variables. 16 | /config/*.secret.exs 17 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/README.md: -------------------------------------------------------------------------------- 1 | # [CaptainFact App] CF Atom Feed 2 | 3 | ## Secrets 4 | 5 | Following secrets must be configured in production: 6 | 7 | - db_hostname 8 | - db_username 9 | - db_password 10 | - db_name 11 | - frontend_url 12 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/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 | use Mix.Config 7 | 8 | # General application configuration 9 | config :cf_atom_feed, 10 | namespace: CF.AtomFeed, 11 | ecto_repos: [DB.Repo] 12 | 13 | # Configures Elixir's Logger 14 | config :logger, :console, 15 | format: "$time $metadata[$level] $message\n", 16 | metadata: [:request_id] 17 | 18 | # Configure Postgres pool size 19 | config :db, DB.Repo, pool_size: 1 20 | 21 | # Import environment specific config. This must remain at the bottom 22 | # of this file so it overrides the configuration defined above. 23 | import_config "#{Mix.env()}.exs" 24 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Do not include metadata nor timestamps in development logs 4 | config :logger, :console, format: "[$level] $message\n" 5 | 6 | config :cf_atom_feed, 7 | CF.AtomFeed.Router, 8 | cowboy: [port: 4004] 9 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Do not print debug messages in production 4 | config :logger, level: :info 5 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Print only warnings and errors during test 4 | config :logger, level: :warn 5 | 6 | # Use a different port in test to avoid conflicting with dev server 7 | config :cf_atom_feed, CF.AtomFeed.Router, cowboy: [port: 10004] 8 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/lib/application.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.AtomFeed.Application do 2 | use Application 3 | 4 | # See https://hexdocs.pm/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec 8 | 9 | children = [] 10 | config = Application.get_env(:cf_atom_feed, CF.AtomFeed.Router) 11 | 12 | if config[:cowboy] do 13 | children = [supervisor(CF.AtomFeed.Router, []) | children] 14 | end 15 | 16 | # See https://hexdocs.pm/elixir/Supervisor.html 17 | # for other strategies and supported options 18 | opts = [strategy: :one_for_one, name: CF.AtomFeed.Supervisor] 19 | Supervisor.start_link(children, opts) 20 | end 21 | 22 | def config_change(_changed, _new, _removed) do 23 | :ok 24 | end 25 | 26 | def version() do 27 | case :application.get_key(:cf_atom_feed, :vsn) do 28 | {:ok, version} -> to_string(version) 29 | _ -> "unknown" 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/lib/common.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.AtomFeed.Common do 2 | @moduledoc """ 3 | Common ATOM feed functions 4 | """ 5 | 6 | @doc """ 7 | Default feed author 8 | """ 9 | def feed_author(feed), 10 | do: Atomex.Feed.author(feed, "CaptainFact", email: "atom-feed@captainfact.io") 11 | 12 | @doc """ 13 | Feed base URL 14 | """ 15 | def feed_base_url(), 16 | do: "https://feed.captainfact.io/" 17 | end 18 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/lib/router.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.AtomFeed.Router do 2 | use Plug.Router 3 | require Logger 4 | 5 | plug(Plug.Head) 6 | plug(:match) 7 | plug(:dispatch) 8 | 9 | def start_link do 10 | config = Application.get_env(:cf_atom_feed, CF.AtomFeed.Router) 11 | Logger.info("Running CF.AtomFeed.Router with cowboy on port #{config[:cowboy][:port]}") 12 | Plug.Cowboy.http(CF.AtomFeed.Router, [], config[:cowboy]) 13 | end 14 | 15 | get "/" do 16 | conn 17 | |> put_resp_content_type("application/json") 18 | |> send_resp(200, """ 19 | { 20 | "app": "CF.AtomFeed", 21 | "status": "✔", 22 | "version": "#{CF.AtomFeed.Application.version()}", 23 | "db_version": "#{DB.Application.version()}" 24 | } 25 | """) 26 | end 27 | 28 | @feed_content_type "application/atom+xml" 29 | 30 | defp render_feed(conn, feed_content) do 31 | conn 32 | |> put_resp_content_type(@feed_content_type) 33 | |> send_resp(200, feed_content) 34 | end 35 | 36 | get "/comments" do 37 | render_feed(conn, CF.AtomFeed.Comments.feed_all()) 38 | end 39 | 40 | get "/statements" do 41 | render_feed(conn, CF.AtomFeed.Statements.feed_all()) 42 | end 43 | 44 | get "/videos" do 45 | render_feed(conn, CF.AtomFeed.Videos.feed_all()) 46 | end 47 | 48 | get "/flags" do 49 | render_feed(conn, CF.AtomFeed.Flags.feed_all()) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.AtomFeed.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :cf_atom_feed, 7 | version: "1.0.4", 8 | build_path: "../../_build", 9 | config_path: "../../config/config.exs", 10 | deps_path: "../../deps", 11 | lockfile: "../../mix.lock", 12 | elixir: "~> 1.6", 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | compilers: Mix.compilers(), 15 | start_permanent: Mix.env() == :prod, 16 | deps: deps(), 17 | test_coverage: [tool: ExCoveralls] 18 | ] 19 | end 20 | 21 | # Configuration for the OTP application. 22 | # 23 | # Type `mix help compile.app` for more information. 24 | def application do 25 | [ 26 | mod: {CF.AtomFeed.Application, []}, 27 | extra_applications: [:logger, :runtime_tools, :cowboy, :plug] 28 | ] 29 | end 30 | 31 | # Specifies which paths to compile per environment. 32 | defp elixirc_paths(:test), do: ["lib", "test/support"] 33 | defp elixirc_paths(_), do: ["lib"] 34 | 35 | defp deps do 36 | [ 37 | # --- Runtime 38 | {:atomex, "~> 0.2"}, 39 | {:cowboy, "~> 2.0"}, 40 | {:plug, "~> 1.7"}, 41 | {:kaur, "~> 1.1"}, 42 | 43 | # ---- In Umbrella 44 | {:db, in_umbrella: true}, 45 | {:cf, in_umbrella: true} 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/test/comments_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.AtomFeed.CommentsTest do 2 | use ExUnit.Case 3 | alias DB.{Repo, Schema, Factory} 4 | 5 | setup do 6 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(DB.Repo) 7 | end 8 | 9 | test "render a basic feed" do 10 | # Ensure comments from previous tests get deleted 11 | Repo.delete_all(Schema.Comment) 12 | 13 | # Insert fake comments and render feed 14 | comments = Factory.insert_list(5, :comment) 15 | feed = CF.AtomFeed.Comments.feed_all() 16 | 17 | # Check feed info 18 | assert feed =~ """ 19 | 20 | 21 | 22 | 23 | CaptainFact 24 | atom-feed@captainfact.io 25 | 26 | https://TEST_FRONTEND/ 27 | [CaptainFact] All Comments 28 | """ 29 | 30 | # Check comment entries 31 | for comment <- comments do 32 | assert feed =~ 33 | ~r(https://TEST_FRONTEND/videos/[a-zA-Z0-9]+\?statement=#{comment.statement_id}&c=#{comment.id}"/>) 34 | 35 | assert feed =~ ~r(New Comment from .+ on ##{comment.statement_id}) 36 | end 37 | end 38 | 39 | test "should properly render anonymized comments" do 40 | # Ensure comments from previous tests get deleted 41 | Repo.delete_all(Schema.Comment) 42 | 43 | Factory.insert(:comment, user: nil) 44 | feed = CF.AtomFeed.Comments.feed_all() 45 | assert feed =~ ~r(New Comment from Deleted account) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/test/statements_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.AtomFeed.StatementsTest do 2 | use ExUnit.Case 3 | alias DB.{Repo, Schema, Factory} 4 | 5 | setup do 6 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(DB.Repo) 7 | end 8 | 9 | test "render a basic feed" do 10 | # Ensure comments from previous tests get deleted 11 | Repo.delete_all(Schema.Statement) 12 | 13 | # Insert fake comments and render feed 14 | statements = Factory.insert_list(5, :statement) 15 | feed = CF.AtomFeed.Statements.feed_all() 16 | 17 | # Check feed info 18 | assert String.starts_with?(feed, """ 19 | 20 | 21 | 22 | 23 | CaptainFact 24 | atom-feed@captainfact.io 25 | 26 | https://TEST_FRONTEND/ 27 | [CaptainFact] All Statements 28 | """) 29 | 30 | # Check comment entries 31 | for statement <- statements do 32 | statement_url = 33 | "https://TEST_FRONTEND/videos/#{statement.video.hash_id}?statement=#{statement.id}" 34 | 35 | assert feed =~ statement_url 36 | assert feed =~ "New statement for video #{statement.video.title}" 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/cf_atom_feed/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Ecto.Adapters.SQL.Sandbox.mode(DB.Repo, {:shared, self()}) 2 | 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /apps/cf_graphql/.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generated on crash by the VM 8 | erl_crash.dump 9 | 10 | -------------------------------------------------------------------------------- /apps/cf_graphql/README.md: -------------------------------------------------------------------------------- 1 | # [CaptainFact App] CF GraphQL 2 | 3 | ## Secrets 4 | 5 | Following secrets must be configured in production: 6 | 7 | - db_hostname 8 | - db_username 9 | - db_password 10 | - db_name 11 | - frontend_url 12 | - host 13 | -------------------------------------------------------------------------------- /apps/cf_graphql/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 | use Mix.Config 7 | 8 | # General application configuration 9 | config :cf_graphql, 10 | namespace: CF.Graphql, 11 | ecto_repos: [DB.Repo], 12 | env: Mix.env() 13 | 14 | # Configures the endpoint 15 | config :cf_graphql, CF.GraphQLWeb.Endpoint, 16 | url: [host: "localhost"], 17 | secret_key_base: "Nl5lfMlBMvQpY3n74G9iNTxH4okMpbMWArWst9Vhj75tl+m2PuV+KPwjX0fNMaa8", 18 | pubsub_server: CF.Graphql.PubSub, 19 | server: true 20 | 21 | # Configures Elixir's Logger 22 | config :logger, :console, 23 | format: "$time $metadata[$level] $message\n", 24 | metadata: [:request_id] 25 | 26 | # Configure Postgres pool size 27 | config :db, DB.Repo, pool_size: 5 28 | 29 | # Import environment specific config. This must remain at the bottom 30 | # of this file so it overrides the configuration defined above. 31 | import_config "#{Mix.env()}.exs" 32 | -------------------------------------------------------------------------------- /apps/cf_graphql/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :cf_graphql, CF.GraphQLWeb.Endpoint, 4 | http: [port: 4002], 5 | https: [ 6 | port: 4003, 7 | otp_app: :cf_graphql, 8 | keyfile: "priv/keys/privkey.pem", 9 | certfile: "priv/keys/fullchain.pem" 10 | ], 11 | debug_errors: true, 12 | code_reloader: false, 13 | check_origin: false, 14 | watchers: [] 15 | 16 | # Do not include metadata nor timestamps in development logs 17 | config :logger, :console, format: "[$level] $message\n" 18 | 19 | # Set a higher stacktrace during development. Avoid configuring such 20 | # in production as building large stacktraces may be expensive. 21 | config :phoenix, :stacktrace_depth, 20 22 | config :phoenix, :json_library, Jason 23 | -------------------------------------------------------------------------------- /apps/cf_graphql/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Do not print debug messages in production 4 | config :logger, level: :info 5 | 6 | # Configure endpoint 7 | config :cf_graphql, CF.GraphQLWeb.Endpoint, 8 | server: false, 9 | debug_errors: false, 10 | code_reloader: false, 11 | check_origin: false, 12 | watchers: [] 13 | -------------------------------------------------------------------------------- /apps/cf_graphql/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :cf_graphql, CF.GraphQLWeb.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/application.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Application do 2 | use Application 3 | 4 | # See https://hexdocs.pm/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec 8 | 9 | # Define workers and child supervisors to be supervised 10 | children = [ 11 | # Start the PubSub system 12 | {Phoenix.PubSub, name: CF.Graphql.PubSub}, 13 | # Start the endpoint when the application starts 14 | supervisor(CF.GraphQLWeb.Endpoint, []) 15 | ] 16 | 17 | # See https://hexdocs.pm/elixir/Supervisor.html 18 | # for other strategies and supported options 19 | opts = [strategy: :one_for_one, name: CF.Graphql.Supervisor] 20 | Supervisor.start_link(children, opts) 21 | end 22 | 23 | # Tell Phoenix to update the endpoint configuration 24 | # whenever the application is updated. 25 | def config_change(changed, _new, removed) do 26 | CF.GraphQLWeb.Endpoint.config_change(changed, removed) 27 | :ok 28 | end 29 | 30 | def version() do 31 | case :application.get_key(:cf_graphql, :vsn) do 32 | {:ok, version} -> to_string(version) 33 | _ -> "unknown" 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/auth_pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.AuthPipeline do 2 | @moduledoc """ 3 | Adds the token authentification from CF app to graphql API. 4 | """ 5 | 6 | @behaviour Plug 7 | 8 | import Plug.Conn 9 | 10 | def init(opts), do: opts 11 | 12 | def call(conn, _) do 13 | case build_context(conn) do 14 | {:ok, context} -> 15 | put_private(conn, :absinthe, %{context: context}) 16 | 17 | _ -> 18 | conn 19 | end 20 | end 21 | 22 | defp build_context(conn) do 23 | with ["Bearer " <> token] <- get_req_header(conn, "authorization"), 24 | {:ok, current_user, _claims} <- authorize(token) do 25 | {:ok, %{user: current_user}} 26 | end 27 | end 28 | 29 | defp authorize(token) do 30 | CF.Authenticator.GuardianImpl.resource_from_token(token) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/captain_fact_graphql_web.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.GraphQLWeb 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 CF.GraphQLWeb, :controller 9 | use CF.GraphQLWeb, :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 router do 21 | quote do 22 | use Phoenix.Router 23 | import Plug.Conn 24 | import Phoenix.Controller 25 | end 26 | end 27 | 28 | @doc """ 29 | When used, dispatch to the appropriate controller/view/etc. 30 | """ 31 | defmacro __using__(which) when is_atom(which) do 32 | apply(__MODULE__, which, []) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.GraphQLWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :cf_graphql 3 | 4 | plug(Plug.RequestId) 5 | plug(Plug.Logger) 6 | 7 | plug( 8 | Corsica, 9 | max_age: 3600, 10 | allow_headers: ~w(Accept Content-Type Authorization Origin), 11 | origins: "*" 12 | ) 13 | 14 | plug( 15 | Plug.Parsers, 16 | parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser], 17 | pass: ["*/*"], 18 | json_decoder: Jason 19 | ) 20 | 21 | plug(Plug.MethodOverride) 22 | plug(Plug.Head) 23 | plug(CF.GraphQLWeb.Router) 24 | end 25 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/resolvers/app_info.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Resolvers.AppInfo do 2 | def info(_, _args, _info) do 3 | {:ok, 4 | %{ 5 | app: "CF.Graphql", 6 | status: "✔", 7 | version: CF.Graphql.Application.version(), 8 | db_version: DB.Application.version() 9 | }} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/resolvers/comments.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Resolvers.Comments do 2 | import Absinthe.Resolution.Helpers, only: [batch: 3] 3 | import Ecto.Query 4 | alias DB.Repo 5 | alias DB.Schema.Vote 6 | 7 | def score(comment, _args, _info) do 8 | batch({__MODULE__, :comments_scores}, comment.id, fn results -> 9 | {:ok, Map.get(results, comment.id) || 0} 10 | end) 11 | end 12 | 13 | def comments_scores(_, comments_ids) do 14 | Vote 15 | |> where([v], v.comment_id in ^comments_ids) 16 | |> select([v], {v.comment_id, sum(v.value)}) 17 | |> group_by([v], v.comment_id) 18 | |> Repo.all() 19 | |> Enum.into(%{}) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/resolvers/speakers.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Resolvers.Speakers do 2 | def picture(speaker, _, _) do 3 | {:ok, DB.Type.SpeakerPicture.full_url(speaker, :thumb)} 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/resolvers/statements.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Resolvers.Statements do 2 | @moduledoc """ 3 | Resolver for `DB.Schema.Statement` 4 | """ 5 | 6 | alias Kaur.Result 7 | 8 | import Ecto.Query 9 | import Absinthe.Resolution.Helpers, only: [batch: 3] 10 | 11 | alias DB.Repo 12 | alias DB.Schema.Statement 13 | 14 | # Queries 15 | 16 | def paginated_list(_root, args = %{offset: offset, limit: limit}, _info) do 17 | Statement 18 | |> Statement.query_list(Map.get(args, :filters, [])) 19 | |> Repo.paginate(page: offset, page_size: limit) 20 | |> Result.ok() 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/resolvers/statistics.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Resolvers.Statistics do 2 | @moduledoc """ 3 | Absinthe solver for community insights and statistics 4 | """ 5 | 6 | alias DB.Statistics 7 | 8 | alias Kaur.Result 9 | 10 | @doc """ 11 | Get default statistic object 12 | """ 13 | @spec default(any, any, any) :: Result.result_tuple() 14 | def default(_, _, _) do 15 | Result.ok(%{}) 16 | end 17 | 18 | @doc """ 19 | Solvers for statistics 20 | """ 21 | @spec all_totals(any, any, any) :: Result.result_tuple() 22 | def all_totals(_, _, _) do 23 | Result.ok(Statistics.all_totals()) 24 | end 25 | 26 | @doc """ 27 | returns 28 | `{:ok, best_users}` 29 | `{:error, "leaderboard unaccessible"} 30 | """ 31 | @spec leaderboard(any, any) :: {:ok, list} | {:error, binary} 32 | def leaderboard(_root, _args) do 33 | Statistics.leaderboard() 34 | |> Result.from_value() 35 | |> Result.map_error(fn _ -> "leaderboard unaccessible" end) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/router.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.GraphQLWeb.Router do 2 | use CF.GraphQLWeb, :router 3 | 4 | @graphiql_route "/graphiql" 5 | 6 | pipeline :api do 7 | plug(:accepts, ["json"]) 8 | end 9 | 10 | pipeline :api_auth do 11 | plug(:accepts, ["json"]) 12 | plug(CF.Graphql.AuthPipeline) 13 | end 14 | 15 | scope "/" do 16 | pipe_through(:api_auth) 17 | 18 | scope @graphiql_route do 19 | forward( 20 | "/", 21 | Absinthe.Plug.GraphiQL, 22 | schema: CF.Graphql.Schema, 23 | analyze_complexity: true, 24 | max_complexity: 400 25 | ) 26 | end 27 | 28 | forward( 29 | "/", 30 | Absinthe.Plug, 31 | schema: CF.Graphql.Schema, 32 | analyze_complexity: true, 33 | max_complexity: 400 34 | ) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/input_objects/statement_filter.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.InputObjects.StatementFilter do 2 | @moduledoc """ 3 | Represent the possible filters to apply to statement. 4 | """ 5 | 6 | use Absinthe.Schema.Notation 7 | 8 | @desc "Props to filter statements on" 9 | input_object :statement_filter do 10 | field(:commented, :boolean) 11 | field(:is_draft, :boolean) 12 | field(:speaker_id, :id) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/input_objects/video_filter.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.InputObjects.VideoFilter do 2 | @moduledoc """ 3 | Represent a user's Notification. 4 | """ 5 | 6 | use Absinthe.Schema.Notation 7 | 8 | @desc "Props to filter videos on" 9 | input_object :video_filter do 10 | field(:language, :string) 11 | field(:min_id, :id) 12 | field(:speaker_id, :id) 13 | field(:speaker_slug, :string) 14 | field(:is_partner, :boolean) 15 | field(:is_featured, :boolean) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/middleware/require_authentication.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Middleware.RequireAuthentication do 2 | @moduledoc """ 3 | A middleware to force authentication. 4 | """ 5 | 6 | @behaviour Absinthe.Middleware 7 | 8 | @doc false 9 | def call(resolution, _args) do 10 | if is_nil(resolution.context[:user]) do 11 | Absinthe.Resolution.put_result(resolution, {:error, "unauthorized"}) 12 | else 13 | resolution 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/middleware/require_reputation.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Middleware.RequireReputation do 2 | @moduledoc """ 3 | A middleware to ensure the user has a certain reputation. 4 | """ 5 | 6 | @behaviour Absinthe.Middleware 7 | 8 | @doc false 9 | def call(resolution, reputation) do 10 | cond do 11 | is_nil(resolution.context[:user]) -> 12 | Absinthe.Resolution.put_result(resolution, {:error, "unauthorized"}) 13 | 14 | resolution.context[:user].reputation && resolution.context[:user].reputation < reputation -> 15 | Absinthe.Resolution.put_result( 16 | resolution, 17 | {:error, 18 | %{ 19 | code: "unauthorized", 20 | message: "You do not have the required reputation to perform this action.", 21 | details: %{ 22 | user_reputation: resolution.context[:user].reputation, 23 | required_reputation: reputation 24 | } 25 | }} 26 | ) 27 | 28 | true -> 29 | resolution 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/types/app_info.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Types.AppInfo do 2 | @moduledoc """ 3 | App info representation. Contains version, status...etc 4 | """ 5 | 6 | use Absinthe.Schema.Notation 7 | 8 | @desc "Information about the application" 9 | object :app_info do 10 | @desc "Indicate if the application is running properly with a checkmark" 11 | field(:status, non_null(:string)) 12 | @desc "Graphql API version" 13 | field(:version, non_null(:string)) 14 | @desc "Version of the database app attached to this API" 15 | field(:db_version, non_null(:string)) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/types/comment.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Types.Comment do 2 | @moduledoc """ 3 | Representation of a `DB.Schema.Comment` for Absinthe 4 | """ 5 | 6 | use Absinthe.Schema.Notation 7 | 8 | import Absinthe.Resolution.Helpers, only: [dataloader: 1] 9 | import CF.Graphql.Schema.Utils 10 | alias CF.Graphql.Resolvers 11 | 12 | @desc "A user's comment. A comment will be considered being a fact if it has a source" 13 | object :comment do 14 | field(:id, non_null(:id)) 15 | @desc "User who made the comment" 16 | field :user, :user do 17 | resolve(dataloader(DB.Repo)) 18 | complexity(join_complexity()) 19 | end 20 | 21 | @desc "Text of the comment. Can be null if the comment has a source" 22 | field(:text, :string) 23 | @desc "Can be true / false (facts) or null (comment)" 24 | field(:approve, :boolean) 25 | @desc "Datetime at which the comment has been added" 26 | field(:inserted_at, :string) 27 | @desc "Score of the comment / fact, based on users votes" 28 | field :score, non_null(:integer) do 29 | resolve(&Resolvers.Comments.score/3) 30 | complexity(join_complexity()) 31 | end 32 | 33 | @desc "Source of the scomment. If null, a text must be set" 34 | field :source, :source do 35 | resolve(dataloader(DB.Repo)) 36 | complexity(join_complexity()) 37 | end 38 | 39 | @desc "If this comment is a reply, this will point toward the comment being replied to" 40 | field(:reply_to_id, :id) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/types/notification.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Types.Notification do 2 | @moduledoc """ 3 | Represent a user's Notification. 4 | """ 5 | 6 | use Absinthe.Schema.Notation 7 | 8 | import Absinthe.Resolution.Helpers, only: [dataloader: 1] 9 | import CF.Graphql.Schema.Utils 10 | 11 | @desc "A user notification" 12 | object :notification do 13 | field(:id, non_null(:id)) 14 | @desc "Type of the notification" 15 | field(:type, non_null(:string)) 16 | @desc "Notification creation datetime" 17 | field(:inserted_at, non_null(:string)) 18 | @desc "When the notification has been seen, or null if it has not" 19 | field(:seen_at, :string) 20 | @desc "Action the notification is referencing" 21 | field :action, :user_action do 22 | resolve(dataloader(DB.Repo)) 23 | complexity(join_complexity()) 24 | end 25 | end 26 | 27 | @desc "A paginated list of user actions" 28 | object :paginated_notifications do 29 | import_fields(:paginated) 30 | field(:entries, list_of(:notification)) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/types/paginated.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Types.Paginated do 2 | @moduledoc """ 3 | A generic pagination object 4 | """ 5 | 6 | use Absinthe.Schema.Notation 7 | 8 | object :paginated do 9 | field(:page_number, :integer) 10 | field(:page_size, :integer) 11 | field(:total_pages, :integer) 12 | field(:total_entries, :integer) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/types/source.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Types.Source do 2 | @moduledoc """ 3 | Representation of a `DB.Schema.Source` for Absinthe 4 | """ 5 | 6 | use Absinthe.Schema.Notation 7 | 8 | @desc "An URL pointing toward a source (article, video, pdf...)" 9 | object :source do 10 | @desc "Unique id of the source" 11 | field(:id, non_null(:id)) 12 | @desc "URL of the source" 13 | field(:url, non_null(:string)) 14 | @desc "Title of the page / article" 15 | field(:title, :string) 16 | @desc "Language of the page / article" 17 | field(:language, :string) 18 | @desc "Site name extracted from OpenGraph" 19 | field(:site_name, :string) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/types/speaker.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Types.Speaker do 2 | @moduledoc """ 3 | Representation of a `DB.Schema.Speaker` for Absinthe 4 | """ 5 | 6 | use Absinthe.Schema.Notation 7 | 8 | import Absinthe.Resolution.Helpers, only: [dataloader: 1] 9 | import Absinthe.Resolution.Helpers, only: [dataloader: 1] 10 | import CF.Graphql.Schema.Utils 11 | alias CF.Graphql.Resolvers 12 | 13 | @desc "A speaker appearing in one or more videos" 14 | object :speaker do 15 | field(:id, non_null(:id)) 16 | @desc "A unique slug to identify the speaker" 17 | field(:slug, :string) 18 | @desc "Full name" 19 | field(:full_name, non_null(:string)) 20 | 21 | @desc "Official title (can have multiple separated by a comma). Ex: Politician, activist, writer" 22 | field(:title, :string) 23 | @desc "Country code of the speaker's origin (from wikidata)" 24 | field(:country, :string) 25 | @desc "Wikidata unique identifier, without the 'Q' prefix" 26 | field(:wikidata_item_id, :string) 27 | @desc "Speaker's picture URL. Format is 50x50" 28 | field(:picture, :string, do: resolve(&Resolvers.Speakers.picture/3)) 29 | @desc "List of speaker's videos" 30 | field :videos, list_of(:video) do 31 | resolve(dataloader(DB.Repo)) 32 | complexity(join_complexity()) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/types/statement.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Types.Statement do 2 | @moduledoc """ 3 | Representation of a `DB.Schema.Statement` for Absinthe 4 | """ 5 | 6 | use Absinthe.Schema.Notation 7 | 8 | import Absinthe.Resolution.Helpers, only: [dataloader: 1] 9 | import CF.Graphql.Schema.Utils 10 | 11 | @desc "A transcript or a description of the picture" 12 | object :statement do 13 | field(:id, non_null(:id)) 14 | @desc "Speaker's transcript or image description" 15 | field(:text, non_null(:string)) 16 | @desc "Statement timecode, in seconds" 17 | field(:time, non_null(:integer)) 18 | @desc "Whether the statement is in draft mode" 19 | field(:is_draft, non_null(:boolean)) 20 | 21 | @desc "Statement's speaker. Null if statement describes picture" 22 | field :speaker, :speaker do 23 | resolve(dataloader(DB.Repo)) 24 | complexity(join_complexity()) 25 | end 26 | 27 | @desc "List of users comments and facts for this statement" 28 | field :comments, list_of(:comment) do 29 | resolve(dataloader(DB.Repo)) 30 | complexity(join_complexity()) 31 | end 32 | 33 | @desc "The video associated with this statement" 34 | field :video, :video do 35 | resolve(dataloader(DB.Repo)) 36 | complexity(join_complexity()) 37 | end 38 | end 39 | 40 | @desc "A list a paginated statements" 41 | object :paginated_statements do 42 | import_fields(:paginated) 43 | field(:entries, list_of(:statement)) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/types/statistics.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Types.Statistics do 2 | @moduledoc """ 3 | Various application statistics, like the number of users, the number of 4 | comments... 5 | """ 6 | 7 | use Absinthe.Schema.Notation 8 | alias CF.Graphql.Resolvers 9 | 10 | @desc "Statistics about the platform community" 11 | object :statistics do 12 | @desc "All totals" 13 | field(:totals, :statistic_totals, do: resolve(&Resolvers.Statistics.all_totals/3)) 14 | @desc "List the 20 best users" 15 | field(:leaderboard, list_of(:user), do: resolve(&Resolvers.Statistics.leaderboard/2)) 16 | end 17 | 18 | @desc "Counts for all public CF tables" 19 | object :statistic_totals do 20 | field(:users, non_null(:integer)) 21 | field(:comments, non_null(:integer)) 22 | field(:statements, non_null(:integer)) 23 | field(:sources, non_null(:integer)) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/types/video_caption.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Types.VideoCaption do 2 | @moduledoc """ 3 | A single caption for a video 4 | """ 5 | 6 | use Absinthe.Schema.Notation 7 | 8 | @desc "Information about the application" 9 | object :video_caption do 10 | @desc "Caption text" 11 | field(:text, non_null(:string)) 12 | @desc "Caption start time (in seconds)" 13 | field(:start, non_null(:float)) 14 | @desc "Caption duration (in seconds)" 15 | field(:duration, :float) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/cf_graphql/lib/schema/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Schema.Utils do 2 | @moduledoc """ 3 | Utility functions for types and resolvers. 4 | """ 5 | 6 | @default_join_complexity 50 7 | 8 | @doc """ 9 | Sets the join complexity for given association. Default join complexity is 10 | set in @default_join_complexity which value is `50` 11 | """ 12 | defmacro join_complexity(complexity \\ @default_join_complexity) do 13 | quote do 14 | fn _, child_complexity -> unquote(complexity) + child_complexity end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/cf_graphql/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :cf_graphql, 7 | version: "1.1.0", 8 | build_path: "../../_build", 9 | config_path: "../../config/config.exs", 10 | deps_path: "../../deps", 11 | lockfile: "../../mix.lock", 12 | elixir: "~> 1.6", 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | compilers: [:phoenix] ++ Mix.compilers(), 15 | build_embedded: Mix.env() == :prod, 16 | start_permanent: Mix.env() == :prod, 17 | aliases: aliases(), 18 | deps: deps(), 19 | test_coverage: [tool: ExCoveralls] 20 | ] 21 | end 22 | 23 | def application do 24 | [ 25 | mod: {CF.Graphql.Application, []}, 26 | extra_applications: [:logger, :runtime_tools] 27 | ] 28 | end 29 | 30 | defp elixirc_paths(:test), do: ["lib", "test/support"] 31 | defp elixirc_paths(_), do: ["lib"] 32 | 33 | defp deps do 34 | [ 35 | {:phoenix, "~> 1.5.14"}, 36 | {:phoenix_pubsub, "~> 2.0"}, 37 | {:jason, "~> 1.4"}, 38 | {:plug, "~> 1.7"}, 39 | {:cowboy, "~> 2.0"}, 40 | {:corsica, "~> 2.1"}, 41 | {:absinthe_plug, "~> 1.5"}, 42 | {:dataloader, "~> 2.0.2"}, 43 | {:kaur, "~> 1.1"}, 44 | {:poison, "~> 3.1"}, 45 | 46 | # Internal dependencies 47 | {:db, in_umbrella: true}, 48 | {:cf, in_umbrella: true}, 49 | 50 | # Dev only 51 | {:exsync, "~> 0.2", only: :dev} 52 | ] 53 | end 54 | 55 | defp aliases do 56 | [] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /apps/cf_graphql/priv/keys/fullchain.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC+zCCAeOgAwIBAgIJAP5GvLkEg+P4MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV 3 | BAMMCWxvY2FsaG9zdDAeFw0xNzA1MTIwNjEwMzJaFw0yNzA1MTAwNjEwMzJaMBQx 4 | EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 5 | ggEBAL6uymshViiULeyiTXOUYMppzagH4W+bCA75rYyYFUXJJbvvaE3QVvpAyTIz 6 | OXX1rNCvoxxUknV0d3y3JyqN8fU+Oi7Q5rkliZu9tN6xthJrXgRnm12HomNRlC2i 7 | rIXhLTJVcPGM7kbiPZOVKCkN0MF+9EPEvnq9xvtDgzc3ZBKnvUglKrjdwUCZJMTQ 8 | fggtN1PLbNpKGLx3cWBb3SRuzwtVLi34ixMgwnyXzMreH5U1IK7K/hra9vhB1N7j 9 | npx4vlNVagzFZuybuf4Aozne3yioU1z/8sAnHb83DoGs+JnRAnGSZyzg/eGIqdCd 10 | GMTsWH3CHnetE5pNGcwJuL4PFE8CAwEAAaNQME4wHQYDVR0OBBYEFK7VtLoCcnIN 11 | /0YYwfD9iTfTkm3OMB8GA1UdIwQYMBaAFK7VtLoCcnIN/0YYwfD9iTfTkm3OMAwG 12 | A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAH/mDv1G3XKdfbzXiPZNiqVa 13 | N17yaffJLFA5Kj33RVwChhutZi0CZYYAuY19BkW9j4+poz2xnRhcvkJqgBdL0mvu 14 | 0NSnCXI/S8+Im97h2aUGgbUQvOxDaeLpLFWt7zZAT9y5zBB4PjwMJY/pp5KAtcNc 15 | GGyfSsr8jhlcriBUqrYUrgX8AvDV8qM2Y++nJ8igmTVjDgWG8hAuipHRmH8r6PBK 16 | 1XeTf4+Q3WB99kEcTglfPgm68KGavRimmuuUejShmulzbNiT+OEMO1KHPIfOc/z9 17 | 4AobuD/k0WNGqOx9Y27A7t7ldKxB7ByXvEwetxyIc3Q9Pv0hrPVGs5ceR1uCk4s= 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /apps/cf_graphql/priv/keys/privkey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAvq7KayFWKJQt7KJNc5RgymnNqAfhb5sIDvmtjJgVRcklu+9o 3 | TdBW+kDJMjM5dfWs0K+jHFSSdXR3fLcnKo3x9T46LtDmuSWJm7203rG2EmteBGeb 4 | XYeiY1GULaKsheEtMlVw8YzuRuI9k5UoKQ3QwX70Q8S+er3G+0ODNzdkEqe9SCUq 5 | uN3BQJkkxNB+CC03U8ts2koYvHdxYFvdJG7PC1UuLfiLEyDCfJfMyt4flTUgrsr+ 6 | Gtr2+EHU3uOenHi+U1VqDMVm7Ju5/gCjOd7fKKhTXP/ywCcdvzcOgaz4mdECcZJn 7 | LOD94Yip0J0YxOxYfcIed60Tmk0ZzAm4vg8UTwIDAQABAoIBACNDf/u/9ocaoEOa 8 | 4Gf3kM7eMkJY8sAJE7xxQD84APce8/OFmuyJEwzE3nCCOKYwAP22/ZtHqK5AE7jk 9 | xkGAbrbEA06VI5Yp8wDyXHiytNFDOefmoTzy0H09oQGvi+hWdF1Sn8iMH6TMQkcA 10 | 1qSBAZJHQDUoNXHNlvbwzVtwyvkH6gXOy1xzPJLWT/9z0TYZN25QJ7hjxVygrqxk 11 | NdDL0J77lBncuMMwTUnUrElJNPEJMaJnINRFs/EpOUZbvFa3sczOfG0JGoJA8Rra 12 | 6jqh4UE5g/ruYR499KaN5zL5Y0w8JeLyglwhGARnEvGl88gpNqBs7KHlEJBhX0jv 13 | 7sEn+kECgYEA+lJdZkC6rNlRiFurJG06AVOL3hej7x1Int/htdVze40OLKlf5Mii 14 | E0Bqp6meRpk8B0IMkhrN8/LN00YV6ktWhRFUBNN0DCCxRVXk1a9fJrvSoDGkVg2Q 15 | Xnd6RjPyW7ub8L5QoUYSuzfcEyULdeWAi7eRDJa0Efaj2AWNYQqQfVsCgYEAwwIZ 16 | MIvEmZkRI/o3aqGoaAcvYJ4+y9TKNhdQCBxsV31YAejzgPLxPaM1+LtoxwZ9Ls8S 17 | gQzskE9bHXylxNMpJApn8DeuIfMLrFa+7VxIC38Gl97C8MAgZvqhSWIpqsY5avBr 18 | D29vYHSidrfioWRM5xmXXO3ys8t/shigANLzcx0CgYEA5LzK2Bsh+byDgmSxmJGu 19 | xXOAhat4g5FwwKy35Z5s7mNQpoMHO1oSsCDW1Opr1PtFHSS/s+qGc/pVFlAeyn+Z 20 | SfMxoU9P5Z0iH8eDWbfs7MoIh5WVI4U1fP0UYH4rYqOmtXBS4WvUxfsfQOdC97KF 21 | qiZNhwFW/mswAL/iFuC+c60CgYBcw/rHpTV4+9+zhawnBY/fLMvU4nJs9GTdJmnj 22 | 8eF4HSBoiDCN/wPTlnhuQnitdODIC6l5ynQekiF9/XW+E9VWV7zqARLNA5lh+kIJ 23 | GAUNsven90g0zrCbTE69Yf0ASBu4S3YieZg6AkHmx8L/k38h0IK4qljyPrQYPK6g 24 | tbkp4QKBgQCKYtF5GvQs2ZCfjYepYuTYzeSOrpy8jnbytlU//KSrzc+htvAuXK70 25 | pwBRilKRlt7h8g1RS+OJi/H8LoOd+sP+lhaEWtHkGGnJTPXfqQ0ETNAeXmUJNP0N 26 | /Auxa38OOGm7owhiKiAIzlE9EwN+7MHcNoaVoBrqT5YtlEPrzAO+/A== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /apps/cf_graphql/priv/secrets/basic_auth_password: -------------------------------------------------------------------------------- 1 | captain -------------------------------------------------------------------------------- /apps/cf_graphql/priv/secrets/host: -------------------------------------------------------------------------------- 1 | localhost -------------------------------------------------------------------------------- /apps/cf_graphql/priv/secrets/secret_key_base: -------------------------------------------------------------------------------- 1 | 3q0jsoW4rL+K6iO7LDeJXeI4bck9DuTKNkMOc1+k1cgcAcwz0DSXVPfLr1p1gP4H -------------------------------------------------------------------------------- /apps/cf_graphql/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.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 | Finally, if the test case interacts with the database, 7 | it cannot be async. For this reason, every test runs 8 | inside a transaction which is reset at the beginning 9 | of the test unless the test case is marked as async. 10 | """ 11 | 12 | use ExUnit.CaseTemplate 13 | 14 | using do 15 | quote do 16 | # Import conveniences for testing with connections 17 | import Plug.Conn 18 | import Phoenix.ConnTest 19 | import CF.GraphQLWeb.Router.Helpers 20 | 21 | # The default endpoint for testing 22 | @endpoint CF.GraphQLWeb.Endpoint 23 | end 24 | end 25 | 26 | setup tags do 27 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(DB.Repo) 28 | 29 | unless tags[:async] do 30 | Ecto.Adapters.SQL.Sandbox.mode(DB.Repo, {:shared, self()}) 31 | end 32 | 33 | {:ok, conn: Phoenix.ConnTest.build_conn()} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /apps/cf_graphql/test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Graphql.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 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias DB.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import CF.Graphql.DataCase 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(DB.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(DB.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | @doc """ 39 | A helper that transform changeset errors to a map of messages. 40 | 41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 42 | assert "password is too short" in errors_on(changeset).password 43 | assert %{password: ["password is too short"]} = errors_on(changeset) 44 | 45 | """ 46 | def errors_on(changeset) do 47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 48 | Enum.reduce(opts, message, fn {key, value}, acc -> 49 | String.replace(acc, "%{#{key}}", to_string(value)) 50 | end) 51 | end) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /apps/cf_graphql/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Ecto.Adapters.SQL.Sandbox.mode(DB.Repo, {:shared, self()}) 2 | ExUnit.start() 3 | -------------------------------------------------------------------------------- /apps/cf_jobs/README.md: -------------------------------------------------------------------------------- 1 | # [CaptainFact App] CF Jobs 2 | 3 | CaptainFact jobs. 4 | 5 | - Flags: Analyze flags and ban comments when there are too much flags on them. 6 | - Moderation: Analyze moderation feedbacks and take action when a consensus is reached. 7 | - Reputation: Analyze actions to update reputation, taking care of daily limits. 8 | 9 | ## Secrets 10 | 11 | Following secrets must be configured in production: 12 | 13 | - db_hostname 14 | - db_username 15 | - db_password 16 | - db_name 17 | - frontend_url 18 | -------------------------------------------------------------------------------- /apps/cf_jobs/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure scheduler 4 | config :cf_jobs, CF.Jobs.Scheduler, 5 | # Run only one instance across cluster 6 | global: true, 7 | debug_logging: false, 8 | jobs: [ 9 | # Reputation 10 | update_reputations: [ 11 | # every 20 minutes 12 | schedule: {:extended, "*/20"}, 13 | task: {CF.Jobs.Reputation, :update, []}, 14 | overlap: false 15 | ], 16 | reset_daily_reputation_limits: [ 17 | schedule: "@daily", 18 | task: {CF.Jobs.Reputation, :reset_daily_limits, []}, 19 | overlap: false 20 | ], 21 | # Moderation 22 | update_moderation: [ 23 | # every 5 minutes 24 | schedule: "*/5 * * * *", 25 | task: {CF.Jobs.Moderation, :update, []}, 26 | overlap: false 27 | ], 28 | # Flags 29 | update_flags: [ 30 | # every minute 31 | schedule: "*/1 * * * *", 32 | task: {CF.Jobs.Flags, :update, []}, 33 | overlap: false 34 | ], 35 | # Notifications 36 | create_notifications: [ 37 | # every 5 seconds 38 | schedule: {:extended, "*/5"}, 39 | task: {CF.Jobs.CreateNotifications, :update, []}, 40 | overlap: false 41 | ], 42 | # Captions 43 | download_captions: [ 44 | # every 8h 45 | schedule: "0 */8 * * *", 46 | task: {CF.Jobs.DownloadCaptions, :update, []}, 47 | overlap: false 48 | ] 49 | ] 50 | 51 | # Configure Postgres pool size 52 | config :db, DB.Repo, pool_size: 3 53 | 54 | # Import environment specific config 55 | import_config "#{Mix.env()}.exs" 56 | -------------------------------------------------------------------------------- /apps/cf_jobs/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /apps/cf_jobs/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /apps/cf_jobs/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Disable CRON tasks on test 4 | config :cf_jobs, CF.Jobs.Scheduler, jobs: [] 5 | -------------------------------------------------------------------------------- /apps/cf_jobs/lib/application.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Jobs.Application do 2 | use Application 3 | 4 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec 8 | 9 | # Wait 10s before starting to give some time for the migrations to run 10 | :timer.sleep(1000) 11 | 12 | env = Application.get_env(:cf, :env) 13 | 14 | # Define workers and child supervisors to be supervised 15 | children = [ 16 | # Jobs 17 | worker(CF.Jobs.Reputation, []), 18 | worker(CF.Jobs.Flags, []), 19 | worker(CF.Jobs.Moderation, []), 20 | worker(CF.Jobs.CreateNotifications, []), 21 | worker(CF.Jobs.DownloadCaptions, []) 22 | ] 23 | 24 | # Do not start scheduler in tests 25 | children = 26 | if env == :test or Application.get_env(:cf, :disable_scheduler), 27 | do: children, 28 | else: children ++ [worker(CF.Jobs.Scheduler, [])] 29 | 30 | opts = [strategy: :one_for_one, name: CF.Jobs.Supervisor] 31 | Supervisor.start_link(children, opts) 32 | end 33 | 34 | @doc """ 35 | Get app's version from `mix.exs` 36 | """ 37 | def version() do 38 | case :application.get_key(:cf, :vsn) do 39 | {:ok, version} -> to_string(version) 40 | _ -> "unknown" 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /apps/cf_jobs/lib/job.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Jobs.Job do 2 | @moduledoc """ 3 | Define the common behaviour between jobs. 4 | """ 5 | 6 | @type t :: module 7 | 8 | use GenServer 9 | 10 | def init(args) do 11 | {:ok, args} 12 | end 13 | 14 | @doc """ 15 | Get the Job name. 16 | """ 17 | @callback name() :: atom() 18 | end 19 | -------------------------------------------------------------------------------- /apps/cf_jobs/lib/jobs/download_captions.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Jobs.DownloadCaptions do 2 | @behaviour CF.Jobs.Job 3 | 4 | require Logger 5 | import Ecto.Query 6 | 7 | alias DB.Repo 8 | alias DB.Schema.Video 9 | alias DB.Schema.VideoCaption 10 | alias DB.Schema.UsersActionsReport 11 | 12 | @name :download_captions 13 | @analyser_id UsersActionsReport.analyser_id(@name) 14 | 15 | # --- Client API --- 16 | 17 | def name, do: @name 18 | 19 | def start_link() do 20 | GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 21 | end 22 | 23 | def init(args) do 24 | {:ok, args} 25 | end 26 | 27 | # 2 minutes 28 | @timeout 120_000 29 | def update() do 30 | GenServer.call(__MODULE__, :download_captions, @timeout) 31 | end 32 | 33 | # --- Server callbacks --- 34 | def handle_call(:download_captions, _from, _state) do 35 | get_videos() 36 | |> Enum.map(fn video -> 37 | Logger.info("Downloading captions for video #{video.id}") 38 | CF.Videos.download_captions(video) 39 | Process.sleep(1000) 40 | end) 41 | 42 | {:reply, :ok, :ok} 43 | end 44 | 45 | # Get all videos that need new captions. We fetch new captions: 46 | # - For any videos that doesn't have any captions yet 47 | # - For videos whose captions haven't been updated in the last 30 days 48 | defp get_videos() do 49 | Repo.all( 50 | from(v in Video, 51 | limit: 5, 52 | left_join: captions in VideoCaption, 53 | on: captions.video_id == v.id, 54 | where: 55 | is_nil(captions.id) or 56 | captions.updated_at < ^DateTime.add(DateTime.utc_now(), -30 * 24 * 60 * 60, :second), 57 | group_by: v.id, 58 | order_by: [desc: v.inserted_at] 59 | ) 60 | ) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /apps/cf_jobs/lib/scheduler.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Jobs.Scheduler do 2 | use Quantum.Scheduler, otp_app: :cf_jobs 3 | 4 | # Scheduler (job runner) implementation. See `config/config.exs` to see the 5 | # exact configuration with run intervals. 6 | end 7 | -------------------------------------------------------------------------------- /apps/cf_jobs/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Jobs.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :cf_jobs, 7 | version: "1.1.0", 8 | build_path: "../../_build", 9 | config_path: "../../config/config.exs", 10 | deps_path: "../../deps", 11 | lockfile: "../../mix.lock", 12 | elixir: "~> 1.6", 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | build_embedded: Mix.env() == :prod, 15 | start_permanent: Mix.env() == :prod, 16 | aliases: aliases(), 17 | deps: deps(), 18 | test_coverage: [tool: ExCoveralls] 19 | ] 20 | end 21 | 22 | def application do 23 | [ 24 | mod: {CF.Jobs.Application, []}, 25 | extra_applications: [:logger] 26 | ] 27 | end 28 | 29 | # Specifies which paths to compile per environment. 30 | # Specifies which paths to compile per environment. 31 | defp elixirc_paths(:test), do: ["lib", "test/support"] 32 | defp elixirc_paths(_), do: ["lib"] 33 | 34 | # Dependencies 35 | defp deps do 36 | [ 37 | {:quantum, "~> 2.3"}, 38 | {:timex, "~> 3.0"}, 39 | 40 | # ---- Internal ---- 41 | {:cf, in_umbrella: true}, 42 | {:db, in_umbrella: true} 43 | ] 44 | end 45 | 46 | defp aliases do 47 | [] 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /apps/cf_jobs/test/jobs/create_notifications_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Jobs.CreateNotificationsTest do 2 | use CF.Jobs.DataCase, async: false 3 | alias DB.Schema.UserAction 4 | alias DB.Schema.Notification 5 | alias DB.Schema.UsersActionsReport 6 | alias CF.Jobs.CreateNotifications 7 | 8 | test "creates notifications" do 9 | DB.Repo.delete_all(Notification) 10 | DB.Repo.delete_all(UserAction) 11 | DB.Repo.delete_all(UsersActionsReport) 12 | 13 | subscription = insert(:subscription) 14 | statement = insert(:statement, video: subscription.video) 15 | 16 | action = 17 | insert( 18 | :user_action, 19 | type: :create, 20 | entity: :statement, 21 | video: subscription.video, 22 | statement: statement 23 | ) 24 | 25 | CreateNotifications.update(true) 26 | 27 | [notification] = DB.Repo.all(Notification) 28 | assert notification.user_id == subscription.user_id 29 | assert notification.action_id == action.id 30 | assert notification.seen_at == nil 31 | assert notification.type == :new_statement 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /apps/cf_jobs/test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.Jobs.DataCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | model tests. 5 | 6 | You may define functions here to be used as helpers in 7 | your model tests. See `errors_on/2`'s definition as reference. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate, async: false 16 | 17 | using do 18 | quote do 19 | alias DB.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import CF.Jobs.DataCase 25 | import DB.Factory 26 | end 27 | end 28 | 29 | setup tags do 30 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(DB.Repo) 31 | 32 | unless tags[:async] do 33 | Ecto.Adapters.SQL.Sandbox.mode(DB.Repo, {:shared, self()}) 34 | end 35 | 36 | Process.sleep(5) 37 | :ok 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/cf_jobs/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _} = Application.ensure_all_started(:ex_machina) 2 | ExUnit.start() 3 | -------------------------------------------------------------------------------- /apps/cf_rest_api/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :cf_rest_api, 4 | cors_origins: [] 5 | 6 | # Configures the endpoint 7 | config :cf_rest_api, CF.RestApi.Endpoint, 8 | url: [host: "localhost"], 9 | render_errors: [view: CF.RestApi.ErrorView, accepts: ~w(json), default_format: "json"], 10 | pubsub_server: CF.RestApi.PubSub, 11 | server: true 12 | 13 | # Configure Postgres pool size 14 | config :db, DB.Repo, pool_size: 10 15 | 16 | # Import environment specific config 17 | import_config "#{Mix.env()}.exs" 18 | -------------------------------------------------------------------------------- /apps/cf_rest_api/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | dev_secret = "8C6FsJwjV11d+1WPUIbkEH6gB/VavJrcXWoPLujgpclfxjkLkoNFSjVU9XfeNm6s" 4 | 5 | config :cf_rest_api, 6 | cors_origins: "*" 7 | 8 | # For development, we disable any cache and enable 9 | # debugging and code reloading. 10 | config :cf_rest_api, CF.RestApi.Endpoint, 11 | secret_key_base: dev_secret, 12 | debug_errors: false, 13 | code_reloader: false, 14 | check_origin: false, 15 | http: [port: 4000], 16 | force_ssl: false, 17 | https: [ 18 | port: 4001, 19 | otp_app: :cf_rest_api, 20 | keyfile: "priv/keys/privkey.pem", 21 | certfile: "priv/keys/fullchain.pem" 22 | ] 23 | 24 | # Set a higher stacktrace during development. Avoid configuring such 25 | # in production as building large stacktraces may be expensive. 26 | config :phoenix, :stacktrace_depth, 20 27 | config :phoenix, :json_library, Jason 28 | -------------------------------------------------------------------------------- /apps/cf_rest_api/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :cf_rest_api, CF.RestApi.Endpoint, 4 | force_ssl: false, 5 | check_origin: [], 6 | server: false 7 | -------------------------------------------------------------------------------- /apps/cf_rest_api/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :cf_rest_api, 4 | cors_origins: "*" 5 | 6 | # We don't run a server during test. If one is required, 7 | # you can enable the server option below. 8 | config :cf_rest_api, CF.RestApi.Endpoint, 9 | http: [port: 10001], 10 | server: false, 11 | force_ssl: false, 12 | secret_key_base: "psZ6n/fq0b444U533yKtve2R0rpjk/IxRGpuanNE92phSDy8/Z2I8lHaIugCMOY7" 13 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/application.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | import Supervisor.Spec 6 | 7 | # Define workers and child supervisors to be supervised 8 | children = [ 9 | # Start the PubSub system 10 | {Phoenix.PubSub, name: CF.RestApi.PubSub}, 11 | # Start the endpoint when the application starts 12 | supervisor(CF.RestApi.Endpoint, []), 13 | # Presence to track number of connected users to a channel 14 | supervisor(CF.RestApi.Presence, []) 15 | ] 16 | 17 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 18 | # for other strategies and supported options 19 | opts = [strategy: :one_for_one, name: CF.RestApi.Supervisor] 20 | Supervisor.start_link(children, opts) 21 | end 22 | 23 | @doc """ 24 | Get app's version from `mix.exs` 25 | """ 26 | def version() do 27 | case :application.get_key(:cf_rest_api, :vsn) do 28 | {:ok, version} -> to_string(version) 29 | _ -> "unknown" 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/channels/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.Presence do 2 | @moduledoc """ 3 | Provides presence tracking to channels and processes. 4 | 5 | See the [`Phoenix.Presence`](http://hexdocs.pm/phoenix/Phoenix.Presence.html) 6 | docs for more details. 7 | """ 8 | use Phoenix.Presence, otp_app: :cf_rest_api, pubsub_server: CF.RestApi.PubSub 9 | 10 | def fetch(_topic, entries) do 11 | %{ 12 | "viewers" => %{"count" => count_presences(entries, "viewers")}, 13 | "users" => %{"count" => count_presences(entries, "users")} 14 | } 15 | end 16 | 17 | defp count_presences(entries, key) do 18 | case get_in(entries, [key, :metas]) do 19 | nil -> 0 20 | metas -> length(metas) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/controllers/api_info_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.ApiInfoController do 2 | use CF.RestApi, :controller 3 | 4 | def get(conn, _params) do 5 | conn 6 | |> put_status(:ok) 7 | |> json(%{ 8 | app: "CF.RestApi", 9 | status: "✔", 10 | version: CF.Application.version(), 11 | db_version: DB.Application.version() 12 | }) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/controllers/fallback_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.FallbackController do 2 | @moduledoc """ 3 | Translates controller action results into valid `Plug.Conn` responses. 4 | 5 | See `Phoenix.Controller.action_fallback/1` for more details. 6 | """ 7 | use CF.RestApi, :controller 8 | 9 | alias CF.Accounts.UserPermissions.PermissionsError 10 | 11 | def call(conn, {:error, %Ecto.Changeset{} = changeset}) do 12 | conn 13 | |> put_status(:unprocessable_entity) 14 | |> render(CF.RestApi.ChangesetView, "error.json", changeset: changeset) 15 | end 16 | 17 | def call(conn, {:error, :not_found}) do 18 | conn 19 | |> put_status(404) 20 | |> render(CF.RestApi.ErrorView, "error.json", message: "not_found") 21 | end 22 | 23 | def call(conn, {:error, %PermissionsError{}}) do 24 | conn 25 | |> put_status(403) 26 | |> render(CF.RestApi.ErrorView, :"403") 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/controllers/moderation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.ModerationController do 2 | use CF.RestApi, :controller 3 | 4 | alias CF.Moderation 5 | alias CF.RestApi.ModerationEntryView 6 | 7 | action_fallback(CF.RestApi.FallbackController) 8 | 9 | # All methods here require authentication 10 | plug(Guardian.Plug.EnsureAuthenticated, handler: CF.RestApi.AuthController) 11 | 12 | def random(conn, _) do 13 | case Moderation.random!(Guardian.Plug.current_resource(conn)) do 14 | nil -> 15 | send_resp(conn, 204, "") 16 | 17 | entry -> 18 | render(conn, ModerationEntryView, :show, moderation_entry: entry) 19 | end 20 | end 21 | 22 | def post_feedback(conn, %{"action_id" => id, "value" => value, "reason" => reason}) 23 | when is_integer(id) and is_integer(value) and is_integer(reason) do 24 | user = Guardian.Plug.current_resource(conn) 25 | Moderation.feedback!(user, id, value, reason) 26 | send_resp(conn, 204, "") 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/controllers/speaker_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.SpeakerController do 2 | use CF.RestApi, :controller 3 | alias DB.Schema.Speaker 4 | 5 | action_fallback(CF.RestApi.FallbackController) 6 | 7 | def show(conn, %{"slug_or_id" => slug_or_id}) do 8 | case get_speaker(slug_or_id) do 9 | nil -> 10 | conn 11 | |> put_status(:not_found) 12 | |> render(CF.RestApi.ErrorView, "404.json") 13 | 14 | speaker -> 15 | render(conn, "show.json", speaker: speaker) 16 | end 17 | end 18 | 19 | defp get_speaker(slug_or_id) do 20 | case Integer.parse(slug_or_id) do 21 | # It's an ID (string has only number) 22 | {id, ""} -> 23 | Repo.get(Speaker, id) 24 | 25 | # It's a slug (string has at least one alpha character) 26 | _ -> 27 | slug_or_id = Slugger.slugify(slug_or_id) 28 | Repo.get_by(Speaker, slug: slug_or_id) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/controllers/statement_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.StatementController do 2 | use CF.RestApi, :controller 3 | 4 | alias DB.Schema.Statement 5 | alias DB.Schema.Comment 6 | 7 | def get(conn, %{"video_id" => video_id}) do 8 | video_id = DB.Type.VideoHashId.decode!(video_id) 9 | 10 | statements = 11 | Repo.all( 12 | from( 13 | statement in Statement, 14 | left_join: speaker in assoc(statement, :speaker), 15 | where: statement.video_id == ^video_id, 16 | where: statement.is_removed == false, 17 | order_by: statement.time, 18 | preload: [:speaker, comments: ^Comment.full(Comment, true)] 19 | ) 20 | ) 21 | 22 | render(conn, "index_full.json", statements: statements) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/cors.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.CORS do 2 | @spec check_origin(String.t()) :: boolean() 3 | def check_origin(origin) do 4 | case Application.get_env(:cf_rest_api, :cors_origins) do 5 | "*" -> 6 | true 7 | 8 | origins -> 9 | origin in origins 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :cf_rest_api 3 | 4 | socket("/socket", CF.RestApi.UserSocket, websocket: true, longpoll: false) 5 | 6 | if Application.get_env(:arc, :storage) == Arc.Storage.Local, 7 | do: plug(Plug.Static, at: "/resources", from: "./resources", gzip: false) 8 | 9 | plug(Plug.RequestId) 10 | plug(Plug.Logger) 11 | plug(CF.RestApi.SecurityHeaders) 12 | 13 | plug( 14 | Corsica, 15 | max_age: 3600, 16 | allow_headers: ~w(Accept Content-Type Authorization Origin), 17 | origins: {CF.RestApi.CORS, :check_origin} 18 | ) 19 | 20 | plug( 21 | Plug.Parsers, 22 | parsers: [:urlencoded, :multipart, :json], 23 | pass: ["*/*"], 24 | json_decoder: Poison 25 | ) 26 | 27 | plug(Plug.MethodOverride) 28 | plug(Plug.Head) 29 | plug(CF.RestApi.Router) 30 | end 31 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/security_headers.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.SecurityHeaders do 2 | @x_frame_options if Application.get_env(:cf, :env) == :dev, 3 | do: "SAMEORIGIN", 4 | else: "DENY" 5 | 6 | def init(params), do: params 7 | 8 | def call(conn, _params) do 9 | Plug.Conn.merge_resp_headers(conn, [ 10 | {"x-frame-options", @x_frame_options}, 11 | {"x-xss-protection", "1; mode=block"}, 12 | {"x-content-type-options", "nosniff"}, 13 | {"strict-transport-security", "max-age=31536000; includeSubDomains"} 14 | ]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/views/changeset_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.ChangesetView do 2 | use CF.RestApi, :view 3 | 4 | @doc """ 5 | Traverses and translates changeset errors. 6 | 7 | See `Ecto.Changeset.traverse_errors/2` and 8 | `CF.RestApi.ErrorHelpers.translate_error/1` for more details. 9 | """ 10 | def translate_errors(changeset) do 11 | Ecto.Changeset.traverse_errors(changeset, &translate_error/1) 12 | end 13 | 14 | def render("error.json", %{changeset: changeset}) do 15 | # When encoded, the changeset returns its errors 16 | # as a JSON object. So we just pass it forward. 17 | %{errors: translate_errors(changeset)} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/views/comment_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.CommentView do 2 | use CF.RestApi, :view 3 | 4 | alias CF.RestApi.{CommentView, UserView} 5 | 6 | def render("show.json", %{comment: comment}) do 7 | render_one(comment, CommentView, "comment.json") 8 | end 9 | 10 | def render("index.json", %{comments: comments}) do 11 | render_many(comments, CommentView, "comment.json") 12 | end 13 | 14 | def render("comment.json", %{comment: comment}) do 15 | user = 16 | if Ecto.assoc_loaded?(comment.user) and comment.user_id != nil, 17 | do: UserView.render("show_public.json", %{user: comment.user}), 18 | else: nil 19 | 20 | %{ 21 | id: comment.id, 22 | reply_to_id: comment.reply_to_id, 23 | user: user, 24 | statement_id: comment.statement_id, 25 | text: comment.text, 26 | is_reported: comment.is_reported, 27 | approve: comment.approve, 28 | inserted_at: comment.inserted_at, 29 | score: comment.score, 30 | source: render_source(comment.source) 31 | } 32 | end 33 | 34 | defp render_source(nil), do: nil 35 | 36 | defp render_source(source) do 37 | %{ 38 | url: source.url, 39 | title: source.title, 40 | language: source.language, 41 | site_name: source.site_name 42 | } 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | if error = form.errors[field] do 13 | content_tag(:span, translate_error(error), class: "help-block") 14 | end 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # Because error messages were defined within Ecto, we must 22 | # call the Gettext module passing our Gettext backend. We 23 | # also use the "errors" domain as translations are placed 24 | # in the errors.po file. 25 | # Ecto will pass the :count keyword if the error message is 26 | # meant to be pluralized. 27 | # On your own code and templates, depending on whether you 28 | # need the message to be pluralized or not, this could be 29 | # written simply as: 30 | # 31 | # dngettext "errors", "1 file", "%{count} files", count 32 | # dgettext "errors", "is invalid" 33 | # 34 | if count = opts[:count] do 35 | Gettext.dngettext(CF.Gettext, "errors", msg, msg, count, opts) 36 | else 37 | Gettext.dgettext(CF.Gettext, "errors", msg, opts) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.ErrorView do 2 | use CF.RestApi, :view 3 | 4 | require Logger 5 | alias CF.Accounts.UserPermissions.PermissionsError 6 | 7 | def render("show.json", %{message: message}) do 8 | render_one(message, CF.RestApi.ErrorView, "error.json") 9 | end 10 | 11 | def render("401.json", _) do 12 | %{error: "unauthorized"} 13 | end 14 | 15 | def render("403.json", %{reason: %PermissionsError{message: message}}) do 16 | %{error: message} 17 | end 18 | 19 | def render("403.json", _) do 20 | %{error: "forbidden"} 21 | end 22 | 23 | def render("404.json", _) do 24 | %{error: "not_found"} 25 | end 26 | 27 | def render("error.json", %{message: message}) do 28 | %{error: message} 29 | end 30 | 31 | def render("error.json", _) do 32 | %{error: "unexpected"} 33 | end 34 | 35 | def render(_, assigns) do 36 | IO.inspect(assigns) 37 | %{error: "unexpected"} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/views/flag_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.FlagView do 2 | use CF.RestApi, :view 3 | 4 | alias CF.RestApi.UserView 5 | 6 | def render("flag_without_action.json", %{flag: flag}) do 7 | %{ 8 | source_user: UserView.render("show.json", user: flag.source_user), 9 | reason: flag.reason 10 | } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/views/moderation_entry_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.ModerationEntryView do 2 | use CF.RestApi, :view 3 | 4 | alias CF.Moderation.ModerationEntry 5 | alias CF.RestApi.UserActionView 6 | alias CF.RestApi.FlagView 7 | 8 | def render("show.json", %{moderation_entry: %ModerationEntry{action: action, flags: flags}}) do 9 | %{ 10 | action: render(UserActionView, "user_action.json", %{user_action: action}), 11 | flags: render_many(flags, FlagView, "flag_without_action.json") 12 | } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/views/speaker_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.SpeakerView do 2 | use CF.RestApi, :view 3 | 4 | def render("show.json", %{speaker: speaker}) do 5 | render_one(speaker, CF.RestApi.SpeakerView, "speaker.json") 6 | end 7 | 8 | def render("speaker.json", %{speaker: speaker}) do 9 | %{ 10 | id: speaker.id, 11 | slug: speaker.slug, 12 | full_name: speaker.full_name, 13 | title: speaker.title, 14 | picture: DB.Type.SpeakerPicture.full_url(speaker, :thumb), 15 | country: speaker.country, 16 | wikidata_item_id: speaker.wikidata_item_id 17 | } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/views/statement_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.StatementView do 2 | use CF.RestApi, :view 3 | 4 | def render("index.json", %{statements: statements}) do 5 | render_many(statements, CF.RestApi.StatementView, "statement.json") 6 | end 7 | 8 | def render("index_full.json", %{statements: statements}) do 9 | render_many(statements, CF.RestApi.StatementView, "statement_full.json") 10 | end 11 | 12 | def render("show.json", %{statement: statement}) do 13 | render_one(statement, CF.RestApi.StatementView, "statement.json") 14 | end 15 | 16 | def render("statement.json", %{statement: statement}) do 17 | %{ 18 | id: statement.id, 19 | text: statement.text, 20 | time: statement.time, 21 | speaker_id: statement.speaker_id, 22 | is_draft: statement.is_draft 23 | } 24 | end 25 | 26 | def render("statement_full.json", %{statement: statement}) do 27 | %{ 28 | id: statement.id, 29 | text: statement.text, 30 | time: statement.time, 31 | is_draft: statement.is_draft, 32 | speaker: render_one(statement.speaker, CF.RestApi.SpeakerView, "speaker.json"), 33 | comments: render_many(statement.comments, CF.RestApi.CommentView, "comment.json") 34 | } 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/views/user_action_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.UserActionView do 2 | use CF.RestApi, :view 3 | 4 | alias DB.Type.VideoHashId 5 | alias CF.RestApi.UserView 6 | alias CF.RestApi.UserActionView 7 | 8 | def render("index.json", %{users_actions: actions}) do 9 | render_many(actions, UserActionView, "user_action.json") 10 | end 11 | 12 | def render("show.json", %{user_action: action}) do 13 | render_one(action, UserActionView, "user_action.json") 14 | end 15 | 16 | def render("user_action.json", %{user_action: action}) do 17 | %{ 18 | id: action.id, 19 | user: UserView.render("show_public.json", %{user: action.user}), 20 | type: action.type, 21 | entity: action.entity, 22 | changes: action.changes, 23 | time: action.inserted_at, 24 | videoId: action.video_id, 25 | videoHashId: action.video_id && VideoHashId.encode(action.video_id), 26 | speakerId: action.speaker_id, 27 | statementId: action.statement_id, 28 | commentId: action.comment_id 29 | } 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/views/user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.UserView do 2 | use CF.RestApi, :view 3 | 4 | alias CF.RestApi.UserView 5 | 6 | def render("index_public.json", %{users: users}) do 7 | render_many(users, UserView, "public_user.json") 8 | end 9 | 10 | def render("show.json", %{user: user}) do 11 | render_one(user, UserView, "user.json") 12 | end 13 | 14 | def render("show_public.json", %{user: user}) do 15 | render_one(user, UserView, "public_user.json") 16 | end 17 | 18 | def render("public_user.json", %{user: user}) do 19 | %{ 20 | id: user.id, 21 | name: user.name, 22 | username: user.username, 23 | reputation: user.reputation, 24 | picture_url: DB.Type.UserPicture.full_url(user, :thumb), 25 | mini_picture_url: DB.Type.UserPicture.full_url(user, :mini_thumb), 26 | registered_at: user.inserted_at, 27 | achievements: user.achievements, 28 | speaker_id: user.speaker_id 29 | } 30 | end 31 | 32 | def render("user.json", %{user: user}) do 33 | %{ 34 | id: user.id, 35 | email: user.email, 36 | fb_user_id: user.fb_user_id, 37 | name: user.name, 38 | username: user.username, 39 | reputation: user.reputation, 40 | picture_url: DB.Type.UserPicture.full_url(user, :thumb), 41 | mini_picture_url: DB.Type.UserPicture.full_url(user, :mini_thumb), 42 | locale: user.locale, 43 | registered_at: user.inserted_at, 44 | achievements: user.achievements, 45 | is_publisher: user.is_publisher, 46 | speaker_id: user.speaker_id 47 | } 48 | end 49 | 50 | def render("user_with_token.json", %{user: user, token: token}) do 51 | %{ 52 | user: UserView.render("show.json", %{user: user}), 53 | token: token 54 | } 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /apps/cf_rest_api/lib/views/vote_view.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.VoteView do 2 | use CF.RestApi, :view 3 | 4 | def render("my_votes.json", %{votes: votes}) do 5 | render_many(votes, CF.RestApi.VoteView, "my_vote.json") 6 | end 7 | 8 | def render("my_vote.json", %{vote: vote}) do 9 | %{ 10 | comment_id: vote.comment_id, 11 | value: vote.value 12 | } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/cf_rest_api/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :cf_rest_api, 7 | version: "1.1.0", 8 | build_path: "../../_build", 9 | compilers: [:phoenix, :gettext] ++ Mix.compilers(), 10 | config_path: "../../config/config.exs", 11 | deps_path: "../../deps", 12 | lockfile: "../../mix.lock", 13 | elixir: "~> 1.6", 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | build_embedded: Mix.env() == :prod, 16 | start_permanent: Mix.env() == :prod, 17 | aliases: aliases(), 18 | deps: deps(), 19 | test_coverage: [tool: ExCoveralls] 20 | ] 21 | end 22 | 23 | def application do 24 | [ 25 | mod: {CF.RestApi.Application, []}, 26 | extra_applications: [:logger] 27 | ] 28 | end 29 | 30 | # Specifies which paths to compile per environment. 31 | defp elixirc_paths(:test), do: ["lib", "test/support"] 32 | defp elixirc_paths(:dev), do: ["lib"] 33 | defp elixirc_paths(_), do: ["lib"] 34 | 35 | # Dependencies 36 | defp deps do 37 | [ 38 | {:corsica, "~> 2.1"}, 39 | {:cowboy, "~> 2.0"}, 40 | {:gettext, "~> 0.13.1"}, 41 | {:kaur, "~> 1.1"}, 42 | {:phoenix, "~> 1.5.14", override: true}, 43 | {:phoenix_html, "~> 2.14.3"}, 44 | {:phoenix_pubsub, "~> 2.0"}, 45 | {:jason, "~> 1.4"}, 46 | {:poison, "~> 3.1"}, 47 | {:plug_cowboy, "~> 2.7.2"}, 48 | 49 | # ---- Internal ---- 50 | {:cf, in_umbrella: true}, 51 | {:db, in_umbrella: true} 52 | ] 53 | end 54 | 55 | defp aliases do 56 | [] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /apps/cf_rest_api/priv/keys/fullchain.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC+zCCAeOgAwIBAgIJAP5GvLkEg+P4MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV 3 | BAMMCWxvY2FsaG9zdDAeFw0xNzA1MTIwNjEwMzJaFw0yNzA1MTAwNjEwMzJaMBQx 4 | EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 5 | ggEBAL6uymshViiULeyiTXOUYMppzagH4W+bCA75rYyYFUXJJbvvaE3QVvpAyTIz 6 | OXX1rNCvoxxUknV0d3y3JyqN8fU+Oi7Q5rkliZu9tN6xthJrXgRnm12HomNRlC2i 7 | rIXhLTJVcPGM7kbiPZOVKCkN0MF+9EPEvnq9xvtDgzc3ZBKnvUglKrjdwUCZJMTQ 8 | fggtN1PLbNpKGLx3cWBb3SRuzwtVLi34ixMgwnyXzMreH5U1IK7K/hra9vhB1N7j 9 | npx4vlNVagzFZuybuf4Aozne3yioU1z/8sAnHb83DoGs+JnRAnGSZyzg/eGIqdCd 10 | GMTsWH3CHnetE5pNGcwJuL4PFE8CAwEAAaNQME4wHQYDVR0OBBYEFK7VtLoCcnIN 11 | /0YYwfD9iTfTkm3OMB8GA1UdIwQYMBaAFK7VtLoCcnIN/0YYwfD9iTfTkm3OMAwG 12 | A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAH/mDv1G3XKdfbzXiPZNiqVa 13 | N17yaffJLFA5Kj33RVwChhutZi0CZYYAuY19BkW9j4+poz2xnRhcvkJqgBdL0mvu 14 | 0NSnCXI/S8+Im97h2aUGgbUQvOxDaeLpLFWt7zZAT9y5zBB4PjwMJY/pp5KAtcNc 15 | GGyfSsr8jhlcriBUqrYUrgX8AvDV8qM2Y++nJ8igmTVjDgWG8hAuipHRmH8r6PBK 16 | 1XeTf4+Q3WB99kEcTglfPgm68KGavRimmuuUejShmulzbNiT+OEMO1KHPIfOc/z9 17 | 4AobuD/k0WNGqOx9Y27A7t7ldKxB7ByXvEwetxyIc3Q9Pv0hrPVGs5ceR1uCk4s= 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /apps/cf_rest_api/priv/keys/privkey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAvq7KayFWKJQt7KJNc5RgymnNqAfhb5sIDvmtjJgVRcklu+9o 3 | TdBW+kDJMjM5dfWs0K+jHFSSdXR3fLcnKo3x9T46LtDmuSWJm7203rG2EmteBGeb 4 | XYeiY1GULaKsheEtMlVw8YzuRuI9k5UoKQ3QwX70Q8S+er3G+0ODNzdkEqe9SCUq 5 | uN3BQJkkxNB+CC03U8ts2koYvHdxYFvdJG7PC1UuLfiLEyDCfJfMyt4flTUgrsr+ 6 | Gtr2+EHU3uOenHi+U1VqDMVm7Ju5/gCjOd7fKKhTXP/ywCcdvzcOgaz4mdECcZJn 7 | LOD94Yip0J0YxOxYfcIed60Tmk0ZzAm4vg8UTwIDAQABAoIBACNDf/u/9ocaoEOa 8 | 4Gf3kM7eMkJY8sAJE7xxQD84APce8/OFmuyJEwzE3nCCOKYwAP22/ZtHqK5AE7jk 9 | xkGAbrbEA06VI5Yp8wDyXHiytNFDOefmoTzy0H09oQGvi+hWdF1Sn8iMH6TMQkcA 10 | 1qSBAZJHQDUoNXHNlvbwzVtwyvkH6gXOy1xzPJLWT/9z0TYZN25QJ7hjxVygrqxk 11 | NdDL0J77lBncuMMwTUnUrElJNPEJMaJnINRFs/EpOUZbvFa3sczOfG0JGoJA8Rra 12 | 6jqh4UE5g/ruYR499KaN5zL5Y0w8JeLyglwhGARnEvGl88gpNqBs7KHlEJBhX0jv 13 | 7sEn+kECgYEA+lJdZkC6rNlRiFurJG06AVOL3hej7x1Int/htdVze40OLKlf5Mii 14 | E0Bqp6meRpk8B0IMkhrN8/LN00YV6ktWhRFUBNN0DCCxRVXk1a9fJrvSoDGkVg2Q 15 | Xnd6RjPyW7ub8L5QoUYSuzfcEyULdeWAi7eRDJa0Efaj2AWNYQqQfVsCgYEAwwIZ 16 | MIvEmZkRI/o3aqGoaAcvYJ4+y9TKNhdQCBxsV31YAejzgPLxPaM1+LtoxwZ9Ls8S 17 | gQzskE9bHXylxNMpJApn8DeuIfMLrFa+7VxIC38Gl97C8MAgZvqhSWIpqsY5avBr 18 | D29vYHSidrfioWRM5xmXXO3ys8t/shigANLzcx0CgYEA5LzK2Bsh+byDgmSxmJGu 19 | xXOAhat4g5FwwKy35Z5s7mNQpoMHO1oSsCDW1Opr1PtFHSS/s+qGc/pVFlAeyn+Z 20 | SfMxoU9P5Z0iH8eDWbfs7MoIh5WVI4U1fP0UYH4rYqOmtXBS4WvUxfsfQOdC97KF 21 | qiZNhwFW/mswAL/iFuC+c60CgYBcw/rHpTV4+9+zhawnBY/fLMvU4nJs9GTdJmnj 22 | 8eF4HSBoiDCN/wPTlnhuQnitdODIC6l5ynQekiF9/XW+E9VWV7zqARLNA5lh+kIJ 23 | GAUNsven90g0zrCbTE69Yf0ASBu4S3YieZg6AkHmx8L/k38h0IK4qljyPrQYPK6g 24 | tbkp4QKBgQCKYtF5GvQs2ZCfjYepYuTYzeSOrpy8jnbytlU//KSrzc+htvAuXK70 25 | pwBRilKRlt7h8g1RS+OJi/H8LoOd+sP+lhaEWtHkGGnJTPXfqQ0ETNAeXmUJNP0N 26 | /Auxa38OOGm7owhiKiAIzlE9EwN+7MHcNoaVoBrqT5YtlEPrzAO+/A== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /apps/cf_rest_api/test/channels/video_debate_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.VideoDebateChannelTest do 2 | use CF.RestApi.ChannelCase 3 | 4 | alias CF.RestApi.VideoDebateChannel 5 | 6 | test "Get video info when connecting" do 7 | video = insert(:video) |> with_video_hash_id() 8 | 9 | {:ok, returned_video, socket} = 10 | subscribe_and_join( 11 | socket(CF.RestApi.UserSocket, "", %{user_id: nil}), 12 | VideoDebateChannel, 13 | "video_debate:#{video.hash_id}" 14 | ) 15 | 16 | assert returned_video.id == video.id 17 | assert returned_video.hash_id == video.hash_id 18 | assert returned_video.url == video.url 19 | leave(socket) 20 | end 21 | 22 | test "New speakers get broadcasted" do 23 | # Init 24 | video = insert(:video) |> with_video_hash_id() 25 | topic = "video_debate:#{video.hash_id}" 26 | 27 | {:ok, _, authed_socket} = 28 | subscribe_and_join( 29 | socket(CF.RestApi.UserSocket, "", %{user_id: insert(:user, %{reputation: 5000}).id}), 30 | VideoDebateChannel, 31 | topic 32 | ) 33 | 34 | # Test 35 | @endpoint.subscribe("video_debate:#{video.hash_id}") 36 | speaker = %{full_name: "Titi Toto"} 37 | ref = push(authed_socket, "new_speaker", speaker) 38 | assert_reply(ref, :ok, _) 39 | assert_broadcast("speaker_added", %{full_name: "Titi Toto"}) 40 | 41 | # Cleanup 42 | leave(authed_socket) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /apps/cf_rest_api/test/controllers/api_info_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.ApiInfoControllerTest do 2 | use CF.RestApi.ConnCase 3 | 4 | test "GET / returns API info", %{conn: conn} do 5 | response = 6 | conn 7 | |> get("/") 8 | |> json_response(200) 9 | 10 | assert is_binary(response["version"]) 11 | assert response["status"] == "✔" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /apps/cf_rest_api/test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.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 and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | import Mock 18 | 19 | # Mock all calls to Algolia 20 | setup_with_mocks([ 21 | {Algoliax.Client, [], [request: fn _ -> nil end]} 22 | ]) do 23 | :ok 24 | end 25 | 26 | using do 27 | quote do 28 | # Import conveniences for testing with channels 29 | use Phoenix.ChannelTest 30 | import DB.Factory 31 | 32 | # The default endpoint for testing 33 | @endpoint CF.RestApi.Endpoint 34 | end 35 | end 36 | 37 | setup tags do 38 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(DB.Repo) 39 | 40 | unless tags[:async] do 41 | Ecto.Adapters.SQL.Sandbox.mode(DB.Repo, {:shared, self()}) 42 | end 43 | 44 | :ok 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /apps/cf_rest_api/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.RestApi.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 | Finally, if the test case interacts with the database, 7 | it cannot be async. For this reason, every test runs 8 | inside a transaction which is reset at the beginning 9 | of the test unless the test case is marked as async. 10 | """ 11 | 12 | use ExUnit.CaseTemplate 13 | 14 | using do 15 | quote do 16 | # Import conveniences for testing with connections 17 | import Plug.Conn 18 | import Phoenix.ConnTest 19 | import CF.RestApi.Router.Helpers 20 | 21 | # The default endpoint for testing 22 | @endpoint CF.RestApi.Endpoint 23 | 24 | alias CF.Authenticator.GuardianImpl 25 | alias DB.Repo 26 | 27 | def build_authenticated_conn(user) do 28 | {:ok, token, _} = GuardianImpl.encode_and_sign(user) 29 | 30 | Phoenix.ConnTest.build_conn() 31 | |> Plug.Conn.put_req_header("authorization", "Bearer #{token}") 32 | end 33 | end 34 | end 35 | 36 | setup tags do 37 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(DB.Repo) 38 | 39 | unless tags[:async] do 40 | Ecto.Adapters.SQL.Sandbox.mode(DB.Repo, {:shared, self()}) 41 | end 42 | 43 | {:ok, conn: Phoenix.ConnTest.build_conn()} 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /apps/cf_rest_api/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _} = Application.ensure_all_started(:ex_machina) 2 | ExUnit.start() 3 | -------------------------------------------------------------------------------- /apps/cf_rest_api/test/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.ErrorViewTest do 2 | use CF.RestApi.ConnCase 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | alias CF.RestApi.ErrorView 7 | alias CF.Accounts.UserPermissions.PermissionsError 8 | 9 | test "renders 401.json" do 10 | assert render_to_string(ErrorView, "401.json", []) =~ "unauthorized" 11 | end 12 | 13 | test "renders 403.json" do 14 | assert render_to_string(ErrorView, "403.json", []) =~ "forbidden" 15 | end 16 | 17 | test "renders 403.json with PermissionsError" do 18 | assert render_to_string(ErrorView, "403.json", %{reason: %PermissionsError{message: "xxx"}}) =~ 19 | "xxx" 20 | end 21 | 22 | test "renders 404.json" do 23 | assert render_to_string(ErrorView, "404.json", []) =~ "not_found" 24 | end 25 | 26 | test "render any other" do 27 | assert render_to_string(ErrorView, "999.json", []) =~ "unexpected" 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /apps/cf_reverse_proxy/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configures the endpoint 4 | config :cf_reverse_proxy, port: 5000 5 | 6 | # Import environment specific config 7 | import_config "#{Mix.env()}.exs" 8 | -------------------------------------------------------------------------------- /apps/cf_reverse_proxy/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /apps/cf_reverse_proxy/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :cf_reverse_proxy, port: 80 4 | -------------------------------------------------------------------------------- /apps/cf_reverse_proxy/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /apps/cf_reverse_proxy/lib/application.ex: -------------------------------------------------------------------------------- 1 | defmodule CF.ReverseProxy.Application do 2 | use Application 3 | 4 | require Logger 5 | 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | port = Application.get_env(:cf_reverse_proxy, :port) 9 | 10 | cowboy = 11 | {Plug.Cowboy, 12 | scheme: :http, 13 | plug: CF.ReverseProxy.Plug, 14 | port: port, 15 | dispatch: [ 16 | {:_, 17 | [ 18 | {"/socket/websocket", Phoenix.Endpoint.Cowboy2Handler, {CF.RestApi.Endpoint, []}}, 19 | {"/socket/longpoll", Phoenix.Endpoint.Cowboy2Handler, {CF.RestApi.Endpoint, []}}, 20 | {:_, Plug.Cowboy.Handler, {CF.ReverseProxy.Plug, []}} 21 | ]} 22 | ]} 23 | 24 | Logger.info("Running CF.ReverseProxy with cowboy on port #{port}") 25 | opts = [strategy: :one_for_one, name: CF.ReverseProxy.Supervisor] 26 | Supervisor.start_link([cowboy], opts) 27 | end 28 | 29 | def config_change(_changed, _new, _removed) do 30 | :ok 31 | end 32 | 33 | def version() do 34 | case :application.get_key(:cf_reverse_proxy, :vsn) do 35 | {:ok, version} -> to_string(version) 36 | _ -> "unknown" 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/cf_reverse_proxy/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.ReverseProxy.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :cf_reverse_proxy, 7 | version: "1.0.0", 8 | build_path: "../../_build", 9 | compilers: [:phoenix] ++ Mix.compilers(), 10 | config_path: "../../config/config.exs", 11 | deps_path: "../../deps", 12 | lockfile: "../../mix.lock", 13 | elixir: "~> 1.6", 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | build_embedded: Mix.env() == :prod, 16 | start_permanent: Mix.env() == :prod, 17 | aliases: aliases(), 18 | deps: deps() 19 | ] 20 | end 21 | 22 | def application do 23 | [ 24 | mod: {CF.ReverseProxy.Application, []}, 25 | extra_applications: [:logger] 26 | ] 27 | end 28 | 29 | # Specifies which paths to compile per environment. 30 | defp elixirc_paths(:test), do: ["lib", "test/support"] 31 | defp elixirc_paths(:dev), do: ["lib"] 32 | defp elixirc_paths(_), do: ["lib"] 33 | 34 | # Dependencies 35 | defp deps do 36 | [ 37 | {:cf_rest_api, in_umbrella: true}, 38 | {:cf_graphql, in_umbrella: true}, 39 | {:cf_atom_feed, in_umbrella: true}, 40 | {:phoenix, "~> 1.5.14"}, 41 | {:jason, "~> 1.4"}, 42 | {:cowboy, "~> 2.0"}, 43 | {:corsica, "~> 2.1"} 44 | ] 45 | end 46 | 47 | defp aliases do 48 | [] 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /apps/db/README.md: -------------------------------------------------------------------------------- 1 | # [CaptainFact App] Database 2 | 3 | This is CaptainFact database schemas and types. 4 | 5 | ## Secrets 6 | 7 | Following secrets must be configured in production: 8 | 9 | - db_hostname 10 | - db_username 11 | - db_password 12 | - db_name 13 | -------------------------------------------------------------------------------- /apps/db/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # General application configuration 4 | config :db, 5 | env: Mix.env(), 6 | ecto_repos: [DB.Repo] 7 | 8 | # Database: use postgres 9 | config :db, DB.Repo, 10 | adapter: Ecto.Adapters.Postgres, 11 | pool_size: 3, 12 | loggers: [ 13 | {Ecto.LogEntry, :log, []} 14 | ] 15 | 16 | # Import environment specific config 17 | import_config "#{Mix.env()}.exs" 18 | -------------------------------------------------------------------------------- /apps/db/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :db, DB.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "captain_fact_dev", 8 | hostname: "localhost" 9 | 10 | # Configure file upload 11 | config :arc, storage: Arc.Storage.Local, asset_host: "http://localhost:4000" 12 | -------------------------------------------------------------------------------- /apps/db/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Do not print debug messages in production 4 | config :logger, level: :info 5 | -------------------------------------------------------------------------------- /apps/db/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Print only warnings and errors during test 4 | config :logger, level: :warn 5 | 6 | # Configure file upload 7 | config :arc, storage: Arc.Storage.Local 8 | 9 | # Configure your database 10 | config :db, DB.Repo, 11 | hostname: 12 | if( 13 | is_nil(System.get_env("CF_DB_HOSTNAME")), 14 | do: "localhost", 15 | else: System.get_env("CF_DB_HOSTNAME") 16 | ), 17 | username: "postgres", 18 | password: "postgres", 19 | database: "captain_fact_test", 20 | pool: Ecto.Adapters.SQL.Sandbox 21 | -------------------------------------------------------------------------------- /apps/db/lib/db/application.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | require Logger 6 | 7 | def start(_type, _args) do 8 | import Supervisor.Spec, warn: false 9 | 10 | # Define workers and child supervisors to be supervised 11 | children = [ 12 | # Starts a worker by calling: DB.Worker.start_link(arg1, arg2, arg3) 13 | # worker(DB.Worker, [arg1, arg2, arg3]), 14 | supervisor(DB.Repo, []) 15 | ] 16 | 17 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 18 | # for other strategies and supported options 19 | opts = [strategy: :one_for_one, name: DB.Supervisor] 20 | link = Supervisor.start_link(children, opts) 21 | migrate_db() 22 | link 23 | end 24 | 25 | defp migrate_db do 26 | Logger.info("Running migrations...") 27 | Ecto.Migrator.run(DB.Repo, migrations_path(), :up, all: true) 28 | Logger.info("Migrated!") 29 | end 30 | 31 | defp migrations_path do 32 | Path.join([:code.priv_dir(:db), "repo", "migrations"]) 33 | end 34 | 35 | def version() do 36 | case :application.get_key(:db, :vsn) do 37 | {:ok, version} -> to_string(version) 38 | _ -> "unknown" 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /apps/db/lib/db/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo do 2 | use Ecto.Repo, otp_app: :db, adapter: Ecto.Adapters.Postgres 3 | use Scrivener, page_size: 10 4 | end 5 | -------------------------------------------------------------------------------- /apps/db/lib/db/statistics.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Statistics do 2 | import Ecto.Query 3 | 4 | alias DB.Schema.User 5 | alias DB.Repo 6 | 7 | @doc """ 8 | A shortcut to returns the amount of user in the database 9 | """ 10 | @spec all_totals() :: %{ 11 | users: integer, 12 | comments: integer, 13 | statements: integer, 14 | sources: integer 15 | } 16 | def all_totals() do 17 | Repo 18 | |> Ecto.Adapters.SQL.query!(""" 19 | SELECT (select count(id) FROM users) as users, 20 | (select count(id) FROM comments) as comments, 21 | (select count(id) FROM statements) as statements, 22 | (select count(id) FROM sources) as sources 23 | """) 24 | |> (fn %Postgrex.Result{rows: [[users, comments, statements, sources]]} -> 25 | %{users: users, comments: comments, statements: statements, sources: sources} 26 | end).() 27 | end 28 | 29 | @doc """ 30 | returns the 20 most active users 31 | """ 32 | @spec leaderboard() :: list(%User{}) 33 | def leaderboard do 34 | from( 35 | u in User, 36 | order_by: [desc: u.reputation], 37 | limit: 20 38 | ) 39 | |> Repo.all() 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /apps/db/lib/db_schema/flag.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.Flag do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias DB.Schema.{User, UserAction} 6 | 7 | schema "flags" do 8 | # Source user 9 | belongs_to(:source_user, User) 10 | belongs_to(:action, UserAction) 11 | field(:reason, DB.Type.FlagReason) 12 | timestamps() 13 | end 14 | 15 | @required_fields ~w(source_user_id action_id reason)a 16 | 17 | @doc """ 18 | Builds a changeset based on an `UserAction` 19 | """ 20 | def changeset(struct, params) do 21 | struct 22 | |> cast(params, [:action_id, :reason]) 23 | |> validate_required(@required_fields) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/db/lib/db_schema/invitation_request.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.InvitationRequest do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias DB.Schema.User 6 | alias DB.Schema.InvitationRequest 7 | 8 | schema "invitation_requests" do 9 | field(:email, :string) 10 | field(:invitation_sent, :boolean, default: false) 11 | field(:token, :string) 12 | field(:locale, :string) 13 | 14 | belongs_to(:invited_by, User) 15 | 16 | timestamps() 17 | end 18 | 19 | @doc false 20 | def changeset(invitation_request = %InvitationRequest{}, attrs) do 21 | invitation_request 22 | |> cast(attrs, [:email, :invited_by_id, :locale]) 23 | |> validate_required([:email]) 24 | |> User.validate_email() 25 | |> User.validate_locale() 26 | |> unique_constraint(:email) 27 | end 28 | 29 | def changeset_token(request, token) do 30 | change(request, token: token) 31 | end 32 | 33 | def changeset_sent(request, is_sent) do 34 | change(request, invitation_sent: is_sent) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /apps/db/lib/db_schema/moderation_user_feedback.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.ModerationUserFeedback do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias DB.Schema.ModerationUserFeedback 5 | 6 | schema "moderation_users_feedbacks" do 7 | field(:value, :integer) 8 | field(:user_id, :id) 9 | field(:action_id, :id) 10 | field(:flag_reason, DB.Type.FlagReason) 11 | 12 | timestamps() 13 | end 14 | 15 | @doc false 16 | def changeset(user_feedback = %ModerationUserFeedback{}, attrs) do 17 | user_feedback 18 | |> cast(attrs, [:value, :flag_reason]) 19 | |> validate_required([:value, :action_id, :user_id, :flag_reason]) 20 | |> validate_number(:value, greater_than_or_equal_to: -1, less_than_or_equal_to: 1) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/db/lib/db_schema/notification.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.Notification do 2 | @moduledoc """ 3 | Represent a user's Notification. 4 | """ 5 | 6 | use Ecto.Schema 7 | import Ecto.Changeset 8 | 9 | schema "notifications" do 10 | belongs_to(:user, DB.Schema.User) 11 | belongs_to(:action, DB.Schema.UserAction) 12 | 13 | field(:type, DB.Type.NotificationType) 14 | field(:seen_at, :utc_datetime, default: nil) 15 | 16 | timestamps() 17 | end 18 | 19 | @fields [:user_id, :action_id, :type, :seen_at] 20 | @required_fields [:user_id, :action_id, :type] 21 | 22 | @doc """ 23 | Builds a changeset based on the `struct` and `params`. 24 | """ 25 | def changeset(struct, params \\ %{}) do 26 | struct 27 | |> cast(params, @fields) 28 | |> validate_required(@required_fields) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /apps/db/lib/db_schema/reset_password_request.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.ResetPasswordRequest do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key {:token, :string, []} 6 | schema "accounts_reset_password_requests" do 7 | field(:source_ip, :string) 8 | belongs_to(:user, DB.Schema.User) 9 | 10 | timestamps(updated_at: false) 11 | end 12 | 13 | @token_length 128 14 | 15 | def changeset(model, attrs) do 16 | model 17 | |> cast(attrs, [:source_ip, :user_id]) 18 | |> change(token: DB.Utils.TokenGenerator.generate(@token_length)) 19 | |> validate_required([:source_ip, :user_id, :token]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/db/lib/db_schema/users_actions_report.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.UsersActionsReport do 2 | @moduledoc """ 3 | A report generated by a job to provide status and statistics. 4 | """ 5 | 6 | use Ecto.Schema 7 | import Ecto.Changeset 8 | alias DB.Schema.UsersActionsReport 9 | 10 | schema "users_actions_reports" do 11 | field(:analyser_id, :integer) 12 | field(:last_action_id, :integer) 13 | field(:status, :integer) 14 | 15 | # Various stats 16 | field(:nb_actions, :integer) 17 | field(:nb_entries_updated, :integer) 18 | field(:run_duration, :integer) 19 | 20 | timestamps() 21 | end 22 | 23 | @required [:analyser_id, :status, :last_action_id, :nb_actions] 24 | 25 | @doc false 26 | def changeset(report = %UsersActionsReport{}, attrs) do 27 | report 28 | |> cast(attrs, @required) 29 | |> validate_required(@required) 30 | end 31 | 32 | def analyser_id(:reputation), do: 1 33 | def analyser_id(:flags), do: 2 34 | def analyser_id(:achievements), do: 3 35 | def analyser_id(:votes), do: 4 36 | def analyser_id(:create_notifications), do: 5 37 | def analyser_id(:download_captions), do: 6 38 | 39 | def status(:pending), do: 1 40 | def status(:running), do: 2 41 | def status(:success), do: 3 42 | def status(:failed), do: 4 43 | end 44 | -------------------------------------------------------------------------------- /apps/db/lib/db_schema/video_caption.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.VideoCaption do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key false 6 | schema "videos_captions" do 7 | belongs_to(:video, DB.Schema.Video, primary_key: true) 8 | field(:raw, :string) 9 | field(:parsed, {:array, :map}) 10 | field(:format, :string) 11 | 12 | timestamps() 13 | end 14 | 15 | @required_fields ~w(video_id raw parsed format)a 16 | 17 | @doc """ 18 | Builds a changeset based on the `struct` and `params`. 19 | """ 20 | def changeset(struct, params \\ %{}) do 21 | struct 22 | |> cast(params, @required_fields) 23 | |> validate_required(@required_fields) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/db/lib/db_schema/video_speaker.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.VideoSpeaker do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key false 6 | schema "videos_speakers" do 7 | belongs_to(:video, DB.Schema.Video, primary_key: true) 8 | belongs_to(:speaker, DB.Schema.Speaker, primary_key: true) 9 | 10 | timestamps() 11 | end 12 | 13 | @required_fields ~w(video_id speaker_id)a 14 | 15 | @doc """ 16 | Builds a changeset based on the `struct` and `params`. 17 | """ 18 | def changeset(struct, params \\ %{}) do 19 | struct 20 | |> cast(params, @required_fields) 21 | |> validate_required(@required_fields) 22 | |> unique_constraint(:video, name: :videos_speakers_pkey) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/db/lib/db_type/achievement.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Type.Achievement do 2 | @moduledoc """ 3 | Translate a user achievement from atom to integer. 4 | TODO: Migrate this to a real Ecto.Type using Ecto.Enum 5 | """ 6 | 7 | @doc """ 8 | Get an achievement id from an easy to use / remember atom. 9 | You can add more at the end, but you CANNOT change 10 | existing identifiers, it would break existing achievements 11 | """ 12 | # Default badge 13 | def get(:welcome), do: 1 14 | # Validate email or link third party account 15 | def get(:not_a_robot), do: 2 16 | # Visit help pages 17 | def get(:help), do: 3 18 | # Install extension 19 | def get(:bulletproof), do: 4 20 | # ??? 21 | def get(:you_are_fake_news), do: 5 22 | # Link third party account 23 | def get(:social_networks), do: 6 24 | # Ambassador 25 | def get(:ambassador), do: 7 26 | # Made a bug report 27 | def get(:ghostbuster), do: 8 28 | # Leaderboard 29 | def get(:famous), do: 9 30 | # Made a contribution on the graphics 31 | def get(:artist), do: 10 32 | # Made a suggestion that gets approved 33 | def get(:good_vibes), do: 11 34 | end 35 | -------------------------------------------------------------------------------- /apps/db/lib/db_type/entity.ex: -------------------------------------------------------------------------------- 1 | import EctoEnum 2 | 3 | defenum( 4 | DB.Type.Entity, 5 | video: 1, 6 | speaker: 2, 7 | statement: 3, 8 | comment: 4, 9 | fact: 5, 10 | user_action: 6, 11 | user: 7, 12 | video_caption: 8 13 | ) 14 | -------------------------------------------------------------------------------- /apps/db/lib/db_type/notification_type.ex: -------------------------------------------------------------------------------- 1 | import EctoEnum 2 | 3 | defenum( 4 | DB.Type.NotificationType, 5 | :notification_type, 6 | [ 7 | # Default notification type, for when type is unknown 8 | :default, 9 | # Notifications generated by `create_notifications` job 10 | :reply_to_comment, 11 | :new_comment, 12 | :new_statement, 13 | :new_speaker, 14 | :updated_statement, 15 | :updated_video, 16 | :updated_speaker, 17 | :removed_statement, 18 | :removed_speaker, 19 | # Notifications below are meant to be dispatched manualy 20 | :new_achievement, 21 | :email_confirmed 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /apps/db/lib/db_type/speaker_picture.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Type.SpeakerPicture do 2 | @moduledoc """ 3 | Speaker picture. Map the Ecto type to an URL using ARC 4 | """ 5 | 6 | use Arc.Definition 7 | use Arc.Ecto.Definition 8 | 9 | @versions [:thumb] 10 | @extension_whitelist ~w(.jpg .jpeg .png) 11 | 12 | # Whitelist file extensions: 13 | def validate({file, _}) do 14 | file_extension = file.file_name |> Path.extname() |> String.downcase() 15 | Enum.member?(@extension_whitelist, file_extension) 16 | end 17 | 18 | # The default `url` function has a bug where it does not include the host 19 | def full_url(speaker, version) do 20 | path = url({speaker.picture, speaker}, version) 21 | 22 | cond do 23 | is_nil(path) -> nil 24 | String.starts_with?(path, "/") -> "#{Application.get_env(:arc, :asset_host)}/#{path}" 25 | true -> path 26 | end 27 | end 28 | 29 | # Define a thumbnail transformation: 30 | def transform(:thumb, _) do 31 | {:convert, "-thumbnail 50x50^ -gravity center -extent 50x50 -format jpg", :jpg} 32 | end 33 | 34 | # Override the persisted filenames: 35 | def filename(version, {_, speaker}) do 36 | "#{speaker.id}_#{version}" 37 | end 38 | 39 | # Override the storage directory: 40 | def storage_dir(_, {_, _}) do 41 | "resources/speakers" 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /apps/db/lib/db_type/subscription_reason.ex: -------------------------------------------------------------------------------- 1 | import EctoEnum 2 | 3 | defenum( 4 | DB.Type.SubscriptionReason, 5 | :subscription_reason, 6 | [ 7 | :is_author, 8 | :manual, 9 | :suggestion 10 | ] 11 | ) 12 | -------------------------------------------------------------------------------- /apps/db/lib/db_type/user_action_type.ex: -------------------------------------------------------------------------------- 1 | import EctoEnum 2 | 3 | defenum( 4 | DB.Type.UserActionType, 5 | # Common 6 | create: 1, 7 | remove: 2, 8 | update: 3, 9 | delete: 4, 10 | add: 5, 11 | restore: 6, 12 | approve: 7, 13 | flag: 8, 14 | # Voting stuff 15 | vote_up: 9, 16 | vote_down: 10, 17 | self_vote: 11, 18 | revert_vote_up: 12, 19 | revert_vote_down: 13, 20 | revert_self_vote: 14, 21 | # Bans - See DB.Type.FlagReason for labels 22 | action_banned_bad_language: 21, 23 | action_banned_spam: 22, 24 | action_banned_irrelevant: 23, 25 | action_banned_not_constructive: 24, 26 | # Special actions 27 | email_confirmed: 100, 28 | collective_moderation: 101, 29 | start_automatic_statements_extraction: 102, 30 | upload: 110, 31 | # Flags 32 | abused_flag: 103, 33 | confirmed_flag: 104, 34 | # Deprecated 35 | action_banned: 102, 36 | social_network_linked: 105 37 | ) 38 | -------------------------------------------------------------------------------- /apps/db/lib/db_utils/string.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Utils.String do 2 | @moduledoc """ 3 | String utils not included in base library 4 | """ 5 | 6 | @doc """ 7 | Convert a string like " aaa bbb ccc " into "aaa bbb ccc" 8 | 9 | ## Examples 10 | 11 | iex> DB.Utils.String.trim_all_whitespaces " aaa bbb ccc " 12 | "aaa bbb ccc" 13 | iex> DB.Utils.String.trim_all_whitespaces "" 14 | "" 15 | """ 16 | def trim_all_whitespaces(nil), 17 | do: nil 18 | 19 | def trim_all_whitespaces(str) do 20 | str 21 | |> String.trim() 22 | |> String.replace(~r/\s+/, " ") 23 | end 24 | 25 | def upcase(nil), do: nil 26 | 27 | def upcase(str), do: String.upcase(str) 28 | end 29 | -------------------------------------------------------------------------------- /apps/db/lib/db_utils/token_generator.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Utils.TokenGenerator do 2 | @moduledoc """ 3 | Generate base64 unique tokens using :crypto.strong_rand_bytes/1 4 | """ 5 | 6 | @doc """ 7 | Generate a new token 8 | """ 9 | def generate(length) do 10 | length 11 | |> :crypto.strong_rand_bytes() 12 | |> Base.url_encode64() 13 | |> binary_part(0, length) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/db/lib/query/query.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Query do 2 | @moduledoc """ 3 | General queriying utils. 4 | """ 5 | 6 | import Ecto.Query 7 | 8 | @doc """ 9 | Revert sort by last_inserted. Fallsback on `id` in case there's an equality. 10 | """ 11 | @spec order_by_last_inserted_desc(Ecto.Queryable.t()) :: Ecto.Queryable.t() 12 | def order_by_last_inserted_desc(query) do 13 | order_by(query, desc: :inserted_at, desc: :id) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170118223600_create_postgres_extensions.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreatePostgresExtensions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute("CREATE EXTENSION IF NOT EXISTS citext;") 6 | execute("CREATE EXTENSION IF NOT EXISTS unaccent;") 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170118223631_create_users.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :username, :citext, null: false 7 | add :email, :citext, null: false 8 | add :encrypted_password, :string, null: false 9 | add :name, :citext, null: true 10 | add :picture_url, :string, null: true 11 | add :reputation, :integer, null: false, default: 0 12 | add :locale, :string, null: true 13 | 14 | # Social networks profiles 15 | add :fb_user_id, :string, null: true 16 | 17 | # Email confirmation 18 | add :email_confirmed, :boolean, null: false, default: false 19 | add :email_confirmation_token, :string, null: true 20 | 21 | timestamps() 22 | end 23 | 24 | create unique_index(:users, [:email]) 25 | create unique_index(:users, [:username]) 26 | create unique_index(:users, [:fb_user_id]) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170125235612_create_videos.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateVideo do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:videos) do 6 | add :provider, :string, null: false 7 | add :provider_id, :string, null: false 8 | add :title, :string, null: false 9 | 10 | timestamps() 11 | end 12 | create unique_index(:videos, [:provider, :provider_id]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170206062334_create_speakers.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateSpeaker do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:speakers) do 6 | add :full_name, :citext, null: false 7 | add :title, :string 8 | add :is_user_defined, :boolean, null: false 9 | add :picture, :string 10 | add :wikidata_item_id, :integer 11 | add :country, :string 12 | add :is_removed, :boolean, null: false, default: false 13 | 14 | timestamps() 15 | end 16 | create index(:speakers, :full_name, where: "is_user_defined = false") 17 | create unique_index(:speakers, :wikidata_item_id, where: "is_user_defined = false") 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170206063137_create_statements.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateStatement do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:statements) do 6 | add :text, :string, null: false 7 | add :time, :integer, null: false 8 | add :is_removed, :boolean, null: false, default: false 9 | 10 | add :video_id, references(:videos, on_delete: :delete_all), null: false 11 | add :speaker_id, references(:speakers, on_delete: :nilify_all), null: true 12 | 13 | timestamps() 14 | end 15 | create index(:statements, [:video_id], where: "is_removed = false") 16 | create index(:statements, [:speaker_id], where: "is_removed = false") 17 | 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170221035619_create_video_speaker.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateVideoSpeaker do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:videos_speakers, primary_key: false) do 6 | add :video_id, references(:videos, on_delete: :delete_all), primary_key: true 7 | add :speaker_id, references(:speakers, on_delete: :delete_all), primary_key: true 8 | 9 | timestamps() 10 | end 11 | create unique_index(:videos_speakers, [:video_id, :speaker_id], name: :videos_speakers_index) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170309214200_create_source.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateSource do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:sources) do 6 | add :url, :string, null: false 7 | add :title, :string, null: true 8 | add :language, :string, null: true 9 | add :site_name, :string, null: true 10 | 11 | timestamps() 12 | end 13 | create unique_index(:sources, :url) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170309214307_create_comment.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateComment do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:comments) do 6 | add :user_id, references(:users, on_delete: :delete_all), null: false 7 | add :statement_id, references(:statements, on_delete: :delete_all), null: false 8 | add :source_id, references(:sources, on_delete: :nilify_all), null: true 9 | add :reply_to_id, references(:comments, on_delete: :delete_all), null: true 10 | 11 | add :text, :string 12 | add :approve, :boolean 13 | add :is_banned, :boolean, null: false, default: false 14 | 15 | timestamps() 16 | end 17 | create index(:comments, [:user_id]) 18 | create index(:comments, [:statement_id], where: "is_banned = false") 19 | 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170316233954_create_vote.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateVote do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:votes, primary_key: false) do 6 | add :value, :integer, null: false 7 | 8 | add :user_id, references(:users, on_delete: :delete_all), primary_key: true 9 | add :comment_id, references(:comments, on_delete: :delete_all), primary_key: true 10 | 11 | timestamps() 12 | end 13 | create unique_index(:votes, [:user_id, :comment_id], name: "user_comment_index") 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170428062411_create_video_debate_action.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateVideoDebateAction do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:video_debate_actions) do 6 | add :user_id, references(:users, on_delete: :nothing), null: true 7 | add :video_id, references(:videos, on_delete: :nothing), null: false 8 | add :entity, :string, null: false 9 | add :entity_id, :integer, null: false 10 | add :type, :string, null: false 11 | add :changes, :map, null: true 12 | 13 | timestamps(updated_at: false) 14 | end 15 | create index(:video_debate_actions, [:user_id]) 16 | create index(:video_debate_actions, [:video_id]) 17 | create index(:video_debate_actions, [:entity, :entity_id]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170611075306_create_flag.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateFlag do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:flags) do 6 | add :type, :integer, null: false 7 | add :reason, :integer, null: false 8 | add :entity_id, :integer, null: false 9 | 10 | add :source_user_id, references(:users, on_delete: :delete_all), null: false 11 | add :target_user_id, references(:users, on_delete: :delete_all), null: false 12 | 13 | timestamps() 14 | end 15 | create index(:flags, [:source_user_id]) 16 | create index(:flags, [:target_user_id]) 17 | create unique_index(:flags, [:source_user_id, :type, :entity_id]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170726224741_create_accounts_reset_password_request.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateDB.Accounts.ResetPasswordRequest do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:accounts_reset_password_requests, primary_key: false) do 6 | add :token, :string, primary_key: true, null: false 7 | add :source_ip, :string, null: false 8 | add :user_id, references(:users, on_delete: :delete_all), null: false 9 | 10 | timestamps(updated_at: false) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170730064848_create_invitation_requests.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateInvitationRequests do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:invitation_requests) do 6 | add :email, :string, null: true 7 | add :token, :string, null: true 8 | add :invitation_sent, :boolean, default: false, null: false 9 | add :invited_by_id, references(:users, on_delete: :nilify_all), null: true 10 | 11 | timestamps() 12 | end 13 | 14 | create unique_index(:invitation_requests, [:email]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20170928043353_add_language_to_videos.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddLanguageToVideos do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:videos) do 6 | add :language, :string, size: 2 7 | end 8 | create index(:videos, :language) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171003220327_create_achievements.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateAchievements do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:achievements) do 6 | add :slug, :string 7 | add :rarity, :integer 8 | 9 | timestamps() 10 | end 11 | 12 | create unique_index(:achievements, [:slug]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171003220416_add_achievements_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddAchievementsToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :achievements, {:array, :integer}, default: [], null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171004100258_create_users_actions.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateUsersActions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users_actions) do 6 | add :user_id, references(:users, on_delete: :nothing), null: false 7 | add :target_user_id, references(:users, on_delete: :nothing), null: true 8 | 9 | add :type, :integer, null: false 10 | add :entity, :integer, null: false 11 | add :context, :string, null: true 12 | add :entity_id, :integer, null: true 13 | add :changes, :map, null: true 14 | 15 | timestamps(updated_at: false) 16 | end 17 | 18 | create index(:users_actions, [:user_id]) 19 | create index(:users_actions, [:context]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171005001838_create_users_actions_reports.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateUsersActionsReports do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users_actions_reports) do 6 | add :analyser_id, :integer, null: false 7 | add :last_action_id, :integer, null: false 8 | add :status, :integer, null: false 9 | 10 | # Various stats 11 | add :nb_actions, :integer, null: true 12 | add :nb_entries_updated, :integer, null: true 13 | add :run_duration, :integer, null: true 14 | 15 | timestamps() 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171005215001_rename_flag_type_to_flag_entity_and_remove_unused_indexes.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.RenameFlagTypeToFlagEntityAndRemoveUnusedIndexes do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename table(:flags), :type, to: :entity 6 | 7 | # Rename unique index 8 | drop unique_index(:flags, [:source_user_id, :type, :entity_id]) 9 | create unique_index(:flags, [:source_user_id, :entity, :entity_id]) 10 | 11 | # Remove unused indexes 12 | drop index(:flags, [:source_user_id]) 13 | drop index(:flags, [:target_user_id]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171009065840_delete_video_debate_action.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.DeleteVideoDebateAction do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop table(:video_debate_actions) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171026222425_add_today_reputation_gain_to_user.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddTodayReputationGainToUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :today_reputation_gain, :integer, default: 0, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171105124655_change_flags_to_flag_actions.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.ChangeFlagsToFlagActions do 2 | use Ecto.Migration 3 | 4 | alias DB.Repo 5 | 6 | 7 | @doc""" 8 | Flags used to point on entities, they will now point directly on actions 9 | 10 | [!] This will remove all old flags 11 | """ 12 | def change do 13 | # Remove all entries and deprecated indexes 14 | Repo.delete_all(DB.Schema.Flag, log: false) 15 | drop unique_index(:flags, [:source_user_id, :entity, :entity_id]) 16 | 17 | # Alter table 18 | alter table(:flags) do 19 | remove :entity 20 | remove :entity_id 21 | remove :target_user_id 22 | add :action_id, references(:users_actions, on_delete: :delete_all), null: false 23 | end 24 | 25 | # Create new unique index for user / action (1 flag per action max) 26 | create unique_index(:flags, [:source_user_id, :action_id], name: :user_flags_unique_index) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171109105152_create_moderation_users_feedbacks.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateModerationUsersFeedbacks do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:moderation_users_feedbacks) do 6 | add :value, :integer 7 | add :user_id, references(:users, on_delete: :delete_all) 8 | add :action_id, references(:users_actions, on_delete: :delete_all) 9 | 10 | timestamps() 11 | end 12 | 13 | create unique_index(:moderation_users_feedbacks, [:user_id, :action_id]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171110040302_allow_null_user_on_user_action.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AllowNullUserOnUserAction do 2 | @moduledoc""" 3 | This migration allow for null value in user_id which will represent an admin action 4 | """ 5 | 6 | use Ecto.Migration 7 | 8 | def change do 9 | alter table(:users_actions) do 10 | # No need to use a reference user as it is already referenced by previous migration. Referencing again 11 | # fails as constraint already exists 12 | modify :user_id, :integer, null: true 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171110212108_rename_comment_is_banned_to_is_reported.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.RenameCommentIsBannedToIsReported do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop index(:comments, [:statement_id], where: "is_banned = false") 6 | rename table(:comments), :is_banned, to: :is_reported 7 | create index(:comments, [:statement_id]) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171117131508_add_slug_to_speakers.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddSlugToSpeaker do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:speakers) do 6 | add :slug, :string, null: true 7 | end 8 | 9 | create unique_index(:speakers, [:slug], where: "slug != NULL") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171119075520_delete_achievements.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.DeleteAchievements do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop table(:achievements) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20171205174328_add_newsletter_to_user.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddNewsletterToUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :newsletter, :boolean, null: false, default: true 7 | add :newsletter_subscription_token, :string, null: false, default: fragment("md5(random()::text)") 8 | end 9 | 10 | create unique_index(:users, :newsletter_subscription_token) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180131002547_add_is_publisher_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddIsPublisherToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :is_publisher, :boolean, default: false, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180302024059_nilify_user_on_user_action_when_deleting.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.NilifyUserOnUserActionWhenDeleting do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop constraint("users_actions", "users_actions_user_id_fkey") 6 | drop constraint("users_actions", "users_actions_target_user_id_fkey") 7 | 8 | alter table(:users_actions) do 9 | modify :user_id, references(:users, on_delete: :nilify_all) 10 | modify :target_user_id, references(:users, on_delete: :nilify_all) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180317062636_add_unlisted_to_videos.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddUnlistedToVideos do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:videos) do 6 | add :unlisted, :boolean, default: false, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180330204602_add_og_url_to_source.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddOgUrlToSource do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:sources) do 6 | add :og_url, :string, default: nil, null: true 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180409035326_add_flag_reason_to_moderation_users_feedbacks.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddFlagReasonToModerationUsersFeedback do 2 | use Ecto.Migration 3 | import Ecto.Query 4 | alias DB.Schema.UserAction 5 | 6 | def change do 7 | # Delete all existing feedbacks 8 | DB.Repo.delete_all(DB.Schema.ModerationUserFeedback, log: false) 9 | 10 | # Add flag reason to feedbacks 11 | alter table("moderation_users_feedbacks") do 12 | add(:flag_reason, :integer, null: false) 13 | end 14 | 15 | # We also changed the way confirmed_email actions are recorded, invert 16 | # source_user_id and target_user_id 17 | UserAction 18 | |> where([a], a.type == ^:email_confirmed) 19 | |> where([a], is_nil(a.target_user_id)) 20 | |> select([:id, :type, :user_id, :target_user_id]) 21 | |> DB.Repo.all() 22 | |> Enum.map(&invert_source_and_target_users/1) 23 | end 24 | 25 | defp invert_source_and_target_users(action = %{user_id: src_usr_id}) do 26 | action 27 | |> Ecto.Changeset.change(user_id: nil, target_user_id: src_usr_id) 28 | |> DB.Repo.update(log: false) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180503083056_add_locale_to_invitation_request.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddLocaleToInvitationRequest do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:invitation_requests) do 6 | add :locale, :string, null: false, default: "en" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180516170544_add_is_partner_to_video.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddIsPartnerToVideo do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:videos) do 6 | add :is_partner, :boolean, default: false, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180605085958_add_completed_onboarding_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddCompletedOnboardingToUsers do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table "users" do 6 | add :completed_onboarding_steps, {:array, :integer}, null: false, default: [] 7 | end 8 | end 9 | 10 | def down do 11 | alter table "users" do 12 | remove :completed_onboarding_steps 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180605144832_add_speaker_to_user.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddSpeakerToUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :speaker_id, references(:speakers, on_delete: :nilify_all), null: true, default: nil 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180730092029_allow_null_user_for_comment.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AllowNullUserForComment do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop constraint("comments", "comments_user_id_fkey") 6 | 7 | alter table(:comments) do 8 | modify :user_id, references(:users, on_delete: :nilify_all), null: true 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180801105246_guardiandb.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Repo.Migrations.Guardian.DB do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:guardian_tokens, primary_key: false) do 6 | add(:jti, :string, primary_key: true) 7 | add(:aud, :string, primary_key: true) 8 | add(:typ, :string) 9 | add(:iss, :string) 10 | add(:sub, :string) 11 | add(:exp, :bigint) 12 | add(:jwt, :text) 13 | add(:claims, :map) 14 | timestamps() 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180802155107_make_speaker_slug_case_insensitive.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.MakeSpeakerSlugCaseInsensitive do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table("speakers") do 6 | modify :slug, :citext 7 | end 8 | end 9 | 10 | def down do 11 | alter table("speakers") do 12 | modify :slug, :string 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180803143819_increase_max_comment_text_length.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.IncreaseMaxCommentTextLength do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:comments) do 6 | modify :text, :string, size: 512 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180816112748_change_wikidata_item_id_type_to_string.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.ChangeWikidataItemIdTypeToString do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute(""" 6 | ALTER TABLE speakers 7 | ALTER COLUMN wikidata_item_id TYPE character varying(255) 8 | USING 'Q' || wikidata_item_id; 9 | """) 10 | end 11 | 12 | def down do 13 | execute(""" 14 | ALTER TABLE speakers 15 | ALTER COLUMN wikidata_item_id TYPE integer 16 | USING (substring(wikidata_item_id from 2))::integer; 17 | """) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180816115534_remove_speaker_is_user_defined_column.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.RemoveSpeakerIsUserDefinedColumn do 2 | use Ecto.Migration 3 | 4 | def up do 5 | # Drop indexes 6 | drop(index(:speakers, :full_name)) 7 | drop(index(:speakers, :wikidata_item_id)) 8 | drop(unique_index(:speakers, :slug)) 9 | 10 | # Remove column 11 | alter table(:speakers) do 12 | remove(:is_user_defined) 13 | remove(:is_removed) 14 | end 15 | 16 | # Re-create indexes 17 | create index(:speakers, :full_name) 18 | create unique_index(:speakers, :wikidata_item_id) 19 | create unique_index(:speakers, :slug, where: "slug IS NOT NULL") 20 | end 21 | 22 | def down do 23 | # Drop indexes 24 | drop(index(:speakers, :full_name)) 25 | drop(index(:speakers, :wikidata_item_id)) 26 | drop(unique_index(:speakers, :slug)) 27 | 28 | # Re-add columns 29 | alter table(:speakers) do 30 | add(:is_user_defined, :boolean, null: false, default: true) 31 | add :is_removed, :boolean, null: false, default: false 32 | end 33 | 34 | # Re-create indexes 35 | create index(:speakers, :full_name, where: "is_user_defined = false") 36 | create unique_index(:speakers, :wikidata_item_id, where: "is_user_defined = false") 37 | create unique_index(:speakers, :slug, where: "slug IS NOT NULL") 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20180827123706_add_hash_id_to_videos.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddHashIdToVideos do 2 | use Ecto.Migration 3 | import Ecto.Query 4 | alias DB.Schema.Video 5 | 6 | def up do 7 | alter table(:videos) do 8 | # A size of 10 allows us to go up to 100_000_000_000_000 videos 9 | add(:hash_id, :string, size: 10) 10 | end 11 | 12 | # Create unique index on hash_id 13 | create(unique_index(:videos, [:hash_id])) 14 | 15 | # Flush pending migrations to ensure column is created 16 | flush() 17 | 18 | # Update all existing videos with their hashIds 19 | Video 20 | |> select([:id]) 21 | |> DB.Repo.all() 22 | |> Enum.map(&Video.changeset_generate_hash_id/1) 23 | |> Enum.map(&DB.Repo.update/1) 24 | end 25 | 26 | def down do 27 | alter table(:videos) do 28 | remove(:hash_id) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20181010105152_create_video_captions.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateVideoCaptions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:videos_captions) do 6 | add(:video_id, references(:videos, on_delete: :delete_all)) 7 | add(:content, :text, null: false) 8 | add(:format, :string, null: false) 9 | timestamps() 10 | end 11 | 12 | create(index(:videos_captions, [:video_id])) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20181109223648_increate_source_max_url_length.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.IncreateSourceMaxUrlLength do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:sources) do 6 | modify(:url, :string, size: 2048) 7 | end 8 | end 9 | 10 | def down do 11 | Ecto.Adapters.SQL.query!(DB.Repo, """ 12 | DELETE FROM sources WHERE LENGTH(url) > 255; 13 | """) 14 | 15 | alter table(:sources) do 16 | modify(:url, :string) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20181109233422_add_file_type_to_source.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.AddFileTypeToSource do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:sources) do 6 | add(:file_mime_type, :string, null: true) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20181209205427_videos_providers_as_columns.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.VideosProvidersAsColumns do 2 | @moduledoc """ 3 | This migration changes the `Videos` schema to go from a pair 4 | of {provider, provider_id} to a model where we have multiple `{provider}_id` 5 | column. This will allow to store multiple sources for a single video, with 6 | different offsets to ensure they're all in sync. 7 | """ 8 | 9 | use Ecto.Migration 10 | 11 | def up do 12 | # Add new columns 13 | alter table(:videos) do 14 | add(:youtube_id, :string, null: true, length: 11) 15 | add(:youtube_offset, :integer, null: false, default: 0) 16 | end 17 | 18 | flush() 19 | 20 | # Migrate existing videos - we only have YouTube right now 21 | Ecto.Adapters.SQL.query!(DB.Repo, """ 22 | UPDATE videos SET youtube_id = provider_id; 23 | """) 24 | 25 | flush() 26 | 27 | # Create index 28 | create(unique_index(:videos, :youtube_id)) 29 | 30 | # Remove columns 31 | alter table(:videos) do 32 | remove(:provider) 33 | remove(:provider_id) 34 | end 35 | end 36 | 37 | def down do 38 | # Restore old scheme 39 | alter table(:videos) do 40 | add(:provider, :string) 41 | add(:provider_id, :string) 42 | end 43 | 44 | flush() 45 | 46 | # Migrate existing videos 47 | Ecto.Adapters.SQL.query!(DB.Repo, """ 48 | UPDATE videos SET provider_id = youtube_id, provider = 'youtube'; 49 | """) 50 | 51 | flush() 52 | 53 | # Re-create index 54 | create(unique_index(:videos, [:provider, :provider_id])) 55 | 56 | # Remove columns 57 | alter table(:videos) do 58 | remove(:youtube_id) 59 | remove(:youtube_offset) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20190110165430_create_subscriptions.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.Createsubscriptions do 2 | use Ecto.Migration 3 | 4 | def up do 5 | DB.Type.SubscriptionReason.create_type() 6 | 7 | create table(:subscriptions) do 8 | add(:user_id, references(:users, on_delete: :delete_all), null: false) 9 | 10 | add(:video_id, references(:videos, on_delete: :delete_all), null: false) 11 | add(:statement_id, references(:statements, on_delete: :delete_all)) 12 | add(:comment_id, references(:comments, on_delete: :delete_all)) 13 | add(:scope, :integer, null: false) 14 | 15 | add(:reason, :subscription_reason) 16 | add(:is_subscribed, :boolean, default: true, null: false) 17 | end 18 | 19 | create(unique_index(:subscriptions, [:user_id, :video_id, :statement_id, :comment_id])) 20 | end 21 | 22 | def down do 23 | drop(table(:subscriptions)) 24 | DB.Type.SubscriptionReason.drop_type() 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20190110171405_create_notifications.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.CreateNotifications do 2 | use Ecto.Migration 3 | 4 | def up do 5 | DB.Type.NotificationType.create_type() 6 | 7 | create table(:notifications) do 8 | add(:user_id, references(:users, on_delete: :delete_all), null: false) 9 | add(:action_id, references(:users_actions, on_delete: :delete_all), null: false) 10 | add(:type, :notification_type, null: false) 11 | add(:seen_at, :utc_datetime, null: true, default: nil) 12 | 13 | timestamps() 14 | end 15 | 16 | create(index(:notifications, [:user_id, :action_id])) 17 | end 18 | 19 | def down do 20 | drop(index(:notifications, [:user_id, :action_id])) 21 | drop(table(:notifications)) 22 | DB.Type.NotificationType.drop_type() 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20200224124536_add-facebook-id-to-videos.exs: -------------------------------------------------------------------------------- 1 | defmodule :"Elixir.DB.Repo.Migrations.Add-facebook-id-to-videos" do 2 | use Ecto.Migration 3 | 4 | def up do 5 | # Add new columns 6 | alter table(:videos) do 7 | add(:facebook_id, :string, null: true) 8 | add(:facebook_offset, :integer, null: false, default: 0) 9 | end 10 | 11 | # Create index 12 | create(unique_index(:videos, :facebook_id)) 13 | end 14 | 15 | def down do 16 | # Remove columns 17 | alter table(:videos) do 18 | remove(:facebook_id) 19 | remove(:facebook_offset) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20200224211412_add-thumbnail-to-videos.exs: -------------------------------------------------------------------------------- 1 | defmodule :"Elixir.DB.Repo.Migrations.Add-thumbnail-to-videos" do 2 | use Ecto.Migration 3 | 4 | def up do 5 | # Add new columns 6 | alter table(:videos) do 7 | add(:thumbnail, :string, null: true) 8 | end 9 | 10 | flush() 11 | 12 | Ecto.Adapters.SQL.query!(DB.Repo, """ 13 | UPDATE videos 14 | SET thumbnail = 'https://img.youtube.com/vi/' || youtube_id || '/hqdefault.jpg' 15 | WHERE youtube_id IS NOT NULL; 16 | """) 17 | end 18 | 19 | def down do 20 | # Remove columns 21 | alter table(:videos) do 22 | remove(:thumbnail) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20210930122534_increase_statement_max_length.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.IncreaseStatementMaxLength do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:statements) do 6 | modify :text, :string, size: 280 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20240618055503_update_video_captions.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Repo.Migrations.UpdateVideoCaptions do 2 | use Ecto.Migration 3 | 4 | def up do 5 | # Delete all values (there are none in prod) 6 | execute("DELETE FROM videos_captions") 7 | 8 | # Drop column :content in favor of raw + parsed 9 | alter table(:videos_captions) do 10 | remove(:content) 11 | add(:raw, :text, null: false) 12 | add(:parsed, {:array, :map}, null: false) 13 | end 14 | end 15 | 16 | def down do 17 | # Drop raw + parsed in favor of :content 18 | alter table(:videos_captions) do 19 | remove(:raw) 20 | remove(:parsed) 21 | add(:content, :text, null: false) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /apps/db/priv/repo/migrations/20240915080224_add-draft-to-statements.exs: -------------------------------------------------------------------------------- 1 | defmodule :"Elixir.DB.Repo.Migrations.Add-draft-to-statements" do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:statements) do 6 | add :is_draft, :boolean, default: false, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /apps/db/priv/repo/seed_with_csv.exs: -------------------------------------------------------------------------------- 1 | defmodule SeedWithCSV do 2 | require Logger 3 | 4 | @nb_threads 1 5 | @timeout 60_000 6 | 7 | def seed(filename, insert_func, insert_func_args, columns_mapping) do 8 | if File.exists?(filename), 9 | do: do_seed(filename, insert_func, insert_func_args, columns_mapping), 10 | else: Logger.error("File #{filename} doesn't exists") 11 | end 12 | 13 | defp do_seed(filename, insert_func, insert_func_args, columns_mapping) do 14 | filename 15 | |> File.stream!() 16 | |> CSV.decode(headers: true) 17 | |> Task.async_stream( 18 | &build_and_insert(&1, insert_func, insert_func_args, columns_mapping), 19 | max_concurrency: @nb_threads, 20 | timeout: @timeout 21 | ) 22 | |> Enum.to_list() 23 | end 24 | 25 | defp build_and_insert(entry, insert_func, insert_func_args, columns_mapping) do 26 | changes = 27 | entry 28 | |> Enum.filter(fn {key, _} -> Map.has_key?(columns_mapping, key) end) 29 | |> Enum.map(fn {key, value} -> 30 | case Map.get(columns_mapping, key) do 31 | key when is_atom(key) or is_binary(key) -> 32 | {key, value} 33 | 34 | {key, func} when is_atom(key) or (is_binary(key) and is_function(func)) -> 35 | {key, func.(value)} 36 | end 37 | end) 38 | |> Enum.into(%{}) 39 | 40 | insert_func.(changes, insert_func_args) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /apps/db/priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | alias DB.Repo 2 | alias DB.Schema.User 3 | require Logger 4 | 5 | # Create Admin in dev or if we're running image locally 6 | if Application.get_env(:db, :env) == :dev do 7 | Logger.warn("API is running in dev mode. Inserting default user admin@captainfact.io") 8 | 9 | admin = 10 | User.registration_changeset(%User{reputation: 4200, username: "Captain"}, %{ 11 | email: "admin@captainfact.io", 12 | password: "password" 13 | }) 14 | 15 | # No need to warn if already exists 16 | Repo.insert(admin) 17 | end 18 | -------------------------------------------------------------------------------- /apps/db/priv/secrets/db_hostname: -------------------------------------------------------------------------------- 1 | localhost -------------------------------------------------------------------------------- /apps/db/priv/secrets/db_name: -------------------------------------------------------------------------------- 1 | captain_fact_dev -------------------------------------------------------------------------------- /apps/db/priv/secrets/db_password: -------------------------------------------------------------------------------- 1 | postgres -------------------------------------------------------------------------------- /apps/db/priv/secrets/db_username: -------------------------------------------------------------------------------- 1 | postgres -------------------------------------------------------------------------------- /apps/db/test/db_schema/flag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.FlagTest do 2 | use DB.DataCase, async: true 3 | 4 | alias DB.Schema.Flag 5 | 6 | test "changeset with valid attributes" do 7 | changeset = Flag.changeset(%Flag{source_user_id: 42}, %{reason: 1, action_id: 42}) 8 | assert changeset.valid? 9 | end 10 | 11 | test "reason cannot be anything" do 12 | changeset = Flag.changeset(%Flag{source_user_id: 42}, %{reason: 0, action_id: 42}) 13 | refute changeset.valid? 14 | 15 | changeset = Flag.changeset(%Flag{source_user_id: 42}, %{reason: 4500, action_id: 42}) 16 | refute changeset.valid? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /apps/db/test/db_schema/moderation_user_feedback_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.ModerationUserFeedbackTest do 2 | use DB.DataCase, async: true 3 | alias DB.Schema.ModerationUserFeedback 4 | import DB.Schema.ModerationUserFeedback, only: [changeset: 2] 5 | 6 | @valid_attrs %{value: 1, flag_reason: 1} 7 | @base_feedback %ModerationUserFeedback{user_id: 1, action_id: 1} 8 | 9 | test "changeset with valid attributes" do 10 | assert changeset(@base_feedback, @valid_attrs).valid? 11 | end 12 | 13 | test "feedback value can only be +1, 0 or -1" do 14 | assert {:value, "must be greater than or equal to -1"} in errors_on(@base_feedback, %{ 15 | value: -2 16 | }) 17 | 18 | assert {:value, "must be less than or equal to 1"} in errors_on(@base_feedback, %{value: 10}) 19 | assert {:value, "is invalid"} in errors_on(@base_feedback, %{value: "Hello"}) 20 | end 21 | 22 | test "reason cannot be anything" do 23 | refute changeset(@base_feedback, %{@valid_attrs | flag_reason: 5000}).valid? 24 | refute changeset(@base_feedback, %{@valid_attrs | flag_reason: 0}).valid? 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /apps/db/test/db_schema/notification_test.exs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /apps/db/test/db_schema/statement_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.StatementTest do 2 | use DB.DataCase, async: true 3 | 4 | alias DB.Schema.Statement 5 | 6 | @valid_attrs %{ 7 | text: "Be proud of you Because you can be do what we want to do !", 8 | time: 42, 9 | speaker_id: 3, 10 | video_id: 2 11 | } 12 | @invalid_attrs %{} 13 | 14 | test "changeset with valid attributes" do 15 | changeset = Statement.changeset(%Statement{}, @valid_attrs) 16 | assert changeset.valid? 17 | end 18 | 19 | test "changeset with invalid attributes" do 20 | changeset = Statement.changeset(%Statement{}, @invalid_attrs) 21 | refute changeset.valid? 22 | end 23 | 24 | test "time cannot be negative" do 25 | attrs = Map.put(@valid_attrs, :time, -1) 26 | changeset = Statement.changeset(%Statement{}, attrs) 27 | refute changeset.valid? 28 | end 29 | 30 | test "text cannot be empty" do 31 | assert {:text, "can't be blank"} in errors_on(%Statement{}, %{text: ""}) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /apps/db/test/db_schema/user_action_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.UserActionTest do 2 | use DB.DataCase, async: true 3 | 4 | alias DB.Schema.UserAction 5 | 6 | @valid_attrs %{ 7 | user_id: 1, 8 | entity: :statement, 9 | comment_id: 42, 10 | type: :update, 11 | changes: %{ 12 | text: "Updated !" 13 | } 14 | } 15 | @invalid_attrs %{} 16 | 17 | test "changeset with valid attributes" do 18 | changeset = UserAction.changeset(%UserAction{}, @valid_attrs) 19 | assert changeset.valid? 20 | end 21 | 22 | test "changeset with invalid attributes" do 23 | changeset = UserAction.changeset(%UserAction{}, @invalid_attrs) 24 | refute changeset.valid? 25 | end 26 | 27 | test "entity cannot be anything" do 28 | attrs = Map.put(@valid_attrs, :entity, "Not a valid entity !") 29 | changeset = UserAction.changeset(%UserAction{}, attrs) 30 | refute changeset.valid? 31 | end 32 | 33 | test "action type must be create, remove, update, delete, or add" do 34 | attrs = Map.put(@valid_attrs, :type, "invalid_action_type") 35 | changeset = UserAction.changeset(%UserAction{}, attrs) 36 | refute changeset.valid? 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /apps/db/test/db_schema/vote_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Schema.VoteTest do 2 | use DB.DataCase, async: true 3 | doctest DB.Schema.Vote 4 | 5 | alias DB.Schema.Vote 6 | 7 | @valid_attrs %{user_id: 1, comment_id: 1, value: 1} 8 | @invalid_attrs %{} 9 | 10 | test "changeset with valid attributes" do 11 | changeset = Vote.changeset(%Vote{}, @valid_attrs) 12 | assert changeset.valid? 13 | 14 | changeset = Vote.changeset(%Vote{}, Map.put(@valid_attrs, :value, -1)) 15 | assert changeset.valid? 16 | end 17 | 18 | test "changeset with invalid attributes" do 19 | changeset = Vote.changeset(%Vote{}, @invalid_attrs) 20 | refute changeset.valid? 21 | end 22 | 23 | test "vote value can only be +1 or -1" do 24 | assert {:value, "is invalid"} in errors_on(%Vote{}, %{value: -2}) 25 | assert {:value, "is invalid"} in errors_on(%Vote{}, %{value: 10}) 26 | assert {:value, "is invalid"} in errors_on(%Vote{}, %{value: "Hello"}) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /apps/db/test/db_type/achievement_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Type.AchievementTest do 2 | use ExUnit.Case 3 | 4 | alias DB.Type.Achievement 5 | 6 | test "ensure values don't change" do 7 | assert Achievement.get(:welcome) == 1 8 | assert Achievement.get(:not_a_robot) == 2 9 | assert Achievement.get(:help) == 3 10 | assert Achievement.get(:bulletproof) == 4 11 | assert Achievement.get(:you_are_fake_news) == 5 12 | assert Achievement.get(:social_networks) == 6 13 | assert Achievement.get(:ambassador) == 7 14 | # Made a bug report 15 | assert Achievement.get(:ghostbuster) == 8 16 | # Leaderboard 17 | assert Achievement.get(:famous) == 9 18 | end 19 | 20 | test "doesn't compile if bad value" do 21 | assert_raise FunctionClauseError, fn -> 22 | Achievement.get(:nopenopenope) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /apps/db/test/db_type/user_picture.ex: -------------------------------------------------------------------------------- 1 | defmodule DB.Type.UserPictureTest do 2 | use DB.DataCase, async: true 3 | doctest DB.Schema.User 4 | 5 | import DB.Factory, only: [insert: 1, insert: 2] 6 | alias DB.Schema.User 7 | 8 | test "defaults to gravatar" do 9 | user = insert(:user, picture_url: nil) 10 | email_md5 = :crypto.hash(:md5, user.email) |> Base.encode16(case: :lower) 11 | 12 | assert DB.Type.UserPicture.default_url(:thumb, user) == 13 | "https://gravatar.com/avatar/#{email_md5}.jpg?size=94&d=robohash" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /apps/db/test/db_type/video_hash_id_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Type.VideoHashIdTest do 2 | use ExUnit.Case 3 | use ExUnitProperties 4 | 5 | alias DB.Type.VideoHashId 6 | 7 | doctest VideoHashId 8 | 9 | @nb_ids_to_test 1_000 10 | 11 | @tag timeout: 3_600_000 12 | test "ensure there is no collision" do 13 | start = 1 14 | range = start..(start + @nb_ids_to_test) 15 | 16 | uniq_generated_ids = 17 | range 18 | |> Enum.map(&VideoHashId.encode/1) 19 | |> Enum.into(MapSet.new()) 20 | 21 | assert Enum.count(uniq_generated_ids) == Enum.count(range) 22 | end 23 | 24 | property "should work with any integer" do 25 | check(all(id <- id_generator(), do: assert(String.length(VideoHashId.encode(id)) >= 4))) 26 | end 27 | 28 | defp id_generator do 29 | ExUnitProperties.gen all(id <- integer()) do 30 | abs(id) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /apps/db/test/db_utils/string_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DB.Utils.StringTest do 2 | use ExUnit.Case 3 | doctest DB.Utils.String 4 | end 5 | -------------------------------------------------------------------------------- /apps/db/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Start everything 2 | 3 | Faker.start() 4 | 5 | Ecto.Adapters.SQL.Sandbox.mode(DB.Repo, {:shared, self()}) 6 | {:ok, _} = Application.ensure_all_started(:ex_machina) 7 | 8 | ExUnit.start() 9 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | import_config "../apps/*/config/config.exs" 4 | import_config "./*.secret.exs" # TODO should filter by env 5 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CF.Umbrella.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | version: "1.2.0", 7 | apps_path: "apps", 8 | deps_path: "deps", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | aliases: aliases(), 13 | test_coverage: [tool: ExCoveralls], 14 | preferred_cli_env: [ 15 | coveralls: :test, 16 | "coveralls.detail": :test, 17 | "coveralls.post": :test, 18 | "coveralls.html": :test 19 | ], 20 | releases: [ 21 | full_app: [ 22 | applications: [ 23 | cf_reverse_proxy: :permanent, 24 | cf_jobs: :permanent 25 | ] 26 | ] 27 | ] 28 | ] 29 | end 30 | 31 | defp deps do 32 | [ 33 | # ---- Test and Dev 34 | {:excoveralls, "~> 0.12.1", only: :test}, 35 | {:credo, "~> 1.7.5", only: [:dev, :test], runtime: false}, 36 | {:mix_test_watch, "~> 1.1", only: :dev, runtime: false} 37 | ] 38 | end 39 | 40 | defp aliases do 41 | [ 42 | "ecto.setup": ["ecto.create", "ecto.migrate", "ecto.seed"], 43 | "ecto.seed": ["run apps/db/priv/repo/seeds.exs"], 44 | "ecto.reset": ["ecto.drop", "ecto.setup"], 45 | test: ["ecto.create --quiet", "ecto.migrate", "test"] 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /scripts/download-captions.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # A simple script that uses yt-dlp to download captions 3 | # Usage: ./download-captions.sh [--locale=locale] 4 | 5 | 6 | 7 | 8 | # Formats: vtt/ttml/srv3/srv2/srv1/json3 9 | 10 | yt-dlp --write-auto-sub --skip-download --sub-langs fr --sub-format srv1 --output "subtitles.%(ext)s" "$1" -------------------------------------------------------------------------------- /scripts/run_e2e_ci.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd "$(dirname "$(realpath "$0")")"/.. 4 | 5 | # Start API 6 | cd ./api 7 | mix run --no-halt & 8 | 9 | # Start Frontend 10 | cd ../frontend 11 | npm run dev & 12 | 13 | # Waiting for API to be ready 14 | timeout 1m bash -c "until curl localhost:4000; do sleep 1; done" 15 | 16 | # Waiting for Frontend to be ready 17 | timeout 1m bash -c "until curl localhost:3333 > /dev/null; do sleep 1; done" 18 | 19 | # Run tests 20 | npm run cypress 21 | -------------------------------------------------------------------------------- /scripts/test_release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Build the release and test it against a dev environment. Yay! 3 | # ------------------------------------------------------------------ 4 | 5 | export MIX_ENV=prod 6 | export CF_SECRET_KEY_BASE="8C6FsJwjV11d+1WPUIbkEH6gB/VavJrcXWoPLujgpclfxjkLkoNFSjVU9XfeNm6s" 7 | export CF_HOST=localhost 8 | export CF_DB_HOSTNAME=localhost 9 | export CF_DB_USERNAME=postgres 10 | export CF_DB_PASSWORD=postgres 11 | export CF_DB_NAME=captain_fact_dev 12 | export CF_FACEBOOK_APP_ID=xxxxxxxxxxxxxxxxxxxx 13 | export CF_FACEBOOK_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 14 | export CF_FRONTEND_URL="http://localhost:3333" 15 | export CF_CHROME_EXTENSION_ID="chrome-extension://fnnhlmbnlbgomamcolcpgncflofhjckm" 16 | export CF_PORT=4242 17 | 18 | # With Mix 19 | # mix release --overwrite 20 | # _build/prod/rel/full_app/bin/full_app start 21 | 22 | # With Docker 23 | docker build -t cf-test-release . 24 | docker run \ 25 | -e MIX_ENV \ 26 | -e CF_SECRET_KEY_BASE \ 27 | -e CF_HOST \ 28 | -e CF_DB_HOSTNAME \ 29 | -e CF_DB_USERNAME \ 30 | -e CF_DB_PASSWORD \ 31 | -e CF_DB_NAME \ 32 | -e CF_FACEBOOK_APP_ID \ 33 | -e CF_FACEBOOK_APP_SECRET \ 34 | -e CF_FRONTEND_URL \ 35 | -e CF_CHROME_EXTENSION_ID \ 36 | -e CF_PORT \ 37 | --network="host" \ 38 | cf-test-release 39 | --------------------------------------------------------------------------------